Files
DarkflameServer/dDashboardServer/routes/Traffic.cpp
Aaron Kimbrell 821b7c8767 feat(dashboard): permission grants count in every dashboard permission check
What someone may do on the dashboard is now their GM level's permissions plus the grants on their account, minus its
denies (PermissionGrants.h). A deny beats a grant; denies never apply to GM 9, and settings and permissions_manage stay
GM 9 only. The account's grants are read with every request (like its GM level), so a change applies at once, and
they are passed through every check: RouteUtils::Can, CanViewCharacter, the rank rules (self_* and manage_equal_rank),
routes guarded by a permission, the templates' `can`, the API documentation, API access, API key scopes (a key never
does more than its owner may now) and WebSocket subscriptions.

New permission grants_manage (GM 9 by default) and the API to manage grants: GET /api/grants/catalog, GET /api/grants,
POST /api/grants, POST /api/grants/:id/remove. Nobody grants or takes away what they don't hold themselves (a
permission, every permission of a group, a command they may use, every command up to their own GM level), and only on
accounts the rank rules let them manage (their own with self_moderation). Commands with a fixed level or a floor
above GM 1 (/execute) can't be granted. Every change goes in the audit log (grant_permission, deny_permission,
remove_grant). Also: the Showcase gate and the traffic subscription now check their permission by name.

Check: grant a GM 2 account accounts_ban (it can ban, and the Ban button shows); deny a GM 8 account accounts_view (the
accounts list is refused); give an expiry a minute ahead and see it stop; try to grant a permission your account
doesn't have (refused); dWebTests PermissionGrantsTests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 01:16:38 -05:00

413 lines
21 KiB
C++

#include "Traffic.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <map>
#include <mutex>
#include "Background.h"
#include "Database.h"
#include "Game.h"
#include "Logger.h"
#include "MessageIdentifiers.h"
#include "MetricsFormat.h"
#include "Permissions.h"
#include "RouteUtils.h"
#include "ServerState.h"
#include "ServiceType.h"
#include "TrafficHistory.h"
#include "TrafficStats.h"
#include "Web.h"
#include "Workers.h"
#include "dServer.h"
#include "eHTTPMethod.h"
#include "magic_enum.hpp"
#include "master/ServerTraffic.h"
#include "MessageType/Auth.h"
#include "MessageType/Chat.h"
#include "MessageType/Client.h"
#include "MessageType/Game.h"
#include "MessageType/Master.h"
#include "MessageType/Server.h"
#include "MessageType/World.h"
using namespace RouteUtils;
namespace {
constexpr const char* PERMISSION = "health_view";
constexpr const char* TOPIC = "traffic";
constexpr auto PUSH_INTERVAL = std::chrono::seconds(2);
constexpr auto WRITE_INTERVAL = std::chrono::seconds(15);
constexpr int64_t ONLINE_SECONDS = 20; // a server that reported this recently is shown as up
TrafficHistory g_History;
bool g_Changed = false;
std::chrono::steady_clock::time_point g_NextPush{};
std::chrono::steady_clock::time_point g_NextWrite{};
// Minutes the background writer couldn't take yet (it was still busy)
std::vector<IServerTraffic::TrafficMinute> g_Unwritten;
template<typename Enum>
std::string EnumName(uint32_t value) {
const auto name = magic_enum::enum_name(static_cast<Enum>(value));
return name.empty() ? "#" + std::to_string(value) : std::string(name);
}
std::string RakNetName(uint32_t id) {
switch (id) {
case ID_REPLICA_MANAGER_CONSTRUCTION: return "REPLICA_CONSTRUCTION";
case ID_REPLICA_MANAGER_SERIALIZE: return "REPLICA_SERIALIZE";
case ID_REPLICA_MANAGER_DESTRUCTION: return "REPLICA_DESTRUCTION";
case ID_NEW_INCOMING_CONNECTION: return "NEW_INCOMING_CONNECTION";
case ID_CONNECTION_REQUEST_ACCEPTED: return "CONNECTION_REQUEST_ACCEPTED";
case ID_DISCONNECTION_NOTIFICATION: return "DISCONNECTION_NOTIFICATION";
case ID_CONNECTION_LOST: return "CONNECTION_LOST";
default: return "RAKNET_" + std::to_string(id);
}
}
// {service, packet, game message} names of a message key
struct MessageNames {
std::string service;
std::string packet;
std::string gameMessage; // empty unless a game message
};
MessageNames NamesOf(const TrafficStats::MessageKey& key) {
if (key.service == TrafficStats::MessageKey::RAKNET) return { "RAKNET", RakNetName(key.packet), "" };
MessageNames names;
const auto service = static_cast<ServiceType>(key.service);
const auto serviceName = magic_enum::enum_name(service);
names.service = serviceName.empty() ? "SERVICE_" + std::to_string(key.service) : std::string(serviceName);
switch (service) {
case ServiceType::COMMON: names.packet = EnumName<MessageType::Server>(key.packet); break;
case ServiceType::AUTH: names.packet = EnumName<MessageType::Auth>(key.packet); break;
case ServiceType::CHAT: names.packet = EnumName<MessageType::Chat>(key.packet); break;
case ServiceType::WORLD: names.packet = EnumName<MessageType::World>(key.packet); break;
case ServiceType::CLIENT: names.packet = EnumName<MessageType::Client>(key.packet); break;
case ServiceType::MASTER: names.packet = EnumName<MessageType::Master>(key.packet); break;
default: names.packet = "#" + std::to_string(key.packet); break;
}
const bool gameMessage = (service == ServiceType::WORLD && key.packet == static_cast<uint32_t>(MessageType::World::GAME_MSG)) ||
(service == ServiceType::CLIENT && key.packet == static_cast<uint32_t>(MessageType::Client::GAME_MSG));
if (gameMessage) names.gameMessage = EnumName<MessageType::Game>(key.gameMessage);
return names;
}
// "World 1100 Avant Gardens #3" for world:1100:3, else the key capitalised
std::string LabelOf(const TrafficHistory::Server& server) {
switch (static_cast<ServiceType>(server.type)) {
case ServiceType::MASTER: return "Master";
case ServiceType::AUTH: return "Auth";
case ServiceType::CHAT: return "Chat";
case ServiceType::DASHBOARD: return "Dashboard";
case ServiceType::UGC: return "UGC";
case ServiceType::WORLD: {
std::string zoneName;
{
std::lock_guard lock(ServerState::g_StatusMutex);
for (const auto& world : ServerState::g_WorldInstances) {
if (world.mapID == server.zoneId && world.instanceID == server.instanceId) zoneName = world.zoneName;
}
}
return "World " + std::to_string(server.zoneId) + (zoneName.empty() ? "" : " " + zoneName) + " #" + std::to_string(server.instanceId);
}
default: return server.key;
}
}
double Rate(uint64_t value, int64_t seconds) {
return std::round(static_cast<double>(value) / static_cast<double>(std::max<int64_t>(seconds, 1)) * 100.0) / 100.0;
}
double Millis(uint64_t microseconds) {
return std::round(static_cast<double>(microseconds) / 10.0) / 100.0;
}
// One server's chart lines: per-second rates of each bucket, latency in ms
struct Lines {
nlohmann::json packetsIn = nlohmann::json::array(), packetsOut = nlohmann::json::array(), bytesIn = nlohmann::json::array(), bytesOut = nlohmann::json::array();
nlohmann::json http = nlohmann::json::array(), httpErrors = nlohmann::json::array(), p50 = nlohmann::json::array(), p95 = nlohmann::json::array(), p99 = nlohmann::json::array();
bool anyHttp = false;
void Add(uint64_t pin, uint64_t pout, uint64_t bin, uint64_t bout, uint64_t requests, uint64_t errors, double lat50, double lat95, double lat99, int64_t step) {
packetsIn.push_back(Rate(pin, step));
packetsOut.push_back(Rate(pout, step));
bytesIn.push_back(Rate(bin, step));
bytesOut.push_back(Rate(bout, step));
http.push_back(Rate(requests, step));
httpErrors.push_back(Rate(errors, step));
p50.push_back(requests ? nlohmann::json(lat50) : nlohmann::json(nullptr));
p95.push_back(requests ? nlohmann::json(lat95) : nlohmann::json(nullptr));
p99.push_back(requests ? nlohmann::json(lat99) : nlohmann::json(nullptr));
anyHttp = anyHttp || requests > 0;
}
nlohmann::json Json() const {
nlohmann::json out = { {"packets_in", packetsIn}, {"packets_out", packetsOut}, {"bytes_in", bytesIn}, {"bytes_out", bytesOut} };
if (anyHttp) {
out["http"] = http;
out["http_errors"] = httpErrors;
out["p50"] = p50;
out["p95"] = p95;
out["p99"] = p99;
}
return out;
}
};
nlohmann::json ServerInfo(const std::string& key, int64_t now) {
const auto& servers = g_History.Servers();
const auto it = servers.find(key);
nlohmann::json info = { {"key", key}, {"label", key}, {"online", false} };
if (it == servers.end()) {
// Only in the database (e.g. a world that has since closed)
if (key.starts_with("world:")) info["label"] = "World " + key.substr(6);
return info;
}
const auto& server = it->second;
info["label"] = LabelOf(server);
info["type"] = std::string(magic_enum::enum_name(static_cast<ServiceType>(server.type)));
info["online"] = now - server.lastSeen <= ONLINE_SECONDS;
info["last_seen"] = server.lastSeen;
info["link"] = { {"connections", server.link.connections}, {"ping_ms", server.link.averagePingMs}, {"resend_queue", server.link.resendQueue},
{"resends", server.link.resends}, {"datagrams_sent", server.link.datagramsSent}, {"datagrams_received", server.link.datagramsReceived} };
nlohmann::json gauges = nlohmann::json::object();
for (const auto& [name, value] : server.gauges) gauges[name] = value;
info["gauges"] = gauges;
return info;
}
// The chart data of a range: live (5m) and 1h from memory, 24h and 7d from the database plus the minutes not written yet
nlohmann::json Series(const std::string& range, int64_t now) {
int64_t span = 300, step = 1;
if (range == "1h") { span = 3600; step = 10; }
else if (range == "24h") { span = 86400; step = 300; }
else if (range == "7d") { span = 7 * 86400; step = 1800; }
const int64_t to = now - (now % step); // the current bucket isn't complete yet
const int64_t from = to - span;
const auto count = static_cast<size_t>(span / step);
nlohmann::json times = nlohmann::json::array();
for (size_t i = 0; i < count; i++) times.push_back(from + static_cast<int64_t>(i) * step);
std::map<std::string, Lines> lines;
if (step < 60) {
for (const auto& [key, points] : g_History.Buckets(from, to, step)) {
auto& line = lines[key];
for (const auto& p : points) {
const auto latency = p.Latency();
line.Add(p.packetsIn, p.packetsOut, p.bytesIn, p.bytesOut, p.httpRequests, p.http4xx + p.http5xx,
Millis(latency.Percentile(0.5)), Millis(latency.Percentile(0.95)), Millis(latency.Percentile(0.99)), step);
}
}
} else {
// Database rows per bucket and server, then the minutes still in memory
std::map<std::string, std::vector<IServerTraffic::TrafficMinute>> buckets;
const auto add = [&](const IServerTraffic::TrafficMinute& row) {
if (row.time < from || row.time >= to + step) return;
auto& list = buckets[row.server];
if (list.empty()) {
list.resize(count);
for (size_t i = 0; i < count; i++) list[i].time = from + static_cast<int64_t>(i) * step;
}
const auto index = std::min(count - 1, static_cast<size_t>((row.time - from) / step));
auto& b = list[index];
b.packetsIn += row.packetsIn; b.packetsOut += row.packetsOut; b.bytesIn += row.bytesIn; b.bytesOut += row.bytesOut;
b.httpRequests += row.httpRequests; b.http4xx += row.http4xx; b.http5xx += row.http5xx;
// Several minutes in one bucket: the worst minute's percentiles
b.latencyP50Us = std::max(b.latencyP50Us, row.latencyP50Us);
b.latencyP95Us = std::max(b.latencyP95Us, row.latencyP95Us);
b.latencyP99Us = std::max(b.latencyP99Us, row.latencyP99Us);
};
try {
for (const auto& row : Database::Get()->GetTrafficMinutes(from, to + step, step)) add(row);
} catch (const std::exception& ex) {
LOG_DEBUG("Traffic: reading server_traffic failed: %s", ex.what());
}
for (const auto& row : g_Unwritten) add(row);
for (const auto& [key, server] : g_History.Servers()) {
for (const auto& minute : server.minutes) {
const auto latency = minute.point.Latency();
add({ .time = minute.point.time, .server = key, .packetsIn = minute.point.packetsIn, .packetsOut = minute.point.packetsOut,
.bytesIn = minute.point.bytesIn, .bytesOut = minute.point.bytesOut, .httpRequests = minute.point.httpRequests,
.http4xx = minute.point.http4xx, .http5xx = minute.point.http5xx,
.latencyP50Us = static_cast<uint32_t>(latency.Percentile(0.5)), .latencyP95Us = static_cast<uint32_t>(latency.Percentile(0.95)),
.latencyP99Us = static_cast<uint32_t>(latency.Percentile(0.99)) });
}
}
for (const auto& [key, list] : buckets) {
auto& line = lines[key];
for (const auto& b : list) {
line.Add(b.packetsIn, b.packetsOut, b.bytesIn, b.bytesOut, b.httpRequests, b.http4xx + b.http5xx,
Millis(b.latencyP50Us), Millis(b.latencyP95Us), Millis(b.latencyP99Us), step);
}
}
}
nlohmann::json servers = nlohmann::json::array();
for (const auto& [key, line] : lines) {
auto info = ServerInfo(key, now);
info["series"] = line.Json();
servers.push_back(std::move(info));
}
// Servers that are up but had nothing in the range yet
for (const auto& [key, server] : g_History.Servers()) {
if (!lines.contains(key) && now - server.lastSeen <= ONLINE_SECONDS) servers.push_back(ServerInfo(key, now));
}
const int64_t since = step < 60 ? from : std::max<int64_t>(from, now - 86400);
nlohmann::json messages = nlohmann::json::array();
for (const auto& count : g_History.TopMessages("", since, 15)) {
const auto names = NamesOf(count.key);
messages.push_back({ {"direction", count.key.outbound ? "out" : "in"}, {"service", names.service}, {"packet", names.packet},
{"game_message", names.gameMessage}, {"count", count.count}, {"bytes", count.bytes} });
}
nlohmann::json routes = nlohmann::json::array();
for (const auto& [server, stats] : g_History.Routes(std::max<int64_t>(from, now - 3600))) {
if (routes.size() >= 30) break;
routes.push_back({ {"server", server}, {"route", stats.route}, {"count", stats.count}, {"status_4xx", stats.status[3]}, {"status_5xx", stats.status[4]},
{"bytes", stats.bytesOut}, {"p50", Millis(stats.latency.Percentile(0.5))}, {"p95", Millis(stats.latency.Percentile(0.95))},
{"p99", Millis(stats.latency.Percentile(0.99))} });
}
return { {"range", range}, {"from", from}, {"to", to}, {"step", step}, {"times", times}, {"servers", servers}, {"messages", messages},
{"messages_since", since}, {"routes", routes} };
}
// What open pages get every few seconds: each server's rates over its last five reported seconds
nlohmann::json Summary(int64_t now) {
nlohmann::json servers = nlohmann::json::object();
for (const auto& [key, server] : g_History.Servers()) {
if (now - server.lastSeen > ONLINE_SECONDS || server.seconds.empty()) continue;
TrafficHistory::Point p;
const auto first = server.seconds.back().time - 4;
for (auto it = server.seconds.rbegin(); it != server.seconds.rend() && it->time >= first; ++it) p.Add(*it);
servers[key] = { {"packets_in", Rate(p.packetsIn, 5)}, {"packets_out", Rate(p.packetsOut, 5)}, {"bytes_in", Rate(p.bytesIn, 5)},
{"bytes_out", Rate(p.bytesOut, 5)}, {"http", Rate(p.httpRequests, 5)} };
}
return { {"time", now}, {"servers", servers} };
}
void Write() {
auto rows = g_History.TakeFinishedMinutes(TrafficStats::Now());
g_Unwritten.insert(g_Unwritten.end(), rows.begin(), rows.end());
if (g_Unwritten.empty()) return;
// A day of a busy universe at most, should the database be away for long
if (g_Unwritten.size() > 100000) g_Unwritten.erase(g_Unwritten.begin(), g_Unwritten.begin() + static_cast<std::ptrdiff_t>(g_Unwritten.size() - 100000));
auto batch = std::move(g_Unwritten);
g_Unwritten.clear();
const bool queued = Background::Run("traffic_minutes", [batch](GameDatabase& db) -> nlohmann::json {
db.InsertTrafficMinutes(batch);
return nlohmann::json::object();
}, [](nlohmann::json, const std::string& error) {
if (!error.empty()) LOG("Traffic: writing minutes failed: %s", error.c_str());
});
if (!queued) g_Unwritten = std::move(batch); // still writing the last batch: next time
}
}
namespace Traffic {
nlohmann::json Server(const std::string& key) {
return ServerInfo(key, TrafficStats::Now());
}
void Ingest(const ServerTraffic& report) {
g_History.Ingest(static_cast<uint16_t>(report.serverType), report.zoneId, report.instanceId, report.report, TrafficStats::Now());
g_Changed = true;
}
void Update() {
const auto now = std::chrono::steady_clock::now();
if (now >= g_NextWrite) {
g_NextWrite = now + WRITE_INTERVAL;
Write();
g_History.Forget(TrafficStats::Now());
}
if (g_Changed && now >= g_NextPush) {
g_NextPush = now + PUSH_INTERVAL;
g_Changed = false;
auto summary = Summary(TrafficStats::Now());
Web::SendWSMessage(TOPIC, summary);
}
}
void AddMetrics(MetricsFormat::Writer& w) {
using MetricsFormat::Labels;
w.Declare("darkflame_net_packets_total", "Packets a server sent and received (LU packets and RakNet messages), since the dashboard started", "counter");
w.Declare("darkflame_net_bytes_total", "Bytes of the packets a server sent and received, since the dashboard started", "counter");
w.Declare("darkflame_net_messages_total", "Packets by type (the busiest types each server reports), since the dashboard started", "counter");
w.Declare("darkflame_net_datagrams_total", "UDP datagrams (acknowledgements and resends included, as RakNet counts them), since the dashboard started", "counter");
w.Declare("darkflame_net_resends_total", "Messages RakNet had to send again, since the dashboard started", "counter");
w.Declare("darkflame_net_connections", "Open RakNet connections of a server", "gauge");
w.Declare("darkflame_net_ping_milliseconds", "Average ping over a server's connections", "gauge");
w.Declare("darkflame_http_requests_total", "HTTP requests answered, by route pattern and status class, since the dashboard started", "counter");
w.Declare("darkflame_http_response_bytes_total", "HTTP response bodies sent, since the dashboard started", "counter");
w.Declare("darkflame_http_request_duration_seconds", "HTTP request latency (deferred requests until their answer went out)", "histogram");
const auto now = TrafficStats::Now();
for (const auto& [key, server] : g_History.Servers()) {
const auto& t = server.totals;
const auto labels = [&key](std::initializer_list<std::pair<std::string, std::string>> more) {
Labels out{ {"server", key} };
out.insert(out.end(), more.begin(), more.end());
return out;
};
w.Add("darkflame_net_packets_total", "", "counter", labels({ {"direction", "in"} }), static_cast<double>(t.packetsIn));
w.Add("darkflame_net_packets_total", "", "counter", labels({ {"direction", "out"} }), static_cast<double>(t.packetsOut));
w.Add("darkflame_net_bytes_total", "", "counter", labels({ {"direction", "in"} }), static_cast<double>(t.bytesIn));
w.Add("darkflame_net_bytes_total", "", "counter", labels({ {"direction", "out"} }), static_cast<double>(t.bytesOut));
w.Add("darkflame_net_datagrams_total", "", "counter", labels({ {"direction", "in"} }), static_cast<double>(t.datagramsReceived));
w.Add("darkflame_net_datagrams_total", "", "counter", labels({ {"direction", "out"} }), static_cast<double>(t.datagramsSent));
w.Add("darkflame_net_resends_total", "", "counter", labels({}), static_cast<double>(t.resends));
for (const auto& [_, count] : t.messages) {
const auto names = NamesOf(count.key);
w.Add("darkflame_net_messages_total", "", "counter", labels({ {"direction", count.key.outbound ? "out" : "in"}, {"service", names.service},
{"message", names.gameMessage.empty() ? names.packet : names.gameMessage} }), static_cast<double>(count.count));
}
if (now - server.lastSeen <= ONLINE_SECONDS) {
w.Add("darkflame_net_connections", "", "gauge", labels({}), server.link.connections);
w.Add("darkflame_net_ping_milliseconds", "", "gauge", labels({}), server.link.averagePingMs);
for (const auto& [name, value] : server.gauges) {
w.Add("darkflame_server_" + name, "Reported by a server with its traffic (see docs/Dashboard.md, Traffic diagnostics)", "gauge", labels({}), value);
}
}
static constexpr const char* CLASSES[] = { "1xx", "2xx", "3xx", "4xx", "5xx" };
for (const auto& [route, stats] : t.routes) {
for (size_t i = 0; i < 5; i++) {
if (stats.status[i]) w.Add("darkflame_http_requests_total", "", "counter", labels({ {"route", route}, {"status", CLASSES[i]} }), static_cast<double>(stats.status[i]));
}
w.Add("darkflame_http_response_bytes_total", "", "counter", labels({ {"route", route} }), static_cast<double>(stats.bytesOut));
// Buckets at every doubling from 0.1 ms (every third histogram bucket), cumulative
uint64_t cumulative = 0;
const std::string family = "darkflame_http_request_duration_seconds";
for (size_t i = 0; i + 1 < TrafficStats::Histogram::BUCKETS; i++) {
cumulative += stats.latency.At(i);
if (i % 3 != 0) continue;
w.AddSample(family, family + "_bucket", "", "histogram", labels({ {"route", route}, {"le", MetricsFormat::Value(static_cast<double>(TrafficStats::Histogram::UpperBound(i)) / 1e6)} }), static_cast<double>(cumulative));
}
w.AddSample(family, family + "_bucket", "", "histogram", labels({ {"route", route}, {"le", "+Inf"} }), static_cast<double>(stats.latency.Count()));
w.AddSample(family, family + "_sum", "", "histogram", labels({ {"route", route} }), static_cast<double>(stats.latency.Sum()) / 1e6);
w.AddSample(family, family + "_count", "", "histogram", labels({ {"route", route} }), static_cast<double>(stats.latency.Count()));
}
}
}
void RegisterRoutes() {
Game::web.RegisterWSSubscription(TOPIC, std::function<uint8_t()>([] { return Permissions::Level(PERMISSION); }), PERMISSION);
// The dashboard's own report stays here; the worker pool is what its deferred requests wait for
Game::server->SetTrafficSink([](ServerTraffic& report) { Ingest(report); });
TrafficStats::Local().SetGauge("workers_busy", [] { return static_cast<double>(Workers::Pool().Active()); });
TrafficStats::Local().SetGauge("workers_queued", [] { return static_cast<double>(Workers::Pool().Queued()); });
TrafficStats::Local().SetGauge("workers_threads", [] { return static_cast<double>(Workers::Pool().Threads()); });
Route(eHTTPMethod::GET, "/api/diagnostics/traffic", Perm(PERMISSION),
"Packets, bytes and HTTP requests per second of every server. Query: ?range=5m|1h|24h|7d. Series are per-second rates of each step, latency in ms",
[](HTTPReply& reply, const HTTPContext& context) {
auto range = QueryValue(context.queryString, "range");
if (range != "1h" && range != "24h" && range != "7d") range = "5m";
JsonSuccess(reply, Series(range, TrafficStats::Now()));
});
}
}