Files
DarkflameServer/dDashboardServer/routes/Inspector.cpp
Aaron Kimbrell 332bc04ce8 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>
2026-09-29 22:34:34 -05:00

686 lines
31 KiB
C++

#include "Inspector.h"
#include "GameText.h"
#include "MasterPackets.h"
#include <chrono>
#include <ctime>
#include <map>
#include "Background.h"
#include "BitStreamUtils.h"
#include "Database.h"
#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"
#include "Permissions.h"
#include "RouteUtils.h"
#include "Scheduler.h"
#include "ServiceType.h"
#include "Web.h"
#include "dConfig.h"
#include "dServer.h"
#include "eHTTPMethod.h"
#include "magic_enum.hpp"
using namespace RouteUtils;
namespace {
using Clock = std::chrono::steady_clock;
using Session = IMessageCaptures::MessageCaptureSession;
using Record = IMessageCaptures::MessageCaptureRecord;
constexpr const char* TOPIC = "message_capture";
constexpr const char* PERMISSION = "dev_message_inspector";
constexpr uint32_t DEFAULT_SECONDS = 120;
constexpr size_t MAX_RUNNING = 4;
// Messages saved per capture; a capture stops when it reaches this
constexpr uint64_t MAX_SAVED = 100000;
// Finished captures stay in the live list this long, and at most this many are kept there (all are saved)
constexpr auto KEEP_ENDED = std::chrono::hours(1);
constexpr size_t MAX_CAPTURES = 20;
constexpr auto PUSH_INTERVAL = std::chrono::milliseconds(250);
constexpr auto SAVE_INTERVAL = std::chrono::seconds(1);
// Ask the worlds again this often while no world has the player (logged out, or changing zones)
constexpr auto RETRY_INTERVAL = std::chrono::seconds(3);
// The world sends something at least every few seconds while it captures; silence this long means it went away
constexpr auto SILENT_AFTER = std::chrono::seconds(15);
// Messages per request for a saved session
constexpr uint32_t PAGE_DEFAULT = 2000;
constexpr uint32_t PAGE_MAX = 5000;
constexpr uint32_t SESSIONS_MAX = 200;
// Opening a finished capture is audited at most this often per staff member and capture
constexpr auto VIEW_AUDIT_INTERVAL = std::chrono::hours(1);
constexpr int64_t DEFAULT_KEEP_DAYS = 30;
constexpr int64_t DEFAULT_MAX_MB = 1024;
enum class eState : uint8_t { WAITING, CAPTURING, ENDED };
struct Capture {
Session session; // saved; session.id is the capture's id
Clock::time_point until;
MessageCaptureControl control;
eState state{ eState::WAITING };
Clock::time_point endedAt{};
Clock::time_point lastHeard{};
Clock::time_point nextRetry{};
InspectorFormat::GapCounter gaps;
std::vector<Record> unsaved; // captured since the last save
nlohmann::json unsent = nlohmann::json::array(); // captured since the last push
bool changed{}; // state changed since the last push
bool dirty{}; // session changed since the last save
};
std::map<uint32_t, Capture> g_Captures;
Clock::time_point g_NextPush{};
Clock::time_point g_NextSave{};
std::map<std::pair<uint32_t, uint64_t>, Clock::time_point> g_ViewAudits; // (account, session) -> last audited
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);
}
void SendControl(const MessageCaptureControl& control) {
if (!Connected()) return;
MasterPackets::SendToMaster(control);
}
// (Re)start in whichever world has the player, for the time that is left
void SendStart(Capture& capture) {
const auto left = std::chrono::duration_cast<std::chrono::seconds>(capture.until - Clock::now()).count();
if (left <= 0) return;
capture.control.action = eMessageCaptureControl::START;
capture.control.seconds = static_cast<uint32_t>(left);
SendControl(capture.control);
capture.nextRetry = Clock::now() + RETRY_INTERVAL;
}
void SendStop(const MessageCaptureControl& from) {
auto control = from;
control.action = eMessageCaptureControl::STOP;
SendControl(control);
}
const char* StateName(eState state) {
switch (state) {
case eState::WAITING: return "waiting";
case eState::CAPTURING: return "capturing";
default: return "ended";
}
}
std::string ZoneName(uint32_t zoneId) {
return zoneId == 0 ? "" : GameText::ZoneName(zoneId);
}
// A session for the page; `state` is the live state, or "ended" for one only in the database
nlohmann::json SessionJson(const Session& session, const char* state) {
nlohmann::json zones = nlohmann::json::array();
for (const auto& visit : GeneralUtils::SplitString(session.zones, ' ')) {
const auto parts = GeneralUtils::SplitString(visit, ':');
if (parts.size() != 3) continue;
const auto zone = GeneralUtils::TryParse<uint32_t>(parts[0]).value_or(0);
zones.push_back({ {"zone", zone}, {"zoneName", ZoneName(zone)}, {"instance", GeneralUtils::TryParse<uint32_t>(parts[1]).value_or(0)},
{"clone", GeneralUtils::TryParse<uint32_t>(parts[2]).value_or(0)} });
}
return {
{"id", session.id},
{"characterId", std::to_string(session.characterId)},
{"characterName", session.characterName},
{"accountId", session.accountId},
{"accountName", session.accountName},
{"startedBy", session.startedBy},
{"startedAt", session.startedAt},
{"endsAt", session.endsAt},
{"endedAt", session.endedAt},
{"state", state},
{"endReason", session.endReason},
{"zone", session.zoneId},
{"zoneName", ZoneName(session.zoneId)},
{"instance", session.instanceId},
{"clone", session.cloneId},
{"zones", zones},
{"received", session.messageCount},
{"bytes", session.byteCount},
{"dropped", session.dropped},
{"toServer", session.toServer},
{"toClient", session.toClient},
{"only", InspectorFormat::MessageNames(InspectorFormat::ParseIds(session.onlyMessages))},
{"skip", InspectorFormat::MessageNames(InspectorFormat::ParseIds(session.skipMessages))},
{"lastSeq", session.messageCount},
{"kind", session.kind},
{"target", session.target}
};
}
nlohmann::json Summary(const Capture& capture) {
return SessionJson(capture.session, StateName(capture.state));
}
// Write what was captured since the last save, and the session's details, as one transaction
void Save(Capture& capture) {
if (capture.unsaved.empty() && !capture.dirty) return;
try {
auto* db = Database::Get();
DatabaseTransaction transaction(*db);
db->InsertMessageCaptureEntries(capture.unsaved);
db->UpdateMessageCaptureSession(capture.session);
transaction.Commit();
} catch (const std::exception& e) {
// Rolled back; dropping this batch keeps one bad write from repeating every second
LOG("Couldn't save %zu captured message(s) of capture %llu: %s", capture.unsaved.size(), capture.session.id, e.what());
}
capture.unsaved.clear();
capture.dirty = false;
}
void End(Capture& capture, const std::string& reason) {
if (capture.state == eState::ENDED) return;
capture.state = eState::ENDED;
capture.session.endReason = reason;
capture.session.endedAt = std::time(nullptr);
capture.endedAt = Clock::now();
capture.changed = true;
capture.dirty = true;
Save(capture);
}
void Push(Capture& capture) {
if (capture.unsent.empty() && !capture.changed) return;
nlohmann::json message{ {"capture", Summary(capture)}, {"entries", std::move(capture.unsent)} };
capture.unsent = nlohmann::json::array();
capture.changed = false;
Game::web.SendWSMessage(TOPIC, message);
}
// Drop finished captures from the live list past their keep time, and the oldest finished ones over the limit
void Prune(Clock::time_point now) {
std::erase_if(g_Captures, [now](const auto& entry) { return entry.second.state == eState::ENDED && now - entry.second.endedAt > KEEP_ENDED; });
while (g_Captures.size() > MAX_CAPTURES) {
auto oldest = g_Captures.end();
for (auto it = g_Captures.begin(); it != g_Captures.end(); ++it) {
if (it->second.state == eState::ENDED && (oldest == g_Captures.end() || it->second.endedAt < oldest->second.endedAt)) oldest = it;
}
if (oldest == g_Captures.end()) break;
g_Captures.erase(oldest);
}
std::erase_if(g_ViewAudits, [now](const auto& entry) { return now - entry.second > VIEW_AUDIT_INTERVAL; });
}
// Where the world that sent this is; a new world is added to the session's list
void NoteWorld(Capture& capture, const MessageCaptureData& data) {
auto& session = capture.session;
if (session.zoneId == data.zoneId && session.instanceId == data.instanceId && session.cloneId == data.cloneId && !session.zones.empty()) return;
session.zoneId = data.zoneId;
session.instanceId = data.instanceId;
session.cloneId = data.cloneId;
const auto visit = std::to_string(data.zoneId) + ":" + std::to_string(data.instanceId) + ":" + std::to_string(data.cloneId);
const auto last = session.zones.rfind(' ');
if (session.zones.substr(last == std::string::npos ? 0 : last + 1) != visit) session.zones += (session.zones.empty() ? "" : " ") + visit;
capture.changed = true;
capture.dirty = true;
}
MessageCaptureControl ControlFor(const Session& session) {
MessageCaptureControl control;
control.captureId = static_cast<uint32_t>(session.id);
control.characterId = session.characterId;
control.toServer = session.toServer;
control.toClient = session.toClient;
control.only = InspectorFormat::ParseIds(session.onlyMessages);
control.skip = InspectorFormat::ParseIds(session.skipMessages);
return control;
}
// The running capture with this id, if it is still in the live list
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;
}
Capture* FindCapture(const HTTPContext& context, HTTPReply& reply) {
const auto id = PathId<uint32_t>(context.path, 3);
auto* capture = id ? Live(*id) : nullptr;
if (!capture) JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such running capture");
return capture;
}
// The session in the path (saving a running one first, so what is read is complete); writes a 404 if there is none
std::optional<Session> FindSession(const HTTPContext& context, HTTPReply& reply) {
const auto id = PathId<uint64_t>(context.path, 3);
if (id) {
if (auto* capture = Live(*id)) {
Save(*capture);
return capture->session;
}
if (auto session = Database::Get()->GetMessageCaptureSession(*id)) return session;
}
JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such capture (it may have been deleted)");
return std::nullopt;
}
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";
}
std::string Describe(const Session& session) {
return session.characterName + " (capture " + std::to_string(session.id) + " by " + session.startedBy + ", " +
std::to_string(session.messageCount) + " messages)";
}
// Delete saved captures past inspector_session_days, then the oldest over inspector_max_mb
void PruneSessions(Scheduler::RunPtr run) {
const auto days = Setting("inspector_session_days", DEFAULT_KEEP_DAYS);
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 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) {
const auto page = db.GetMessageCaptureSessions({ .order = IMessageCaptures::eSessionOrder::STARTED, .ascending = true, .limit = 1000, .offset = offset });
for (const auto& session : page) {
stored.push_back({ session.id, session.startedAt, session.byteCount, session.endedAt == 0 });
total += session.byteCount;
}
if (page.size() < 1000) break;
}
const auto expired = InspectorFormat::SelectExpired(stored, std::time(nullptr), days, maxMb > 0 ? static_cast<uint64_t>(maxMb) * 1024 * 1024 : 0);
uint64_t freed = 0;
for (const auto id : expired) {
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) {
if (!error.empty()) return run->Finish(false, "Failed: " + error);
const auto mb = [](uint64_t bytes) { return std::to_string((bytes + 1024 * 1024 - 1) / (1024 * 1024)) + " MB"; };
run->Log(std::to_string(result["sessions"].get<uint64_t>()) + " saved capture(s), about " + mb(result["total"].get<uint64_t>()));
run->Finish(true, "Deleted " + std::to_string(result["deleted"].get<uint64_t>()) + " saved capture(s), about " + mb(result["freed"].get<uint64_t>()));
});
if (!queued) run->Finish(false, "Pruning is already running");
}
// ?character=&account=&staff=&since=&until=&running=1&order=&dir=asc&offset=&limit=. Sets `error` for a filter it can't read.
std::optional<IMessageCaptures::SessionQuery> SessionQueryFrom(const std::string& query, std::string& error, bool& none) {
IMessageCaptures::SessionQuery q;
const auto character = QueryValue(query, "character");
if (!character.empty()) {
// An ID as it is (the character may have been deleted since), a name by looking it up
const auto number = GeneralUtils::TryParse<LWOOBJID>(character);
const auto id = number ? number : ResolveCharacter(character);
if (!id) none = true;
else q.characterId = *id;
}
const auto account = QueryValue(query, "account");
if (!account.empty()) {
const auto number = GeneralUtils::TryParse<uint32_t>(account);
const auto info = number ? std::nullopt : Database::Get()->GetAccountInfo(account);
if (number) q.accountId = *number;
else if (info) q.accountId = info->id;
else none = true;
}
q.startedBy = QueryValue(query, "staff");
q.since = GeneralUtils::TryParse<int64_t>(QueryValue(query, "since")).value_or(0);
q.until = GeneralUtils::TryParse<int64_t>(QueryValue(query, "until")).value_or(0);
q.unfinishedOnly = QueryValue(query, "running") == "1";
const auto order = QueryValue(query, "order");
if (!order.empty()) {
const auto parsed = InspectorFormat::ParseOrder(order);
if (!parsed) {
error = "Unknown order: " + order;
return std::nullopt;
}
q.order = *parsed;
}
q.ascending = QueryValue(query, "dir") == "asc";
q.offset = GeneralUtils::TryParse<uint32_t>(QueryValue(query, "offset")).value_or(0);
q.limit = std::clamp<uint32_t>(GeneralUtils::TryParse<uint32_t>(QueryValue(query, "limit")).value_or(25), 1, SESSIONS_MAX);
return q;
}
}
namespace Inspector {
void Initialize() {
Scheduler::Register({ "message_capture_pruning", "Message capture pruning",
"Deletes saved game message captures older than inspector_session_days, then the oldest ones while all of them together take more "
"than inspector_max_mb. Running captures are never deleted.", "50 0 * * *", PruneSessions });
// Captures that were running when the dashboard stopped: carry on with those that still have time left
const auto now = static_cast<int64_t>(std::time(nullptr));
std::vector<Session> unfinished;
try {
unfinished = Database::Get()->GetMessageCaptureSessions({ .unfinishedOnly = true, .ascending = true, .limit = 1000 });
} catch (const std::exception& e) {
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;
capture.session = session;
capture.control = ControlFor(session);
capture.until = Clock::now() + std::chrono::seconds(session.endsAt - now);
capture.nextRetry = Clock::now();
capture.lastHeard = Clock::now();
LOG("Carrying on message capture %llu of %s", session.id, session.characterName.c_str());
g_Captures.emplace(static_cast<uint32_t>(session.id), std::move(capture));
continue;
}
session.endedAt = std::min(now, session.endsAt);
session.endReason = "The dashboard stopped while it ran";
try {
Database::Get()->UpdateMessageCaptureSession(session);
} catch (const std::exception& e) {
LOG("Couldn't end message capture %llu: %s", session.id, e.what());
}
}
}
void HandleData(const MessageCaptureData& data) {
const auto it = g_Captures.find(data.captureId);
if (it == g_Captures.end() || it->second.state == eState::ENDED) {
// A world still sending for a capture that ended here (it missed the stop, or the dashboard restarted): stop it again
if (data.status != eMessageCaptureStatus::ENDED) {
MessageCaptureControl control;
control.captureId = data.captureId;
control.characterId = data.characterId;
SendStop(control);
}
return;
}
auto& capture = it->second;
capture.lastHeard = Clock::now();
NoteWorld(capture, data);
// The world numbers its messages from 1 again when the capture starts there
if (data.status == eMessageCaptureStatus::STARTED) capture.gaps.Restart();
if (capture.state == eState::WAITING && data.status != eMessageCaptureStatus::ENDED) {
capture.state = eState::CAPTURING;
capture.changed = true;
}
auto& session = capture.session;
session.dropped += data.dropped;
if (data.dropped) capture.changed = capture.dirty = true;
bool full = false;
for (const auto& entry : data.entries) {
if (session.messageCount >= MAX_SAVED) {
full = true;
break;
}
const auto gap = capture.gaps.Next(entry.sequence);
auto record = InspectorFormat::ToRecord(entry, session.id, static_cast<uint32_t>(++session.messageCount), gap, data.zoneId, data.instanceId, data.cloneId);
session.byteCount += InspectorFormat::StoredBytes(record);
capture.unsent.push_back(InspectorFormat::RecordJson(record));
capture.unsaved.push_back(std::move(record));
capture.dirty = true;
}
if (full) {
SendStop(capture.control);
End(capture, "Reached " + std::to_string(MAX_SAVED) + " messages, the most one capture keeps");
return;
}
if (data.status == eMessageCaptureStatus::ENDED) {
switch (data.reason) {
case eMessageCaptureEnd::PLAYER_LEFT:
// Most often a zone change: look for the player in the other worlds until the time runs out
capture.state = eState::WAITING;
capture.nextRetry = Clock::now() + RETRY_INTERVAL;
capture.changed = true;
break;
case eMessageCaptureEnd::TIME_LIMIT:
End(capture, "Time limit reached");
break;
default:
End(capture, "Stopped");
break;
}
}
}
void Update() {
const auto now = Clock::now();
if (g_Captures.empty()) return;
if (now >= g_NextSave) {
g_NextSave = now + SAVE_INTERVAL;
for (auto& [id, capture] : g_Captures) Save(capture);
}
if (now < g_NextPush) return;
g_NextPush = now + PUSH_INTERVAL;
for (auto& [id, capture] : g_Captures) {
if (capture.state != eState::ENDED) {
if (now >= capture.until) {
SendStop(capture.control);
End(capture, "Time limit reached");
} else if (capture.state == eState::CAPTURING && now - capture.lastHeard > SILENT_AFTER) {
// The world stopped answering (crashed or shut down); look for the player again
capture.state = eState::WAITING;
capture.changed = true;
SendStart(capture);
} else if (capture.state == eState::WAITING && now >= capture.nextRetry) {
SendStart(capture);
}
}
Push(capture);
}
Prune(now);
}
void Shutdown() {
for (auto& [id, capture] : g_Captures) Save(capture);
}
void RegisterRoutes() {
Game::web.RegisterWSSubscription(TOPIC, std::function<uint8_t()>([] { return Permissions::Level(PERMISSION); }), PERMISSION);
Route(eHTTPMethod::GET, "/inspector", Perm(PERMISSION), "The game message inspector",
[](HTTPReply& reply, const HTTPContext& context) {
RenderPage(reply, context, "inspector.jinja2", "inspector");
});
Route(eHTTPMethod::GET, "/api/inspector/messages", Perm(PERMISSION), "Every game message the server knows, by ID and name, for capture filters",
[](HTTPReply& reply, const HTTPContext&) {
static const auto messages = InspectorFormat::AllMessages();
JsonSuccess(reply, { {"messages", messages} });
});
Route(eHTTPMethod::GET, "/api/inspector/captures", Perm(PERMISSION), "Running and recently finished message captures",
[](HTTPReply& reply, const HTTPContext&) {
nlohmann::json captures = nlohmann::json::array();
for (auto it = g_Captures.rbegin(); it != g_Captures.rend(); ++it) captures.push_back(Summary(it->second));
JsonSuccess(reply, { {"captures", captures}, {"maxSeconds", MessageCapture::MAX_SECONDS}, {"defaultSeconds", DEFAULT_SECONDS} });
});
Route(eHTTPMethod::POST, "/api/inspector/captures", Perm(PERMISSION),
"Start capturing an online player's game messages. Body: {character (name or ID), seconds (1-900), toServer, toClient, only: [message names or IDs], skip: [...]}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const auto characterId = ResolveCharacter(body->value("character", std::string{}));
const auto info = characterId ? Database::Get()->GetCharacterInfo(*characterId) : std::nullopt;
if (!info) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such character");
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");
}
MessageCaptureControl control;
control.characterId = info->id;
control.toServer = body->value("toServer", true);
control.toClient = body->value("toClient", true);
if (!control.toServer && !control.toClient) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Capture at least one direction");
std::string error;
const auto only = InspectorFormat::ParseMessageList(body->contains("only") ? (*body)["only"] : nlohmann::json(), error);
const auto skip = only ? InspectorFormat::ParseMessageList(body->contains("skip") ? (*body)["skip"] : nlohmann::json(), error) : std::nullopt;
if (!only || !skip) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, error);
control.only = *only;
control.skip = *skip;
size_t running = 0;
for (const auto& [id, capture] : g_Captures) {
if (capture.state == eState::ENDED) continue;
running++;
if (capture.session.characterId == info->id) {
return JsonError(reply, eHTTPStatusCode::CONFLICT, info->name + " is already being captured (capture " + std::to_string(id) + ")");
}
}
if (running >= MAX_RUNNING) return JsonError(reply, eHTTPStatusCode::CONFLICT, "At most " + std::to_string(MAX_RUNNING) + " captures can run at once; stop one first");
if (!Connected()) return JsonError(reply, eHTTPStatusCode::SERVICE_UNAVAILABLE, "Not connected to the master server");
Capture capture;
auto& session = capture.session;
session.characterId = info->id;
session.characterName = info->name;
session.accountId = info->accountId;
const auto account = Database::Get()->GetAccountById(info->accountId);
session.accountName = account.value("name", std::string{});
session.startedById = context.accountId;
session.startedBy = context.authenticatedUser;
session.startedAt = std::time(nullptr);
session.endsAt = session.startedAt + seconds;
session.toServer = control.toServer;
session.toClient = control.toClient;
session.onlyMessages = InspectorFormat::IdsText(control.only);
session.skipMessages = InspectorFormat::IdsText(control.skip);
session.id = Database::Get()->InsertMessageCaptureSession(session);
if (session.id == 0 || session.id > UINT32_MAX) return JsonError(reply, eHTTPStatusCode::INTERNAL_SERVER_ERROR, "Couldn't save the capture");
capture.until = Clock::now() + std::chrono::seconds(seconds);
control.captureId = static_cast<uint32_t>(session.id);
capture.control = control;
SendStart(capture);
const auto summary = Summary(capture);
g_Captures.emplace(control.captureId, std::move(capture));
Prune(Clock::now());
Audit(context, "start_message_capture", "Capturing game messages of " + info->name + " for " + std::to_string(seconds) + " s (capture " +
std::to_string(control.captureId) + ")", AuditTarget::Character(info->id));
JsonSuccess(reply, { {"capture", summary} });
});
Route(eHTTPMethod::POST, "/api/inspector/captures/:id/stop", Perm(PERMISSION), "Stop a running capture",
[](HTTPReply& reply, const HTTPContext& context) {
auto* capture = FindCapture(context, reply);
if (!capture) return;
if (capture->state == eState::ENDED) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "That capture has already ended");
SendStop(capture->control);
End(*capture, "Stopped by " + context.authenticatedUser);
Push(*capture);
Audit(context, "stop_message_capture", "Stopped capturing game messages of " + capture->session.characterName + " (capture " +
std::to_string(capture->session.id) + ")", AuditTarget::Character(capture->session.characterId));
JsonSuccess(reply, { {"capture", Summary(*capture)} });
});
Route(eHTTPMethod::GET, "/api/inspector/sessions", Perm(PERMISSION),
"Saved captures, newest first. Query: ?character=<name or ID>&account=<name or ID>&staff=<who started it>&since=&until=<Unix seconds>"
"&running=1&order=started|character|started_by|messages|bytes&dir=asc|desc&offset=&limit=<1-200>",
[](HTTPReply& reply, const HTTPContext& context) {
std::string error;
bool none = false;
const auto query = SessionQueryFrom(context.queryString, error, none);
if (!query) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, error);
nlohmann::json sessions = nlohmann::json::array();
uint64_t total = 0;
// A character or account that doesn't exist matches nothing
if (!none) {
total = Database::Get()->CountMessageCaptureSessions(*query);
for (const auto& session : Database::Get()->GetMessageCaptureSessions(*query)) {
// A running capture's counts in the database can be a second behind
const auto* capture = Live(session.id);
sessions.push_back(capture ? Summary(*capture) : SessionJson(session, "ended"));
}
}
JsonSuccess(reply, { {"sessions", sessions}, {"total", total}, {"offset", query->offset}, {"limit", query->limit},
{"orders", InspectorFormat::OrderNames()} });
});
Route(eHTTPMethod::GET, "/api/inspector/sessions/:id", Perm(PERMISSION), "One saved or running capture",
[](HTTPReply& reply, const HTTPContext& context) {
const auto session = FindSession(context, reply);
if (session) JsonSuccess(reply, { {"capture", SessionJson(*session, StateOf(*session))} });
});
Route(eHTTPMethod::GET, "/api/inspector/sessions/:id/entries", Perm(PERMISSION),
"A capture's messages in order. Query: ?after=<seq> for only later ones, &limit=<1-5000> (default 2000); more is true when there are more",
[](HTTPReply& reply, const HTTPContext& context) {
const auto session = FindSession(context, reply);
if (!session) return;
const auto after = GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "after")).value_or(0);
const auto limit = std::clamp<uint32_t>(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "limit")).value_or(PAGE_DEFAULT), 1, PAGE_MAX);
nlohmann::json entries = nlohmann::json::array();
for (const auto& record : Database::Get()->GetMessageCaptureEntries(session->id, after, limit)) entries.push_back(InspectorFormat::RecordJson(record));
const bool more = entries.size() == limit;
// Opening someone else's finished capture is audited (at most hourly per staff member and capture); starting one already was
const auto* capture = Live(session->id);
if (after == 0 && (!capture || capture->state == eState::ENDED) && session->startedById != context.accountId) {
auto& last = g_ViewAudits[{ context.accountId, session->id }];
if (last == Clock::time_point{} || Clock::now() - last > VIEW_AUDIT_INTERVAL) {
last = Clock::now();
Audit(context, "view_message_capture", "Opened the saved game messages of " + Describe(*session), AuditTarget::Character(session->characterId));
}
}
JsonSuccess(reply, { {"capture", SessionJson(*session, StateOf(*session))}, {"entries", entries}, {"more", more} });
});
Route(eHTTPMethod::GET, "/api/inspector/sessions/:id/download", Perm(PERMISSION), "A capture and all its messages as a JSON file (audited)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto session = FindSession(context, reply);
if (!session) return;
nlohmann::json entries = nlohmann::json::array();
for (uint32_t after = 0;;) {
const auto page = Database::Get()->GetMessageCaptureEntries(session->id, after, PAGE_MAX);
for (const auto& record : page) entries.push_back(InspectorFormat::RecordJson(record));
if (page.size() < PAGE_MAX) break;
after = page.back().seq;
}
Audit(context, "export_message_capture", "Downloaded the game messages of " + Describe(*session), AuditTarget::Character(session->characterId));
nlohmann::json file{ {"capture", SessionJson(*session, StateOf(*session))}, {"entries", std::move(entries)} };
reply.status = eHTTPStatusCode::OK;
reply.message = file.dump(1, '\t');
reply.contentType = eContentType::APPLICATION_JSON;
reply.headers.push_back("Content-Disposition: attachment; filename=\"capture_" + std::to_string(session->id) + ".json\"");
});
Route(eHTTPMethod::POST, "/api/inspector/sessions/:id/delete", Perm(PERMISSION), "Delete a finished capture and its messages (audited). Body: {reason}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const auto session = FindSession(context, reply);
if (!session) return;
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),
AuditTarget::Character(session->characterId));
JsonSuccess(reply);
});
}
}