#include "CaptureReplay.h" #include "MasterPackets.h" #include #include #include #include #include #include #include #include "BinaryPathFinder.h" #include "CaptureBundle.h" #include "CaptureTools.h" #include "Database.h" #include "DashboardRoutes.h" #include "Game.h" #include "LiveWorld.h" #include "Logger.h" #include "master/MessageCapture.h" #include "PacketDecoder.h" #include "Permissions.h" #include "RouteUtils.h" #include "Web.h" #include "dConfig.h" #include "dServer.h" #include "eHTTPMethod.h" #ifndef DLU_GIT_COMMIT #define DLU_GIT_COMMIT "unknown" #endif using namespace RouteUtils; namespace fs = std::filesystem; namespace { using Clock = std::chrono::steady_clock; using Session = IMessageCaptures::MessageCaptureSession; using json = nlohmann::json; constexpr const char* TOPIC = "packet_capture"; constexpr const char* PERMISSION = "dev_message_inspector"; constexpr uint32_t DEFAULT_SECONDS = 300; // Captures of everything at once (each gets every packet of every server) constexpr size_t MAX_EVERYTHING = 1; // Every server is told again this often (servers that started since, characters made since) constexpr auto REARM_INTERVAL = std::chrono::seconds(10); constexpr auto PUSH_INTERVAL = std::chrono::milliseconds(500); constexpr auto SAVE_INTERVAL = std::chrono::seconds(5); // Batches still on their way when a capture ends are kept this long constexpr auto LATE_GRACE = std::chrono::seconds(15); constexpr auto KEEP_ENDED = std::chrono::hours(1); // Most recent packets pushed to open pages constexpr size_t RECENT = 50; constexpr uint32_t PAGE_DEFAULT = 500; constexpr uint32_t PAGE_MAX = 2000; struct Capture { Session session; MessageCaptureControl control; Clock::time_point until; bool running{ true }; Clock::time_point endedAt{}; std::ofstream file; std::string pending; // records waiting to be written Clock::time_point lastWrite{}; Clock::time_point nextArm{}; Clock::time_point lastSave{}; std::deque recent; std::map perSource; bool changed{}; bool dirty{}; }; std::map g_Captures; Clock::time_point g_NextPush{}; // The last bundle read for the viewer (a running capture's file grows: read again when its size changed) struct Cached { uint64_t id{}; uintmax_t size{}; CaptureBundle::Bundle bundle; int64_t startUs{}; }; Cached g_Cache; bool Connected() { return Game::server && Game::server->GetIsConnectedToMaster(); } int64_t Setting(const std::string& key, int64_t fallback) { if (!Game::config) return fallback; return GeneralUtils::TryParse(Game::config->GetValue(key)).value_or(fallback); } const char* TargetName(eCaptureTarget target) { switch (target) { case eCaptureTarget::CHARACTER: return "character"; case eCaptureTarget::ACCOUNT: return "account"; default: return "everything"; } } std::string Describe(const Session& s) { if (s.target == "everything") return "everything"; if (s.target == "character") return s.characterName + " (character " + std::to_string(s.characterId) + ")"; return s.accountName + " (account " + std::to_string(s.accountId) + ")"; } json SessionJson(const Capture& c) { const auto& s = c.session; return { {"id", s.id}, {"kind", 1}, {"target", s.target}, {"describe", Describe(s)}, {"characterId", std::to_string(s.characterId)}, {"characterName", s.characterName}, {"accountId", s.accountId}, {"accountName", s.accountName}, {"startedBy", s.startedBy}, {"startedAt", s.startedAt}, {"endsAt", s.endsAt}, {"endedAt", s.endedAt}, {"state", c.running ? "capturing" : "ended"}, {"endReason", s.endReason}, {"received", s.messageCount}, {"bytes", s.byteCount}, {"dropped", s.dropped}, {"perSource", c.perSource}, }; } void SendControl(const MessageCaptureControl& control) { if (Connected()) MasterPackets::SendToMaster(control); } // Tell every server (again) what to record, for the time that is left void Arm(Capture& c) { const auto left = std::chrono::duration_cast(c.until - Clock::now()).count(); if (left <= 0) return; c.control.action = eMessageCaptureControl::ARM; c.control.seconds = static_cast(left); // Characters made since the last time belong to the account too if (c.control.target == eCaptureTarget::ACCOUNT) { try { c.control.characterIds = Database::Get()->GetAccountCharacterIds(c.control.accountId); } catch (const std::exception&) {} } SendControl(c.control); c.nextArm = Clock::now() + REARM_INTERVAL; } void Write(Capture& c) { if (c.pending.empty()) return; if (c.file.is_open()) { c.file.write(c.pending.data(), static_cast(c.pending.size())); c.file.flush(); } c.pending.clear(); c.lastWrite = Clock::now(); } void Save(Capture& c) { if (!c.dirty) return; try { Database::Get()->UpdateMessageCaptureSession(c.session); } catch (const std::exception& e) { LOG("Couldn't save packet capture %llu: %s", c.session.id, e.what()); } c.dirty = false; c.lastSave = Clock::now(); } void End(Capture& c, const std::string& reason) { if (!c.running) return; c.running = false; c.endedAt = Clock::now(); c.session.endedAt = std::time(nullptr); c.session.endReason = reason; c.changed = c.dirty = true; c.control.action = eMessageCaptureControl::DISARM; SendControl(c.control); Write(c); Save(c); LOG("Packet capture %llu of %s ended: %s", c.session.id, Describe(c.session).c_str(), reason.c_str()); } void Close(Capture& c) { Write(c); if (c.file.is_open()) c.file.close(); Save(c); } void Push(Capture& c) { if (!c.changed) return; c.changed = false; json recent = json::array(); const auto start = c.session.startedAt * 1000000; size_t i = c.session.messageCount - c.recent.size(); for (const auto& record : c.recent) recent.push_back(CaptureTools::RecordJson(record, i++, start, false)); c.recent.clear(); json message{ {"capture", SessionJson(c)}, {"recent", recent} }; Game::web.SendWSMessage(TOPIC, message); } void Note(Capture& c, const PacketRecordHeader& header, std::string_view bytes) { PacketRecord::Append(c.pending, header, bytes.data()); c.session.messageCount++; c.session.byteCount += sizeof(header) + header.length; c.recent.push_back({ header, std::string(bytes) }); if (c.recent.size() > RECENT) c.recent.pop_front(); c.changed = c.dirty = true; } const char* SourceKey(uint8_t source) { switch (static_cast(source)) { case eCaptureSource::AUTH: return "auth"; case eCaptureSource::CHAT: return "chat"; case eCaptureSource::WORLD: return "world"; case eCaptureSource::MASTER: return "master"; default: return "other"; } } Capture* Live(uint64_t id) { const auto it = id <= UINT32_MAX ? g_Captures.find(static_cast(id)) : g_Captures.end(); return it == g_Captures.end() ? nullptr : &it->second; } // A packet capture's session from the path; writes the error when there is none std::optional FindSession(const HTTPContext& context, HTTPReply& reply) { const auto id = PathId(context.path, 3); std::optional session; if (id) { if (auto* live = Live(*id)) { Write(*live); session = live->session; } else { session = Database::Get()->GetMessageCaptureSession(*id); } } if (!session || session->kind != 1) { JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such packet capture"); return std::nullopt; } return session; } // The capture's records on one timeline (read again when the file grew) const Cached* Load(const Session& session, HTTPReply& reply) { const auto path = CaptureReplay::FileOf(session.id); std::error_code ec; const auto size = fs::file_size(path, ec); if (ec) { JsonError(reply, eHTTPStatusCode::NOT_FOUND, "The capture's file is gone (deleted, or kept on another machine)"); return nullptr; } if (g_Cache.id == session.id && g_Cache.size == size) return &g_Cache; Cached cached; std::string error; if (!CaptureBundle::Load(path, cached.bundle, error)) { JsonError(reply, eHTTPStatusCode::INTERNAL_SERVER_ERROR, error); return nullptr; } CaptureTools::SortTimeline(cached.bundle.records); cached.id = session.id; cached.size = size; cached.startUs = cached.bundle.records.empty() ? session.startedAt * 1000000 : std::min(session.startedAt * 1000000, cached.bundle.records.front().header.timeUs); g_Cache = std::move(cached); return &g_Cache; } json StoredSessionJson(const Session& s) { if (const auto* live = Live(s.id)) return SessionJson(*live); Capture view; view.session = s; view.running = false; return SessionJson(view); } // Checks and fills a new capture from the request; the error is written when it can't bool Prepare(const json& body, Capture& c, HTTPReply& reply) { const auto target = body.value("target", std::string{}); auto& s = c.session; auto& control = c.control; if (target == "everything") { control.target = eCaptureTarget::EVERYTHING; } else if (target == "account") { control.target = eCaptureTarget::ACCOUNT; const auto text = body.value("account", std::string{}); const auto number = GeneralUtils::TryParse(text); json account = number ? Database::Get()->GetAccountById(*number) : json(); if (!number) { if (const auto info = Database::Get()->GetAccountInfo(text)) account = Database::Get()->GetAccountById(info->id); } if (!account.is_object() || !account.contains("id")) { JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such account"); return false; } control.accountId = account["id"].is_string() ? GeneralUtils::TryParse(account["id"].get()).value_or(0) : account["id"].get(); control.accountName = account.value("name", std::string{}); control.characterIds = Database::Get()->GetAccountCharacterIds(control.accountId); } else if (target == "character") { control.target = eCaptureTarget::CHARACTER; const auto id = ResolveCharacter(body.value("character", std::string{})); const auto info = id ? Database::Get()->GetCharacterInfo(*id) : std::nullopt; if (!info) { JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such character"); return false; } control.characterIds = { info->id }; control.accountId = info->accountId; s.characterId = info->id; s.characterName = info->name; } else { JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "target must be account, character or everything"); return false; } if (control.accountId) { s.accountId = control.accountId; const auto account = Database::Get()->GetAccountById(control.accountId); s.accountName = account.value("name", std::string{}); // Account names are how logins and session keys are matched on the servers control.accountName = s.accountName; } s.target = TargetName(control.target); return true; } } namespace CaptureReplay { fs::path Folder() { auto folder = fs::path(Game::config ? Game::config->GetValue("capture_dir") : ""); if (folder.empty()) folder = "captures"; if (folder.is_relative()) folder = BinaryPathFinder::GetBinaryDir() / folder; return folder; } bool IsRunning(uint64_t sessionId) { const auto* c = Live(sessionId); return c && c->running; } fs::path FileOf(uint64_t sessionId) { return Folder() / (std::to_string(sessionId) + ".bundle"); } void Initialize() { // Packet captures don't carry on across a dashboard restart: the servers stop them at their time limit try { for (auto& session : Database::Get()->GetMessageCaptureSessions({ .unfinishedOnly = true, .ascending = true, .limit = 1000 })) { if (session.kind != 1) continue; session.endedAt = std::time(nullptr); session.endReason = "The dashboard stopped while it ran"; Database::Get()->UpdateMessageCaptureSession(session); } } catch (const std::exception& e) { LOG("Couldn't end unfinished packet captures: %s", e.what()); } } void HandleData(const MessageCaptureData& data) { std::set unknown; const auto accept = [&](uint32_t id) -> Capture* { auto* c = id ? Live(id) : nullptr; if (c && (c->running || Clock::now() - c->endedAt < LATE_GRACE)) return c; if (id) unknown.insert(id); return nullptr; }; PacketRecord::ForEach(data.packets, [&](const PacketRecordHeader& header, std::string_view bytes) { for (uint8_t bit = 0; bit < MessageCapture::MAX_SLOTS; bit++) { if (!(header.mask & (1 << bit))) continue; auto* c = accept(data.slots[bit]); if (!c) continue; auto own = header; own.mask = 1; // one capture per file Note(*c, own, bytes); c->perSource[SourceKey(header.source)]++; } }); // Lost on a server (its buffer was full): a gap in every capture it was recording for if (data.packetsDropped) { for (const auto id : data.slots) { auto* c = accept(id); if (!c) continue; PacketRecordHeader gap; gap.timeUs = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); gap.source = data.source; gap.flags = PacketRecordFlags::GAP; gap.bits = data.packetsDropped; gap.zoneId = static_cast(data.zoneId); gap.instanceId = static_cast(data.instanceId); PacketRecord::Append(c->pending, gap, nullptr); c->session.dropped += data.packetsDropped; c->changed = c->dirty = true; } } // A server still recording for a capture that ended here (it missed the disarm): tell it again for (const auto id : unknown) { for (uint8_t slot = 0; slot < MessageCapture::MAX_SLOTS; slot++) { if (data.slots[slot] != id) continue; MessageCaptureControl control; control.action = eMessageCaptureControl::DISARM; control.captureId = id; control.slot = slot; SendControl(control); } } } void Update() { if (g_Captures.empty()) return; const auto now = Clock::now(); const auto flushBytes = static_cast(Setting("capture_flush_bytes", 256 * 1024)); const auto flushInterval = std::chrono::milliseconds(Setting("capture_flush_interval_ms", 1000)); const bool push = now >= g_NextPush; if (push) g_NextPush = now + PUSH_INTERVAL; for (auto it = g_Captures.begin(); it != g_Captures.end();) { auto& c = it->second; if (c.running) { if (now >= c.until) End(c, "Time limit reached"); else if (now >= c.nextArm) Arm(c); } // One write per capture when enough is waiting or it waited long enough; never per packet if (c.pending.size() >= flushBytes || (!c.pending.empty() && now - c.lastWrite >= flushInterval)) Write(c); if (c.dirty && now - c.lastSave >= SAVE_INTERVAL) Save(c); if (push) Push(c); if (!c.running && c.file.is_open() && now - c.endedAt >= LATE_GRACE) Close(c); if (!c.running && now - c.endedAt >= KEEP_ENDED) it = g_Captures.erase(it); else ++it; } } void Shutdown() { for (auto& [id, c] : g_Captures) { if (c.running) End(c, "The dashboard stopped"); Close(c); } } void RegisterRoutes() { Game::web.RegisterWSSubscription(TOPIC, std::function([] { return Permissions::Level(PERMISSION); })); Route(eHTTPMethod::GET, "/api/inspector/targets", Perm(PERMISSION), "Accounts and characters to capture, by part of an account or character name, or an account or character ID. Query: ?q=", [](HTTPReply& reply, const HTTPContext& context) { const auto q = QueryValue(context.queryString, "q"); std::set online; for (const auto& player : LiveWorld::OnlinePlayers()) online.insert(player.value("id", std::string{})); json accounts = json::array(); const auto add = [&](uint32_t accountId, const std::string& name) { json characters = json::array(); bool anyOnline = false; for (const auto& character : Database::Get()->GetAccountCharacters(accountId)) { const auto id = character.value("id", std::string{}); const bool on = online.contains(id); anyOnline |= on; characters.push_back({ {"id", id}, {"name", character.value("name", std::string{})}, {"online", on} }); } accounts.push_back({ {"id", accountId}, {"name", name}, {"online", anyOnline}, {"characters", characters} }); }; // A pasted character object ID finds its account const auto characterId = GeneralUtils::TryParse(q); if (characterId && *characterId > UINT32_MAX) { if (const auto info = Database::Get()->GetCharacterInfo(*characterId)) { add(info->accountId, Database::Get()->GetAccountById(info->accountId).value("name", std::string{})); } } else { const auto table = Database::Get()->GetAccountsTable(0, 10, q, 1, true); for (const auto& row : table.value("data", json::array())) { const auto id = row["id"].is_number() ? row["id"].get() : GeneralUtils::TryParse(row["id"].get()).value_or(0); add(id, row.value("name", std::string{})); } } JsonSuccess(reply, { {"accounts", accounts} }); }); Route(eHTTPMethod::GET, "/api/inspector/packet-captures", Perm(PERMISSION), "Running and recently finished packet captures", [](HTTPReply& reply, const HTTPContext&) { json captures = json::array(); for (auto it = g_Captures.rbegin(); it != g_Captures.rend(); ++it) captures.push_back(SessionJson(it->second)); JsonSuccess(reply, { {"captures", captures}, {"maxSeconds", MessageCapture::MAX_SECONDS}, {"defaultSeconds", DEFAULT_SECONDS}, {"slots", MessageCapture::MAX_SLOTS} }); }); Route(eHTTPMethod::POST, "/api/inspector/packet-captures", Perm(PERMISSION), "Arm a packet capture on every server (audited). Body: {target: account|character|everything, account (name or ID), character (name or ID), seconds}", [](HTTPReply& reply, const HTTPContext& context) { const auto body = ParseBody(context); if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON"); const auto seconds = body->value("seconds", DEFAULT_SECONDS); if (seconds < 1 || seconds > MessageCapture::MAX_SECONDS) { return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "A capture can run from 1 second to " + std::to_string(MessageCapture::MAX_SECONDS / 60) + " minutes"); } Capture c; if (!Prepare(*body, c, reply)) return; // A free slot (each running capture has one bit in the servers' records) std::set taken; size_t everything = 0; for (const auto& [id, other] : g_Captures) { if (!other.running) continue; taken.insert(other.control.slot); if (other.control.target == eCaptureTarget::EVERYTHING) everything++; const bool same = other.control.target == c.control.target && other.control.accountId == c.control.accountId && other.session.characterId == c.session.characterId; if (same) return JsonError(reply, eHTTPStatusCode::CONFLICT, "That is already being captured (capture " + std::to_string(id) + ")"); } if (c.control.target == eCaptureTarget::EVERYTHING && everything >= MAX_EVERYTHING) { return JsonError(reply, eHTTPStatusCode::CONFLICT, "Everything is already being captured"); } uint8_t slot = 0; while (slot < MessageCapture::MAX_SLOTS && taken.contains(slot)) slot++; if (slot == MessageCapture::MAX_SLOTS) return JsonError(reply, eHTTPStatusCode::CONFLICT, "At most " + std::to_string(MessageCapture::MAX_SLOTS) + " packet captures can run at once"); if (!Connected()) return JsonError(reply, eHTTPStatusCode::SERVICE_UNAVAILABLE, "Not connected to the master server"); auto& s = c.session; s.kind = 1; s.startedById = context.accountId; s.startedBy = context.authenticatedUser; s.startedAt = std::time(nullptr); s.endsAt = s.startedAt + seconds; s.id = Database::Get()->InsertMessageCaptureSession(s); if (s.id == 0 || s.id > UINT32_MAX) return JsonError(reply, eHTTPStatusCode::INTERNAL_SERVER_ERROR, "Couldn't save the capture"); std::error_code ec; fs::create_directories(CaptureReplay::Folder(), ec); c.file.open(CaptureReplay::FileOf(s.id), std::ios::binary | std::ios::trunc); if (!c.file) { Database::Get()->DeleteMessageCaptureSession(s.id); return JsonError(reply, eHTTPStatusCode::INTERNAL_SERVER_ERROR, "Couldn't create the capture file in " + CaptureReplay::Folder().string()); } const json meta{ {"format", CaptureBundle::FORMAT_VERSION}, {"origin", "dlu-capture"}, {"captureId", s.id}, {"target", s.target}, {"server", { {"version", PROJECT_VERSION}, {"commit", DLU_GIT_COMMIT} }}, {"startedAt", s.startedAt}, {"portable", false} }; const auto header = CaptureBundle::Header(meta); c.file.write(header.data(), static_cast(header.size())); c.file.flush(); c.control.captureId = static_cast(s.id); c.control.slot = slot; c.until = Clock::now() + std::chrono::seconds(seconds); c.lastWrite = c.lastSave = Clock::now(); Arm(c); const auto summary = SessionJson(c); const auto target = s.characterId ? AuditTarget::Character(s.characterId) : AuditTarget::Account(s.accountId); Audit(context, "arm_packet_capture", "Armed a packet capture of " + Describe(s) + " for " + std::to_string(seconds) + " s (capture " + std::to_string(s.id) + ")", target); g_Captures.emplace(c.control.captureId, std::move(c)); JsonSuccess(reply, { {"capture", summary} }); }); Route(eHTTPMethod::POST, "/api/inspector/packet-captures/:id/stop", Perm(PERMISSION), "Stop a running packet capture", [](HTTPReply& reply, const HTTPContext& context) { const auto id = PathId(context.path, 3); auto* c = id ? Live(*id) : nullptr; if (!c || !c->running) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such running packet capture"); End(*c, "Stopped by " + context.authenticatedUser); Push(*c); Audit(context, "stop_packet_capture", "Stopped the packet capture of " + Describe(c->session) + " (capture " + std::to_string(c->session.id) + ")"); JsonSuccess(reply, { {"capture", SessionJson(*c)} }); }); Route(eHTTPMethod::GET, "/api/inspector/sessions/:id/packets", Perm(PERMISSION), "A packet capture's packets on one timeline, decoded. Query: ?offset=&limit=<1-2000>&q=&source=auth|chat|world|master&fields=0", [](HTTPReply& reply, const HTTPContext& context) { const auto session = FindSession(context, reply); if (!session) return; const auto* cached = Load(*session, reply); if (!cached) return; const auto offset = GeneralUtils::TryParse(QueryValue(context.queryString, "offset")).value_or(0); const auto limit = std::clamp(GeneralUtils::TryParse(QueryValue(context.queryString, "limit")).value_or(PAGE_DEFAULT), 1, PAGE_MAX); auto q = QueryValue(context.queryString, "q"); for (auto& ch : q) ch = static_cast(std::toupper(static_cast(ch))); const auto source = QueryValue(context.queryString, "source"); const bool fields = QueryValue(context.queryString, "fields") != "0"; json records = json::array(); size_t matched = 0; const auto& all = cached->bundle.records; for (size_t i = 0; i < all.size(); i++) { if (q.empty() && source.empty()) { if (i < offset) continue; if (records.size() >= limit) break; records.push_back(CaptureTools::RecordJson(all[i], i, cached->startUs, fields)); continue; } auto record = CaptureTools::RecordJson(all[i], i, cached->startUs, false); if (!source.empty() && record.value("source", std::string{}) != source) continue; if (!q.empty() && record.value("name", std::string{}).find(q) == std::string::npos) continue; if (matched++ < offset || records.size() >= limit) continue; records.push_back(fields ? CaptureTools::RecordJson(all[i], i, cached->startUs, true) : record); } JsonSuccess(reply, { {"capture", StoredSessionJson(*session)}, {"total", all.size()}, {"matched", q.empty() && source.empty() ? all.size() : matched}, {"start", cached->startUs / 1000}, {"duration", all.empty() ? 0.0 : static_cast(all.back().header.timeUs - cached->startUs) / 1000.0}, {"records", records} }); }); Route(eHTTPMethod::GET, "/api/inspector/sessions/:id/packets/:index", Perm(PERMISSION), "One captured packet with its bytes", [](HTTPReply& reply, const HTTPContext& context) { const auto session = FindSession(context, reply); if (!session) return; const auto* cached = Load(*session, reply); if (!cached) return; const auto index = PathId(context.path, 5); if (!index || *index >= cached->bundle.records.size()) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such packet"); const auto& record = cached->bundle.records[*index]; auto out = CaptureTools::RecordJson(record, *index, cached->startUs, true); out["hex"] = MessageCapture::ToHex(record.bytes); JsonSuccess(reply, { {"record", out} }); }); Route(eHTTPMethod::GET, "/api/inspector/sessions/:id/positions", Perm(PERMISSION), "Where the captured characters moved, in the World 3D replay's shape. Query: ?zone= (default: the first zone with movement)", [](HTTPReply& reply, const HTTPContext& context) { const auto session = FindSession(context, reply); if (!session) return; const auto* cached = Load(*session, reply); if (!cached) return; const auto tracks = CaptureTools::Tracks(cached->bundle.records, cached->startUs); std::set zones; for (const auto& t : tracks) zones.insert(t.zoneId); auto zone = GeneralUtils::TryParse(QueryValue(context.queryString, "zone")).value_or(zones.empty() ? 0 : *zones.begin()); json players = json::array(); for (const auto& t : tracks) { if (t.zoneId != zone) continue; const auto info = t.characterId ? Database::Get()->GetCharacterInfo(t.characterId) : std::nullopt; players.push_back({ {"id", std::to_string(t.characterId) + ":" + std::to_string(t.instanceId)}, {"name", info ? info->name : "Character " + std::to_string(t.characterId)}, {"instances", json::array({ t.instanceId })}, {"samples", t.samples} }); } const auto from = cached->startUs / 1000000; const auto to = cached->bundle.records.empty() ? from + 1 : cached->bundle.records.back().header.timeUs / 1000000 + 1; JsonSuccess(reply, { {"zone", zone}, {"zones", zones}, {"from", from}, {"to", to}, {"players", players}, {"idleSeconds", 3}, {"bucket", 1}, {"interval", 1}, {"truncated", false} }); }); Route(eHTTPMethod::GET, "/api/inspector/sessions/:id/bundle", Perm(PERMISSION), "Export a packet capture as a portable bundle for the capture tool (audited). Query: ?anonymise=1 also blanks character names and chat", [](HTTPReply& reply, const HTTPContext& context) { const auto session = FindSession(context, reply); if (!session) return; const auto* cached = Load(*session, reply); if (!cached) return; const bool anonymise = QueryValue(context.queryString, "anonymise") == "1"; auto bundle = cached->bundle; bundle.meta["origin"] = "dlu-capture"; bundle.meta["exportedAt"] = std::time(nullptr); bundle.meta["server"] = { {"version", PROJECT_VERSION}, {"commit", DLU_GIT_COMMIT} }; // Zone and client data checksums seen in the capture, so a replay on other data is reported, not diffed json zones = json::object(); for (const auto& record : bundle.records) { const auto decoded = PacketDecoder::Decode(record.bytes, CaptureTools::FromClient(record.header)); if (!decoded.fields) continue; if (decoded.name == "LOAD_STATIC_ZONE") zones[std::to_string((*decoded.fields)["mapID"].get())] = (*decoded.fields)["mapChecksum"]; if (decoded.name == "VALIDATION") bundle.meta["fdbChecksum"] = (*decoded.fields)["fdbChecksum"]; } bundle.meta["zones"] = zones; const auto characters = CaptureTools::MakePortable(bundle); if (anonymise) CaptureTools::Anonymise(bundle); // The setup section: what a replay needs to make these characters on another server (their saved data, // without the account it belonged to) json setup = json::array(); static const std::regex account(R"( acct="[0-9]+")"); for (const auto& [symbol, id] : characters) { const auto info = Database::Get()->GetCharacterInfo(id); if (!info) continue; auto xml = Database::Get()->GetCharacterXml(id); xml = std::regex_replace(xml, account, ""); setup.push_back({ {"symbol", symbol}, {"placeholder", bundle.meta["ids"][symbol]["placeholder"]}, {"name", anonymise ? symbol : info->name}, {"xml", xml} }); } bundle.meta["setup"] = { {"characters", setup} }; std::string out = CaptureBundle::Header(bundle.meta); for (const auto& record : bundle.records) CaptureBundle::AppendRecord(out, record); Audit(context, "export_packet_capture", "Exported the packet capture of " + Describe(*session) + (anonymise ? " (anonymised)" : "") + " as a bundle", session->characterId ? AuditTarget::Character(session->characterId) : AuditTarget::Account(session->accountId)); reply.status = eHTTPStatusCode::OK; reply.message = std::move(out); reply.contentType = eContentType::APPLICATION_OCTET_STREAM; reply.headers.push_back("Content-Disposition: attachment; filename=\"capture_" + std::to_string(session->id) + (anonymise ? "_anonymised" : "") + ".bundle\""); }); } }