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:
Aaron Kimbrell
2026-09-27 08:45:12 -05:00
parent fb6d73e4bd
commit 332bc04ce8
35 changed files with 3207 additions and 15 deletions

253
dNet/CaptureTools.cpp Normal file
View 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;
}
}