mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
feat(capture): record whole packets of an account, a character or everything on every server
Staff arm a packet capture on the dashboard; master passes MESSAGE_CAPTURE_CONTROL ARM to every world, auth and chat and arms its own. Each server's PacketCapture tap (dServer receive, and a send hook in RakPeer::Send so replica constructions are seen too) records into one preallocated chunk per server and ships sealed chunks through master on the main loop when capture_flush_bytes or capture_flush_interval_ms is reached; past capture_buffer_max_mb the oldest chunks are dropped and the dashboard records a gap. Nothing is armed: one flag check. - targets: an account (from its login; packets before the login are kept per connection and added once auth or the world knows whose they are), a character (from when it is picked), or everything; up to 8 at once (a bit each in the record mask) - worlds and auth record their clients' packets and the master link messages of a captured player (session keys by name, zone transfers by request, player added/removed, migration); chat finds the player in each packet; master records server traffic for everything - secrets are never recorded: structs that carry them (login request, login response user key, world validation session key, session key messages between servers) are read, blanked and written again before recording; auth keeps only the handshake and login - PacketDecoder: a registry by service and message id names every packet and decodes the registered structs; CaptureBundle is the file format (DLUBNDL1, metadata, records); CaptureTools orders records on one timeline, pulls movement out, makes bundles portable or anonymous and diffs replays - the dashboard keeps packet captures in message_capture_sessions (capture_kind 1) and their packets in a file under capture_dir, one write per batch; arming is audited - MESSAGE_CAPTURE_CONTROL/DATA only gain appended enum values and trailing fields Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "Profiler.h"
|
||||
#include <optional>
|
||||
#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);
|
||||
|
||||
@@ -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<MasterPackets::PlayerAdded>(Master::PLAYER_ADDED, OnPlayerAdded);
|
||||
handlers.On<MasterPackets::PlayerRemoved>(Master::PLAYER_REMOVED, OnPlayerRemoved);
|
||||
handlers.On<PlayerPositions>(Master::PLAYER_POSITIONS, [](const PlayerPositions& positions, const SystemAddress&) { LiveWorld::HandlePlayerPositions(positions); });
|
||||
handlers.On<MessageCaptureData>(Master::MESSAGE_CAPTURE_DATA, [](const MessageCaptureData& data, const SystemAddress&) { Inspector::HandleData(data); });
|
||||
handlers.On<MessageCaptureData>(Master::MESSAGE_CAPTURE_DATA, [](const MessageCaptureData& data, const SystemAddress&) {
|
||||
if (data.status == eMessageCaptureStatus::PACKETS) CaptureReplay::HandleData(data);
|
||||
else Inspector::HandleData(data);
|
||||
});
|
||||
handlers.On<DataChanged>(Master::DATA_CHANGED, [](const DataChanged& changed, const SystemAddress&) { BroadcastDataChanged(changed); });
|
||||
handlers.On<PlayerActionResult>(Master::PLAYER_ACTION_RESULT, [](const PlayerActionResult& result, const SystemAddress&) { PlayerActions::HandleResult(result); });
|
||||
handlers.On<MasterPackets::WorldShutDown>(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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
665
dDashboardServer/routes/CaptureReplay.cpp
Normal file
665
dDashboardServer/routes/CaptureReplay.cpp
Normal file
@@ -0,0 +1,665 @@
|
||||
#include "CaptureReplay.h"
|
||||
#include "MasterPackets.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <ctime>
|
||||
#include <deque>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <regex>
|
||||
#include <set>
|
||||
|
||||
#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<CaptureBundle::Record> recent;
|
||||
std::map<std::string, uint64_t> perSource;
|
||||
bool changed{};
|
||||
bool dirty{};
|
||||
};
|
||||
|
||||
std::map<uint32_t, Capture> 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<int64_t>(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<std::chrono::seconds>(c.until - Clock::now()).count();
|
||||
if (left <= 0) return;
|
||||
c.control.action = eMessageCaptureControl::ARM;
|
||||
c.control.seconds = static_cast<uint32_t>(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<std::streamsize>(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<eCaptureSource>(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<uint32_t>(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<Session> FindSession(const HTTPContext& context, HTTPReply& reply) {
|
||||
const auto id = PathId<uint64_t>(context.path, 3);
|
||||
std::optional<Session> 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<uint32_t>(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<uint32_t>(account["id"].get<std::string>()).value_or(0) : account["id"].get<uint32_t>();
|
||||
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<uint32_t> 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::microseconds>(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<uint16_t>(data.zoneId);
|
||||
gap.instanceId = static_cast<uint16_t>(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<size_t>(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<uint8_t()>([] { 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<std::string> 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<LWOOBJID>(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<uint32_t>() : GeneralUtils::TryParse<uint32_t>(row["id"].get<std::string>()).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<uint8_t> 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<std::streamsize>(header.size()));
|
||||
c.file.flush();
|
||||
|
||||
c.control.captureId = static_cast<uint32_t>(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<uint32_t>(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=<part of a name>&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<size_t>(QueryValue(context.queryString, "offset")).value_or(0);
|
||||
const auto limit = std::clamp<size_t>(GeneralUtils::TryParse<size_t>(QueryValue(context.queryString, "limit")).value_or(PAGE_DEFAULT), 1, PAGE_MAX);
|
||||
auto q = QueryValue(context.queryString, "q");
|
||||
for (auto& ch : q) ch = static_cast<char>(std::toupper(static_cast<unsigned char>(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<double>(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<size_t>(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<uint32_t> zones;
|
||||
for (const auto& t : tracks) zones.insert(t.zoneId);
|
||||
auto zone = GeneralUtils::TryParse<uint32_t>(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<int>())] = (*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\"");
|
||||
});
|
||||
}
|
||||
}
|
||||
40
dDashboardServer/routes/CaptureReplay.h
Normal file
40
dDashboardServer/routes/CaptureReplay.h
Normal file
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
#include "DashboardRoutes.h"
|
||||
#include "Game.h"
|
||||
#include "InspectorFormat.h"
|
||||
#include "CaptureReplay.h"
|
||||
#include <filesystem>
|
||||
#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<InspectorFormat::StoredSession> 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<uint32_t>(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),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<uint8_t>(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<uint32_t>(s.kind), s.target);
|
||||
auto last = ExecuteSelect("SELECT LAST_INSERT_ID() AS id;"); // this connection's insert
|
||||
return last->next() ? last->getUInt64("id") : 0;
|
||||
}
|
||||
|
||||
@@ -56,16 +56,18 @@ namespace {
|
||||
s.messageCount = static_cast<uint64_t>(r.getInt64Field("message_count"));
|
||||
s.byteCount = static_cast<uint64_t>(r.getInt64Field("byte_count"));
|
||||
s.dropped = static_cast<uint64_t>(r.getInt64Field("dropped"));
|
||||
s.kind = static_cast<uint8_t>(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<uint32_t>(s.kind), s.target);
|
||||
auto [_, last] = ExecuteSelect("SELECT last_insert_rowid() AS id;"); // this connection's insert
|
||||
return last.eof() ? 0 : static_cast<uint64_t>(last.getInt64Field("id"));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "Profiler.h"
|
||||
#include "master/PlayerAction.h"
|
||||
#include "PacketCapture.h"
|
||||
#include "master/DashboardMessages.h"
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
@@ -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<DataChanged>(Master::DATA_CHANGED, ForwardWorldToDashboard<DataChanged>);
|
||||
handlers.On<MessageCaptureControl>(Master::MESSAGE_CAPTURE_CONTROL, OnMessageCaptureControl);
|
||||
handlers.On<MessageCaptureData>(Master::MESSAGE_CAPTURE_DATA, ForwardWorldToDashboard<MessageCaptureData>);
|
||||
handlers.On<MessageCaptureData>(Master::MESSAGE_CAPTURE_DATA, OnMessageCaptureData);
|
||||
handlers.On<RequestServerList>(Master::REQUEST_SERVER_LIST, OnRequestServerList);
|
||||
handlers.On<ServerTraffic>(Master::SERVER_TRAFFIC, OnServerTraffic);
|
||||
handlers.On<ProfileRequest>(Master::PROFILE_REQUEST, OnProfileRequest);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "AuthPackets.h"
|
||||
#include "PacketCapture.h"
|
||||
#include "MasterPackets.h"
|
||||
|
||||
#include <ctime>
|
||||
@@ -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());
|
||||
|
||||
@@ -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"
|
||||
|
||||
104
dNet/CaptureBundle.h
Normal file
104
dNet/CaptureBundle.h
Normal file
@@ -0,0 +1,104 @@
|
||||
#ifndef __CAPTUREBUNDLE__H__
|
||||
#define __CAPTUREBUNDLE__H__
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#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<Record> records;
|
||||
};
|
||||
|
||||
inline std::string Header(const nlohmann::json& meta) {
|
||||
const auto text = meta.dump();
|
||||
std::string out(MAGIC);
|
||||
const auto length = static_cast<uint32_t>(text.size());
|
||||
out.append(reinterpret_cast<const char*>(&length), sizeof(length));
|
||||
out += text;
|
||||
return out;
|
||||
}
|
||||
|
||||
inline void AppendRecord(std::string& out, const Record& record) {
|
||||
auto header = record.header;
|
||||
header.length = static_cast<uint32_t>(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<char>(file)), std::istreambuf_iterator<char>());
|
||||
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<std::streamsize>(out.size()));
|
||||
return static_cast<bool>(file);
|
||||
}
|
||||
}
|
||||
|
||||
#endif //!__CAPTUREBUNDLE__H__
|
||||
253
dNet/CaptureTools.cpp
Normal file
253
dNet/CaptureTools.cpp
Normal file
@@ -0,0 +1,253 @@
|
||||
#include "CaptureTools.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <set>
|
||||
|
||||
#include "PacketDecoder.h"
|
||||
#include "ServiceType.h"
|
||||
|
||||
namespace {
|
||||
using json = nlohmann::json;
|
||||
using Record = CaptureBundle::Record;
|
||||
|
||||
const char* SourceName(uint8_t source) {
|
||||
switch (static_cast<eCaptureSource>(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<std::string, std::string> Ends(const PacketRecordHeader& h) {
|
||||
std::string server = SourceName(h.source);
|
||||
if (h.source == static_cast<uint8_t>(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<uint8_t>(eCaptureSource::CHAT) || h.source == static_cast<uint8_t>(eCaptureSource::MASTER)) other = "server";
|
||||
else other = "client";
|
||||
if (h.flags & PacketRecordFlags::BROADCAST) other = "everyone";
|
||||
return h.direction == static_cast<uint8_t>(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<Record>& 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<eCaptureSource>(h.source);
|
||||
return h.direction == static_cast<uint8_t>(ePacketDirection::RECEIVED) && !(h.flags & PacketRecordFlags::MASTER_LINK) &&
|
||||
(source == eCaptureSource::AUTH || source == eCaptureSource::WORLD);
|
||||
}
|
||||
|
||||
bool IsVolatileField(const std::string& name) {
|
||||
static const std::set<std::string> 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<double>(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<Track> Tracks(const std::vector<Record>& records, int64_t startUs) {
|
||||
std::vector<Track> tracks;
|
||||
for (const auto& record : records) {
|
||||
if (!FromClient(record.header) || record.header.source != static_cast<uint8_t>(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<float>(h.timeUs - startUs) / 1e6f, position->x, position->y, position->z });
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
std::map<std::string, LWOOBJID> MakePortable(CaptureBundle::Bundle& bundle) {
|
||||
std::map<LWOOBJID, std::string> characters;
|
||||
std::map<uint32_t, uint32_t> 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<uint32_t>(accounts.size() + 1);
|
||||
}
|
||||
json ids = json::object();
|
||||
std::map<std::string, LWOOBJID> found;
|
||||
std::map<LWOOBJID, LWOOBJID> placeholders;
|
||||
size_t n = 0;
|
||||
for (const auto& [id, symbol] : characters) {
|
||||
const auto placeholder = PLACEHOLDER_BASE + static_cast<int64_t>(++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<uint32_t>(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<uint32_t>(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<Record>& expected, const std::vector<Record>& actual) {
|
||||
DiffReport report;
|
||||
// Server -> client packets only (what the server answered)
|
||||
const auto answers = [](const std::vector<Record>& records) {
|
||||
std::vector<const Record*> out;
|
||||
for (const auto& r : records) {
|
||||
if (r.header.flags & (PacketRecordFlags::GAP | PacketRecordFlags::MASTER_LINK)) continue;
|
||||
const auto source = static_cast<eCaptureSource>(r.header.source);
|
||||
if ((source == eCaptureSource::AUTH || source == eCaptureSource::WORLD) && r.header.direction == static_cast<uint8_t>(ePacketDirection::SENT)) out.push_back(&r);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const auto want = answers(expected), got = answers(actual);
|
||||
report.expected = want.size();
|
||||
std::vector<bool> 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;
|
||||
}
|
||||
}
|
||||
74
dNet/CaptureTools.h
Normal file
74
dNet/CaptureTools.h
Normal file
@@ -0,0 +1,74 @@
|
||||
#ifndef __CAPTURETOOLS__H__
|
||||
#define __CAPTURETOOLS__H__
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<CaptureBundle::Record>& 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<float> 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<Track> Tracks(const std::vector<CaptureBundle::Record>& 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<std::string, LWOOBJID> 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<std::string, size_t> differingByName;
|
||||
std::map<std::string, size_t> missingByName;
|
||||
std::map<std::string, size_t> extraByName;
|
||||
std::vector<std::string> examples; // the first few differences, readable
|
||||
nlohmann::json ToJson() const;
|
||||
};
|
||||
DiffReport Diff(const std::vector<CaptureBundle::Record>& expected, const std::vector<CaptureBundle::Record>& actual);
|
||||
|
||||
// Fields left out of comparisons (by name, in any packet)
|
||||
bool IsVolatileField(const std::string& name);
|
||||
}
|
||||
|
||||
#endif //!__CAPTURETOOLS__H__
|
||||
613
dNet/PacketCapture.cpp
Normal file
613
dNet/PacketCapture.cpp
Normal file
@@ -0,0 +1,613 @@
|
||||
#include "PacketCapture.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#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<LWOOBJID> 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<uint32_t, MessageCapture::MAX_SLOTS> 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<uint64_t> g_Ignored;
|
||||
|
||||
std::array<Slot, MessageCapture::MAX_SLOTS> 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<uint64_t, Binding> g_Bindings;
|
||||
std::vector<uint64_t> g_Unbind; // connections that closed; forgotten at the next receive
|
||||
std::unordered_map<uint64_t, Pending> g_Pending;
|
||||
std::map<uint64_t, uint8_t> 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<Chunk> 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<uint64_t>(address.binaryAddress) << 16) | address.port;
|
||||
}
|
||||
|
||||
int64_t NowUs() {
|
||||
return std::chrono::duration_cast<std::chrono::microseconds>(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<uint8_t>(g_Source);
|
||||
header.direction = static_cast<uint8_t>(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<uint32_t>(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<ServiceType>(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<uint32_t>(Master::MESSAGE_CAPTURE_CONTROL) || id == static_cast<uint32_t>(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<uint32_t>(MessageType::Auth::LOGIN_REQUEST)) &&
|
||||
!(service == ServiceType::CLIENT && id == static_cast<uint32_t>(MessageType::Client::LOGIN_RESPONSE))) return false;
|
||||
if (!PacketDecoder::HasSecrets(service, id)) return true;
|
||||
scratch.assign(reinterpret_cast<const char*>(data), (bits + 7) / 8);
|
||||
if (!PacketDecoder::Redact(scratch)) return false;
|
||||
data = reinterpret_cast<const unsigned char*>(scratch.data());
|
||||
bits = static_cast<uint32_t>(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<unsigned char*>(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<Master>(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<uint32_t>(bits));
|
||||
else if (peer == g_MasterLink && g_MasterLink) RecordMasterLink(ePacketDirection::SENT, data, static_cast<uint32_t>(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<uint32_t>(GeneralUtils::TryParse<uint32_t>(Game::config->GetValue("capture_flush_interval_ms")).value_or(1000), 50, 60000);
|
||||
settings.flushBytes = std::clamp<uint32_t>(GeneralUtils::TryParse<uint32_t>(Game::config->GetValue("capture_flush_bytes")).value_or(256 * 1024), 4096, 4 * 1024 * 1024);
|
||||
settings.maxBufferBytes = std::clamp<uint64_t>(GeneralUtils::TryParse<uint64_t>(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<uint16_t>(zoneId);
|
||||
g_Instance = static_cast<uint16_t>(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<uint32_t>(packet->bitSize));
|
||||
}
|
||||
|
||||
void OnReceiveFromMasterImpl(const Packet* packet) {
|
||||
if (!packet || !g_MasterLink) return;
|
||||
RecordMasterLink(ePacketDirection::RECEIVED, packet->data, static_cast<uint32_t>(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<const unsigned char*>(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<uint8_t>(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<uint64_t>(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);
|
||||
}
|
||||
}
|
||||
104
dNet/PacketCapture.h
Normal file
104
dNet/PacketCapture.h
Normal file
@@ -0,0 +1,104 @@
|
||||
#ifndef __PACKETCAPTURE__H__
|
||||
#define __PACKETCAPTURE__H__
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
#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<bool(MessageCaptureData& data)>;
|
||||
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__
|
||||
419
dNet/PacketDecoder.cpp
Normal file
419
dNet/PacketDecoder.cpp
Normal file
@@ -0,0 +1,419 @@
|
||||
#include "PacketDecoder.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <utility>
|
||||
|
||||
#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<std::optional<json>(RakNet::BitStream&, bool fromClient)>;
|
||||
using Redactor = std::function<bool(std::string&)>;
|
||||
|
||||
using Scrubber = std::function<bool(std::string&, bool anonymise)>;
|
||||
|
||||
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<typename T>
|
||||
Scrubber Scrub(std::function<void(T&)> names, std::function<void(T&)> anonymous = nullptr) {
|
||||
return [names, anonymous](std::string& bytes, bool anonymise) {
|
||||
RakNet::BitStream in(reinterpret_cast<unsigned char*>(bytes.data()), static_cast<unsigned int>(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<const char*>(out.GetData()), out.GetNumberOfBytesUsed());
|
||||
if (written == bytes) return false;
|
||||
bytes = std::move(written);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
PacketDecoder::GameMessageFields g_GameMessages;
|
||||
|
||||
using Rewriter = std::function<std::optional<std::string>(std::string_view)>;
|
||||
template<typename T>
|
||||
Rewriter Rewrite() {
|
||||
return [](std::string_view bytes) -> std::optional<std::string> {
|
||||
RakNet::BitStream in(reinterpret_cast<unsigned char*>(const_cast<char*>(bytes.data())), static_cast<unsigned int>(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<const char*>(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<typename T>
|
||||
Fields Make(std::function<void(const T&, json&)> fill) {
|
||||
return [fill](RakNet::BitStream& stream, bool) -> std::optional<json> {
|
||||
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<typename T>
|
||||
Redactor Blank(std::function<void(T&)> blank) {
|
||||
return [blank](std::string& bytes) {
|
||||
RakNet::BitStream in(reinterpret_cast<unsigned char*>(bytes.data()), static_cast<unsigned int>(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<const char*>(out.GetData()), out.GetNumberOfBytesUsed());
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
using Key = std::pair<ServiceType, uint32_t>;
|
||||
template<typename E> Key K(ServiceType service, E id) { return { service, static_cast<uint32_t>(id) }; }
|
||||
|
||||
const std::map<Key, Entry>& Registry() {
|
||||
using S = ServiceType;
|
||||
static const std::map<Key, Entry> registry{
|
||||
// Handshake: one ID, a struct per direction
|
||||
{ K(S::COMMON, MessageType::Server::VERSION_CONFIRM), { [](RakNet::BitStream& stream, bool fromClient) -> std::optional<json> {
|
||||
if (fromClient) {
|
||||
CommonPackets::ClientVersionConfirm packet;
|
||||
if (!packet.Deserialize(stream)) return std::nullopt;
|
||||
return json{ {"netVersion", packet.netVersion}, {"serviceType", static_cast<int>(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<int>(packet.serviceType)} };
|
||||
} } },
|
||||
|
||||
// Auth: the username and password are blanked when recorded
|
||||
{ K(S::AUTH, MessageType::Auth::LOGIN_REQUEST), {
|
||||
Make<AuthPackets::LoginRequest>([](const auto& p, json& j) {
|
||||
j = { {"username", p.username.GetAsString()}, {"localeID", static_cast<int>(p.localeID)}, {"clientOS", static_cast<int>(p.clientOS)},
|
||||
{"memoryStats", p.memoryStats.GetAsString()}, {"videoCard", p.videoCard.GetAsString()} };
|
||||
}),
|
||||
Blank<AuthPackets::LoginRequest>([](auto& p) { p.username.string.clear(); p.password.string.clear(); }),
|
||||
Scrub<AuthPackets::LoginRequest>([](auto& p) { X(p.username); }) } },
|
||||
{ K(S::CLIENT, MessageType::Client::LOGIN_RESPONSE), {
|
||||
Make<ClientPackets::LoginResponse>([](const auto& p, json& j) {
|
||||
json stamps = json::array();
|
||||
for (const auto& stamp : p.stamps.list) stamps.push_back({ {"type", static_cast<int>(stamp.type)}, {"value", stamp.value}, {"timestamp", stamp.timestamp} });
|
||||
j = { {"responseCode", static_cast<int>(p.responseCode)}, {"worldServerIP", p.worldServerIP.string}, {"worldServerPort", p.worldServerPort},
|
||||
{"errorMessage", p.errorMessage}, {"stamps", stamps} };
|
||||
}),
|
||||
Blank<ClientPackets::LoginResponse>([](auto& p) { p.userKey.string.clear(); p.cdnKey.string.clear(); }) } },
|
||||
|
||||
// World
|
||||
{ K(S::WORLD, MessageType::World::VALIDATION), {
|
||||
Make<WorldPackets::Validation>([](const auto& p, json& j) { j = { {"username", p.username.GetAsString()}, {"fdbChecksum", p.fdbChecksum.string} }; }),
|
||||
Blank<WorldPackets::Validation>([](auto& p) { p.sessionKey.string.clear(); }),
|
||||
Scrub<WorldPackets::Validation>([](auto& p) { X(p.username); }) } },
|
||||
{ K(S::WORLD, MessageType::World::CHARACTER_CREATE_REQUEST), { Make<WorldPackets::CharacterCreateRequest>([](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<WorldPackets::CharacterCreateRequest>(nullptr, [](auto& p) { X(p.name); }) } },
|
||||
{ K(S::WORLD, MessageType::World::LOGIN_REQUEST), { Make<WorldPackets::CharacterLoginRequest>([](const auto& p, json& j) { j = { {"playerID", Id(p.playerID)} }; }) } },
|
||||
{ K(S::WORLD, MessageType::World::CHARACTER_DELETE_REQUEST), { Make<WorldPackets::CharacterDeleteRequest>([](const auto& p, json& j) { j = { {"objectID", Id(p.objectID)} }; }) } },
|
||||
{ K(S::WORLD, MessageType::World::CHARACTER_RENAME_REQUEST), { Make<WorldPackets::CharacterRenameRequest>([](const auto& p, json& j) {
|
||||
j = { {"objectID", Id(p.objectID)}, {"name", p.name.GetAsString()} };
|
||||
}),
|
||||
nullptr, Scrub<WorldPackets::CharacterRenameRequest>(nullptr, [](auto& p) { X(p.name); }) } },
|
||||
{ K(S::WORLD, MessageType::World::LEVEL_LOAD_COMPLETE), { Make<WorldPackets::LevelLoadComplete>([](const auto& p, json& j) {
|
||||
j = { {"mapID", p.mapID}, {"instanceID", p.instanceID}, {"cloneID", p.cloneID} };
|
||||
}) } },
|
||||
{ K(S::WORLD, MessageType::World::POSITION_UPDATE), { Make<WorldPackets::PositionUpdate>([](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<WorldPackets::GeneralChatMessage>([](const auto& p, json& j) {
|
||||
j = { {"chatChannel", p.chatChannel}, {"message", GeneralUtils::UTF16ToWTF8(p.message)} };
|
||||
}),
|
||||
nullptr, Scrub<WorldPackets::GeneralChatMessage>(nullptr, [](auto& p) { X(p.message); }) } },
|
||||
{ K(S::WORLD, MessageType::World::ROUTE_PACKET), { Make<WorldPackets::RoutePacket>([](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<ClientPackets::LoadStaticZone>([](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<ClientPackets::CharacterListResponse>([](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<ClientPackets::CharacterListResponse>(nullptr, [](auto& p) { for (auto& c : p.characters) { X(c.name); X(c.unapprovedName); } }) } },
|
||||
{ K(S::CLIENT, MessageType::Client::CHARACTER_CREATE_RESPONSE), { Make<ClientPackets::CharacterCreateResponse>([](const auto& p, json& j) {
|
||||
j = { {"response", static_cast<int>(p.response)} };
|
||||
}) } },
|
||||
{ K(S::CLIENT, MessageType::Client::TRANSFER_TO_WORLD), { Make<ClientPackets::TransferToWorld>([](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<ChatPackets::GeneralChatMessage>([](const auto& p, json& j) {
|
||||
j = { {"playerID", Id(p.playerID)}, {"chatChannel", static_cast<int>(p.chatChannel)}, {"message", p.message.GetAsString()} };
|
||||
}),
|
||||
nullptr, Scrub<ChatPackets::GeneralChatMessage>(nullptr, [](auto& p) { X(p.senderName); X(p.message); }) } },
|
||||
{ K(S::CHAT, MessageType::Chat::PRIVATE_CHAT_MESSAGE), { Make<ChatPackets::PrivateChatMessage>([](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<ChatPackets::PrivateChatMessage>(nullptr, [](auto& p) { X(p.senderName); X(p.receiverName); X(p.message); }) } },
|
||||
{ K(S::CHAT, MessageType::Chat::WORLD_ROUTE_PACKET), { Make<ChatPackets::WorldRoutePacket>([](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<ServiceType>(service), id);
|
||||
}
|
||||
}) } },
|
||||
|
||||
// Between servers: session keys are blanked when recorded
|
||||
{ K(S::MASTER, MessageType::Master::REQUEST_SESSION_KEY), { Make<MasterPackets::RequestSessionKey>([](const auto& p, json& j) { j = { {"username", p.username.GetAsString()} }; }),
|
||||
nullptr, Scrub<MasterPackets::RequestSessionKey>([](auto& p) { X(p.username); }) } },
|
||||
{ K(S::MASTER, MessageType::Master::SESSION_KEY_RESPONSE), {
|
||||
Make<MasterPackets::SessionKeyResponse>([](const auto& p, json& j) { j = { {"username", p.username.GetAsString()} }; }),
|
||||
Blank<MasterPackets::SessionKeyResponse>([](auto& p) { p.sessionKey = 0; }),
|
||||
Scrub<MasterPackets::SessionKeyResponse>([](auto& p) { X(p.username); }) } },
|
||||
{ K(S::MASTER, MessageType::Master::SET_SESSION_KEY), {
|
||||
Make<MasterPackets::SetSessionKey>([](const auto& p, json& j) { j = { {"username", p.username.string} }; }),
|
||||
Blank<MasterPackets::SetSessionKey>([](auto& p) { p.sessionKey = 0; }),
|
||||
Scrub<MasterPackets::SetSessionKey>([](auto& p) { X(p.username); }) } },
|
||||
{ K(S::MASTER, MessageType::Master::NEW_SESSION_ALERT), {
|
||||
Make<MasterPackets::NewSessionAlert>([](const auto& p, json& j) { j = { {"username", p.username.string} }; }),
|
||||
Blank<MasterPackets::NewSessionAlert>([](auto& p) { p.sessionKey = 0; }),
|
||||
Scrub<MasterPackets::NewSessionAlert>([](auto& p) { X(p.username); }) } },
|
||||
{ K(S::MASTER, MessageType::Master::REQUEST_ZONE_TRANSFER), { Make<MasterPackets::RequestZoneTransfer>([](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<MasterPackets::RequestZoneTransferResponse>([](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<MasterPackets::PlayerAdded>([](const auto& p, json& j) { j = { {"zoneID", p.zoneID}, {"instanceID", p.instanceID} }; }) } },
|
||||
{ K(S::MASTER, MessageType::Master::PLAYER_REMOVED), { Make<MasterPackets::PlayerRemoved>([](const auto& p, json& j) { j = { {"zoneID", p.zoneID}, {"instanceID", p.instanceID} }; }) } },
|
||||
};
|
||||
return registry;
|
||||
}
|
||||
|
||||
const std::map<Key, Rewriter>& Rewriters() {
|
||||
using S = ServiceType;
|
||||
static const std::map<Key, Rewriter> rewriters{
|
||||
{ K(S::AUTH, MessageType::Auth::LOGIN_REQUEST), Rewrite<AuthPackets::LoginRequest>() },
|
||||
{ K(S::CLIENT, MessageType::Client::LOGIN_RESPONSE), Rewrite<ClientPackets::LoginResponse>() },
|
||||
{ K(S::WORLD, MessageType::World::VALIDATION), Rewrite<WorldPackets::Validation>() },
|
||||
{ K(S::WORLD, MessageType::World::CHARACTER_CREATE_REQUEST), Rewrite<WorldPackets::CharacterCreateRequest>() },
|
||||
{ K(S::WORLD, MessageType::World::LOGIN_REQUEST), Rewrite<WorldPackets::CharacterLoginRequest>() },
|
||||
{ K(S::WORLD, MessageType::World::CHARACTER_DELETE_REQUEST), Rewrite<WorldPackets::CharacterDeleteRequest>() },
|
||||
{ K(S::WORLD, MessageType::World::CHARACTER_RENAME_REQUEST), Rewrite<WorldPackets::CharacterRenameRequest>() },
|
||||
{ K(S::WORLD, MessageType::World::LEVEL_LOAD_COMPLETE), Rewrite<WorldPackets::LevelLoadComplete>() },
|
||||
{ K(S::WORLD, MessageType::World::POSITION_UPDATE), Rewrite<WorldPackets::PositionUpdate>() },
|
||||
{ K(S::WORLD, MessageType::World::GENERAL_CHAT_MESSAGE), Rewrite<WorldPackets::GeneralChatMessage>() },
|
||||
{ K(S::WORLD, MessageType::World::ROUTE_PACKET), Rewrite<WorldPackets::RoutePacket>() },
|
||||
{ K(S::CLIENT, MessageType::Client::LOAD_STATIC_ZONE), Rewrite<ClientPackets::LoadStaticZone>() },
|
||||
{ K(S::CLIENT, MessageType::Client::CHARACTER_LIST_RESPONSE), Rewrite<ClientPackets::CharacterListResponse>() },
|
||||
{ K(S::CLIENT, MessageType::Client::CHARACTER_CREATE_RESPONSE), Rewrite<ClientPackets::CharacterCreateResponse>() },
|
||||
{ K(S::CLIENT, MessageType::Client::TRANSFER_TO_WORLD), Rewrite<ClientPackets::TransferToWorld>() },
|
||||
{ K(S::CHAT, MessageType::Chat::GENERAL_CHAT_MESSAGE), Rewrite<ChatPackets::GeneralChatMessage>() },
|
||||
{ K(S::CHAT, MessageType::Chat::PRIVATE_CHAT_MESSAGE), Rewrite<ChatPackets::PrivateChatMessage>() },
|
||||
{ K(S::CHAT, MessageType::Chat::WORLD_ROUTE_PACKET), Rewrite<ChatPackets::WorldRoutePacket>() },
|
||||
{ K(S::MASTER, MessageType::Master::REQUEST_SESSION_KEY), Rewrite<MasterPackets::RequestSessionKey>() },
|
||||
{ K(S::MASTER, MessageType::Master::SESSION_KEY_RESPONSE), Rewrite<MasterPackets::SessionKeyResponse>() },
|
||||
{ K(S::MASTER, MessageType::Master::SET_SESSION_KEY), Rewrite<MasterPackets::SetSessionKey>() },
|
||||
{ K(S::MASTER, MessageType::Master::NEW_SESSION_ALERT), Rewrite<MasterPackets::NewSessionAlert>() },
|
||||
{ K(S::MASTER, MessageType::Master::REQUEST_ZONE_TRANSFER), Rewrite<MasterPackets::RequestZoneTransfer>() },
|
||||
{ K(S::MASTER, MessageType::Master::REQUEST_ZONE_TRANSFER_RESPONSE), Rewrite<MasterPackets::RequestZoneTransferResponse>() },
|
||||
{ K(S::MASTER, MessageType::Master::PLAYER_ADDED), Rewrite<MasterPackets::PlayerAdded>() },
|
||||
{ K(S::MASTER, MessageType::Master::PLAYER_REMOVED), Rewrite<MasterPackets::PlayerRemoved>() },
|
||||
};
|
||||
return rewriters;
|
||||
}
|
||||
|
||||
template<typename E>
|
||||
std::string EnumName(uint32_t id) {
|
||||
const auto name = magic_enum::enum_name(static_cast<E>(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<uint8_t>(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<ServiceType>(raw);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
namespace PacketDecoder {
|
||||
std::string Name(ServiceType service, uint32_t messageId) {
|
||||
switch (service) {
|
||||
case ServiceType::COMMON: return EnumName<MessageType::Server>(messageId);
|
||||
case ServiceType::AUTH: return EnumName<MessageType::Auth>(messageId);
|
||||
case ServiceType::CHAT: return EnumName<MessageType::Chat>(messageId);
|
||||
case ServiceType::WORLD: return EnumName<MessageType::World>(messageId);
|
||||
case ServiceType::CLIENT: return EnumName<MessageType::Client>(messageId);
|
||||
case ServiceType::MASTER: return EnumName<MessageType::Master>(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<uint8_t>(bytes[0]);
|
||||
out.name = RakNetName(static_cast<uint8_t>(bytes[0]));
|
||||
return out;
|
||||
}
|
||||
out.lu = true;
|
||||
out.serviceId = static_cast<uint16_t>(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<unsigned char*>(const_cast<char*>(bytes.data())), static_cast<unsigned int>(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<uint32_t>(MessageType::World::GAME_MSG)) ||
|
||||
(service == ServiceType::CLIENT && id == static_cast<uint32_t>(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<MessageType::Game>(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<unsigned char*>(const_cast<char*>(bytes.data())) + offset, static_cast<unsigned int>(bytes.size() - offset), false);
|
||||
out.fields = g_GameMessages(static_cast<MessageType::Game>(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<NiPoint3> Position(std::string_view bytes) {
|
||||
ServiceType service{};
|
||||
uint32_t id{};
|
||||
if (!ReadLuHeader(bytes, service, id) || service != ServiceType::WORLD || id != static_cast<uint32_t>(MessageType::World::POSITION_UPDATE)) return std::nullopt;
|
||||
RakNet::BitStream stream(reinterpret_cast<unsigned char*>(const_cast<char*>(bytes.data())), static_cast<unsigned int>(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<bool> 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; }
|
||||
}
|
||||
79
dNet/PacketDecoder.h
Normal file
79
dNet/PacketDecoder.h
Normal file
@@ -0,0 +1,79 @@
|
||||
#ifndef __PACKETDECODER__H__
|
||||
#define __PACKETDECODER__H__
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#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<nlohmann::json> 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<NiPoint3> Position(std::string_view bytes);
|
||||
|
||||
using GameMessageFields = std::function<std::optional<nlohmann::json>(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<bool> RoundTrip(std::string_view bytes);
|
||||
|
||||
// How many (service, message) pairs have typed decoders, for tests
|
||||
size_t RegisteredCount();
|
||||
}
|
||||
|
||||
#endif //!__PACKETDECODER__H__
|
||||
96
dNet/PacketRecord.h
Normal file
96
dNet/PacketRecord.h
Normal file
@@ -0,0 +1,96 @@
|
||||
#ifndef __PACKETRECORD__H__
|
||||
#define __PACKETRECORD__H__
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
/**
|
||||
* 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<typename Fn>
|
||||
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<uint32_t>(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__
|
||||
@@ -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<uint32_t>(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);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#define __MESSAGECAPTURE__H__
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
@@ -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<uint16_t> only; // START: capture only these message IDs (empty: all)
|
||||
std::vector<uint16_t> 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<LWOOBJID> 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<uint8_t>(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<uint16_t>(std::min<size_t>(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<MessageCaptureEntry> 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<uint32_t, MessageCapture::MAX_SLOTS> 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<uint32_t>(std::min<size_t>(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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "DashboardActions.h"
|
||||
#include "Profiler.h"
|
||||
#include <optional>
|
||||
#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<Profiler::Scope> packetScope;
|
||||
packetScope.emplace("Master packets", Profiler::Phase::PACKETS);
|
||||
|
||||
6
migrations/dlu/mysql/103_packet_captures.sql
Normal file
6
migrations/dlu/mysql/103_packet_captures.sql
Normal file
@@ -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 '';
|
||||
3
migrations/dlu/sqlite/86_packet_captures.sql
Normal file
3
migrations/dlu/sqlite/86_packet_captures.sql
Normal file
@@ -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 '';
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ SET(DNET_TESTS
|
||||
"GuildPacketsTests.cpp"
|
||||
"CommonAuthPacketsTests.cpp"
|
||||
"MasterPacketsTests.cpp"
|
||||
"PacketCaptureTests.cpp"
|
||||
"ServerTrafficTests.cpp"
|
||||
"WorldPacketsTests.cpp")
|
||||
|
||||
|
||||
555
tests/dGameTests/dNetTests/PacketCaptureTests.cpp
Normal file
555
tests/dGameTests/dNetTests/PacketCaptureTests.cpp
Normal file
@@ -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 <chrono>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
namespace {
|
||||
std::string Bytes(const LUBitStream& packet) {
|
||||
RakNet::BitStream stream;
|
||||
packet.WritePacket(stream);
|
||||
return std::string(reinterpret_cast<const char*>(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<CaptureBundle::Record> records;
|
||||
std::vector<MessageCaptureData> 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<LWOOBJID> 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<unsigned int>(bytes.size());
|
||||
packet.bitSize = static_cast<BitSize_t>(bytes.size() * 8);
|
||||
packet.data = reinterpret_cast<unsigned char*>(const_cast<char*>(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<const unsigned char*>(bytes.data()), static_cast<uint32_t>(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<uint8_t>(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<uint8_t>(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<uint8_t>(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<const char*>(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<float>(), 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<char>(ID_REPLICA_MANAGER_CONSTRUCTION));
|
||||
EXPECT_EQ(PacketDecoder::Decode(raknet, false).name, "ID_REPLICA_MANAGER_CONSTRUCTION");
|
||||
EXPECT_EQ(PacketDecoder::Name(ServiceType::MASTER, static_cast<uint32_t>(MessageType::Master::SESSION_KEY_RESPONSE)), "SESSION_KEY_RESPONSE");
|
||||
EXPECT_TRUE(PacketDecoder::HasSecrets(ServiceType::MASTER, static_cast<uint32_t>(MessageType::Master::SET_SESSION_KEY)));
|
||||
EXPECT_FALSE(PacketDecoder::HasSecrets(ServiceType::WORLD, static_cast<uint32_t>(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<const char*>(stream.GetData()), stream.GetNumberOfBytesUsed());
|
||||
ASSERT_TRUE(PacketDecoder::Redact(bytes));
|
||||
const uint32_t secret = 0xDEADBEEF;
|
||||
EXPECT_EQ(bytes.find(std::string(reinterpret_cast<const char*>(&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<uint8_t>(source);
|
||||
record.header.direction = static_cast<uint8_t>(direction);
|
||||
record.header.characterId = character;
|
||||
record.header.zoneId = 1100;
|
||||
record.header.length = static_cast<uint32_t>(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<bool>());
|
||||
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<CaptureBundle::Record> expected{ MakeRecord(zone, 1, eCaptureSource::WORLD, ePacketDirection::SENT),
|
||||
MakeRecord(zone, 2, eCaptureSource::WORLD, ePacketDirection::SENT), MakeRecord(transfer, 3, eCaptureSource::WORLD, ePacketDirection::SENT) };
|
||||
std::vector<CaptureBundle::Record> 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<std::string> 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<const unsigned char*>(bytes.data());
|
||||
const auto bits = static_cast<uint32_t>(bytes.size() * 8);
|
||||
|
||||
const auto start = Clock::now();
|
||||
for (int i = 0; i < PACKETS; i++) {
|
||||
PacketCapture::RecordForTest(Address(static_cast<uint32_t>(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<std::chrono::nanoseconds>(Clock::now() - start).count();
|
||||
|
||||
size_t total = 0;
|
||||
for (const auto& batch : batches) total += batch.size();
|
||||
ASSERT_EQ(PacketCapture::GetStats().recorded, static_cast<uint64_t>(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<std::streamsize>(batch.size()));
|
||||
file.flush();
|
||||
}
|
||||
}
|
||||
const auto fileNs = std::chrono::duration_cast<std::chrono::nanoseconds>(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<int>(payload.size()), SQLITE_STATIC);
|
||||
sqlite3_step(insert);
|
||||
sqlite3_reset(insert);
|
||||
});
|
||||
sqlite3_exec(db, "COMMIT;", nullptr, nullptr, nullptr);
|
||||
}
|
||||
const auto dbNs = std::chrono::duration_cast<std::chrono::nanoseconds>(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<double>(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<double>(tapNs) / PACKETS, PACKETS / seconds, static_cast<double>(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<double>(fileNs) / 1e6, static_cast<double>(fileNs) / PACKETS, static_cast<double>(dbNs) / 1e6, static_cast<double>(dbNs) / PACKETS);
|
||||
// Generous bounds: this is a smoke test, the numbers above are the measurement
|
||||
EXPECT_LT(static_cast<double>(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);
|
||||
}
|
||||
8
thirdparty/raknet/Source/RakPeer.cpp
vendored
8
thirdparty/raknet/Source/RakPeer.cpp
vendored
@@ -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);
|
||||
|
||||
5
thirdparty/raknet/Source/RakPeer.h
vendored
5
thirdparty/raknet/Source/RakPeer.h
vendored
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user