From b0819df5b611ba10d30802029c03aa11d7a393e9 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 02:13:41 -0500 Subject: [PATCH] feat(dashboard): Network summary has each server's port and machine The dashboard keeps master's server list endpoints and joins them into the Network summary: its listening port and its machine as a token, since the summary goes to every open page. The connection list maps the tokens to addresses for viewers with network_ips. The dashboard reports its web port. Co-Authored-By: Claude Opus 5.5 --- dDashboardServer/DashboardServer.cpp | 10 +++++ dDashboardServer/routes/NetworkView.cpp | 18 ++++++-- dDashboardServer/routes/NetworkView.h | 21 +++++++-- dDashboardServer/routes/ServerState.h | 9 ++++ dDashboardServer/routes/Traffic.cpp | 24 +++++++---- tests/dWebTests/NetworkViewTests.cpp | 57 +++++++++++++++++++++++++ 6 files changed, 124 insertions(+), 15 deletions(-) diff --git a/dDashboardServer/DashboardServer.cpp b/dDashboardServer/DashboardServer.cpp index 60b409502..c7ab77e30 100644 --- a/dDashboardServer/DashboardServer.cpp +++ b/dDashboardServer/DashboardServer.cpp @@ -94,6 +94,7 @@ #include "WorldView.h" #include "PrometheusMetrics.h" #include "Traffic.h" +#include "TrafficHistory.h" #include "master/ServerTraffic.h" #include "master/Profiling.h" #include "Performance.h" @@ -135,6 +136,7 @@ namespace ServerState { uint32_t g_UgcPid{}; std::vector g_WorldInstances{}; std::vector g_PendingWorlds{}; + std::map g_Endpoints{}; std::mutex g_StatusMutex{}; } @@ -238,6 +240,12 @@ namespace { } } + ServerState::g_Endpoints.clear(); + for (const auto& endpoint : list.endpoints) { + const auto key = TrafficHistory::KeyFor(static_cast(endpoint.type), endpoint.zoneID, endpoint.instanceID); + ServerState::g_Endpoints[key] = { endpoint.ip.string, endpoint.port, endpoint.host.string }; + } + LOG_DEBUG("Received server list: auth=%s chat=%s ugc=%s worlds=%u", list.authOnline ? "online" : "offline", list.chatOnline ? "online" : "offline", @@ -451,6 +459,8 @@ int main(int argc, char** argv) { &Game::lastSignal, masterPassword ); + // Master (and the Network page) show the port browsers use + g_Server->SetAdvertisedPort(ourPort); Game::server = g_Server; // What the worker threads read from the CDClient and the settings, read now on this thread: workers never query diff --git a/dDashboardServer/routes/NetworkView.cpp b/dDashboardServer/routes/NetworkView.cpp index 66bf5635f..ea096c325 100644 --- a/dDashboardServer/routes/NetworkView.cpp +++ b/dDashboardServer/routes/NetworkView.cpp @@ -47,7 +47,7 @@ namespace NetworkView { return out; } - nlohmann::json Summary(const TrafficHistory& history, int64_t now, int64_t onlineSeconds, const Labeler& label) { + nlohmann::json Summary(const TrafficHistory& history, int64_t now, int64_t onlineSeconds, const Labeler& label, const Endpoints& endpoints, uint64_t salt) { nlohmann::json servers = nlohmann::json::object(); for (const auto& [key, server] : history.Servers()) { if (now - server.lastSeen > onlineSeconds || server.seconds.empty()) continue; @@ -62,6 +62,10 @@ namespace NetworkView { entry["type"] = std::string(magic_enum::enum_name(static_cast(server.type))); entry["zone"] = server.zoneId; entry["instance"] = server.instanceId; + const auto endpoint = endpoints.find(key); + const bool known = endpoint != endpoints.end(); + entry["port"] = known && endpoint->second.port ? nlohmann::json(endpoint->second.port) : nlohmann::json(nullptr); + entry["host"] = known && !endpoint->second.host.empty() ? nlohmann::json(MaskAddress(endpoint->second.host, salt)) : nlohmann::json(nullptr); entry["http"] = Rate(p.httpRequests, WINDOW); entry["http_bytes_out"] = Rate(p.httpBytesOut, WINDOW); entry["link"] = { {"connections", server.link.connections}, {"ping_ms", server.link.averagePingMs}, {"resends", server.link.resends}, @@ -88,7 +92,8 @@ namespace NetworkView { return { {"time", now}, {"window", WINDOW}, {"servers", servers} }; } - nlohmann::json Connections(const TrafficHistory& history, int64_t now, int64_t onlineSeconds, bool showAddresses, uint64_t salt, const Labeler& label) { + nlohmann::json Connections(const TrafficHistory& history, int64_t now, int64_t onlineSeconds, bool showAddresses, uint64_t salt, const Labeler& label, + const Endpoints& endpoints) { struct Group { std::string address; std::string kind = "server"; @@ -173,6 +178,13 @@ namespace NetworkView { } list.push_back(std::move(entry)); } - return { {"time", now}, {"addresses_shown", showAddresses}, {"reported", anyReported}, {"peers", list}, {"others", others} }; + // The machines the Summary names by token + nlohmann::json hosts = nlohmann::json::object(); + if (showAddresses) { + for (const auto& [_, endpoint] : endpoints) { + if (!endpoint.host.empty()) hosts[MaskAddress(endpoint.host, salt)] = endpoint.host; + } + } + return { {"time", now}, {"addresses_shown", showAddresses}, {"reported", anyReported}, {"peers", list}, {"others", others}, {"hosts", hosts} }; } } diff --git a/dDashboardServer/routes/NetworkView.h b/dDashboardServer/routes/NetworkView.h index 913bb125d..89688aed8 100644 --- a/dDashboardServer/routes/NetworkView.h +++ b/dDashboardServer/routes/NetworkView.h @@ -2,6 +2,7 @@ #include #include +#include #include #include "json.hpp" @@ -17,22 +18,34 @@ namespace NetworkView { // A server's name for people ("World 1200 Nimbus Station #3") using Labeler = std::function; + // Where a server listens and the machine it runs on, from master's server list + struct Endpoint { + uint32_t port{}; + std::string host; // the machine's address as master sees it (empty: unknown) + }; + // By TrafficHistory key ("master", "auth", "world:1200:3") + using Endpoints = std::map; + /** * Every server that reported in the last `onlineSeconds`, by key: its packets and bytes per second in and out over * its last WINDOW reported seconds, HTTP requests, RakNet link statistics, gauges, and the split of the packets by * peer (clients, master, servers) with its HTTP requests from and to other servers, or split: null when its reports - * don't have one (an older server). Never holds addresses. + * don't have one (an older server). With `endpoints`, its listening `port` and its machine as `host`, a token + * (MaskAddress with `salt`; null when not known): the summary goes to every viewer, so it never holds addresses. */ - nlohmann::json Summary(const TrafficHistory& history, int64_t now, int64_t onlineSeconds, const Labeler& label); + nlohmann::json Summary(const TrafficHistory& history, int64_t now, int64_t onlineSeconds, const Labeler& label, + const Endpoints& endpoints = {}, uint64_t salt = 0); /** * The remote ends of every server (from each one's last report): game clients by player (else by RakNet connection), * HTTP clients of the dashboard and the UGC server by address and signed-in account (`user`, the dashboard account * the requests were signed in as), and the servers' own links. Rates per second. Account and user names are always * included; without `showAddresses` each address is replaced by a token that stays the same for the same address - * and `salt`, and ports are left out. + * and `salt`, and ports are left out. With `showAddresses`, `hosts` maps each machine's token in the Summary to its + * address (empty otherwise). */ - nlohmann::json Connections(const TrafficHistory& history, int64_t now, int64_t onlineSeconds, bool showAddresses, uint64_t salt, const Labeler& label); + nlohmann::json Connections(const TrafficHistory& history, int64_t now, int64_t onlineSeconds, bool showAddresses, uint64_t salt, const Labeler& label, + const Endpoints& endpoints = {}); // "peer-" and 8 hex digits: the same for the same address and salt, and says nothing about the address std::string MaskAddress(const std::string& address, uint64_t salt); diff --git a/dDashboardServer/routes/ServerState.h b/dDashboardServer/routes/ServerState.h index 3f4a662c4..c28258208 100644 --- a/dDashboardServer/routes/ServerState.h +++ b/dDashboardServer/routes/ServerState.h @@ -44,6 +44,13 @@ struct WorldInstanceInfo { std::string state{}; }; +// Where a server listens and the machine it runs on, from master's server list +struct ServerEndpoint { + std::string ip{}; + uint32_t port{0}; + std::string host{}; +}; + namespace ServerState { extern ServerStatus g_AuthStatus; extern ServerStatus g_ChatStatus; @@ -54,6 +61,8 @@ namespace ServerState { extern std::vector g_WorldInstances; // Worlds master launched that aren't connected yet, or that are shutting down (kept apart: not running) extern std::vector g_PendingWorlds; + // Every server master listed, by traffic key ("master", "auth", "world:1200:3") + extern std::map g_Endpoints; extern std::mutex g_StatusMutex; inline nlohmann::json GetServerStateJson() { diff --git a/dDashboardServer/routes/Traffic.cpp b/dDashboardServer/routes/Traffic.cpp index a643bdc0d..28f5c92e0 100644 --- a/dDashboardServer/routes/Traffic.cpp +++ b/dDashboardServer/routes/Traffic.cpp @@ -278,11 +278,6 @@ namespace { {"messages_since", since}, {"routes", routes} }; } - // What open pages get every few seconds (the Network page draws it; Diagnostics reloads on it) - nlohmann::json Summary(int64_t now) { - return NetworkView::Summary(g_History, now, ONLINE_SECONDS, LabelOf); - } - uint64_t AddressSalt() { static const uint64_t salt = [] { std::random_device device; @@ -291,6 +286,19 @@ namespace { return salt; } + // Each server's listening port and machine, from master's last server list + NetworkView::Endpoints Endpoints() { + NetworkView::Endpoints endpoints; + std::lock_guard lock(ServerState::g_StatusMutex); + for (const auto& [key, endpoint] : ServerState::g_Endpoints) endpoints[key] = { endpoint.port, endpoint.host }; + return endpoints; + } + + // What open pages get every few seconds (the Network page draws it; Diagnostics reloads on it) + nlohmann::json Summary(int64_t now) { + return NetworkView::Summary(g_History, now, ONLINE_SECONDS, LabelOf, Endpoints(), AddressSalt()); + } + // One server for the Network page's detail panel: what ServerInfo says, its busiest message types each way over the // last 5 minutes, and its packets and bytes per second over the last 10 minutes (5 second steps) nlohmann::json NetworkServer(const std::string& key, int64_t now) { @@ -448,7 +456,7 @@ namespace Traffic { TrafficStats::Local().SetGauge("workers_threads", [] { return static_cast(Workers::Pool().Threads()); }); Route(eHTTPMethod::GET, "/api/diagnostics/network", Perm(PERMISSION), - "The Network page's live view: every reporting server's packets and bytes per second over its last report, its split by peer (clients, master, other servers; null for servers too old to report one), HTTP requests, RakNet link statistics and gauges", + "The Network page's live view: every reporting server's packets and bytes per second over its last report, its split by peer (clients, master, other servers; null for servers too old to report one), HTTP requests, RakNet link statistics, gauges, its listening port and its machine (host: a token; the connections route maps it to the address with network_ips)", [](HTTPReply& reply, const HTTPContext&) { JsonSuccess(reply, Summary(TrafficStats::Now())); }); @@ -462,9 +470,9 @@ namespace Traffic { }); Route(eHTTPMethod::GET, "/api/diagnostics/network/connections", Perm(PERMISSION), - "Every server's remote ends from its last report, grouped by address (game clients' RakNet connections, HTTP clients, server links), with rates, ping and the logged-in player. Addresses only with network_ips; otherwise a token per address", + "Every server's remote ends from its last report, grouped by address (game clients' RakNet connections, HTTP clients, server links), with rates, ping and the logged-in player. Addresses and remote ports only with network_ips; otherwise a token per address. hosts: each machine token of the summary to its address (network_ips only)", [](HTTPReply& reply, const HTTPContext& context) { - JsonSuccess(reply, NetworkView::Connections(g_History, TrafficStats::Now(), ONLINE_SECONDS, Can(context, IPS_PERMISSION), AddressSalt(), LabelOf)); + JsonSuccess(reply, NetworkView::Connections(g_History, TrafficStats::Now(), ONLINE_SECONDS, Can(context, IPS_PERMISSION), AddressSalt(), LabelOf, Endpoints())); }); Route(eHTTPMethod::GET, "/api/diagnostics/traffic", Perm(PERMISSION), diff --git a/tests/dWebTests/NetworkViewTests.cpp b/tests/dWebTests/NetworkViewTests.cpp index d5ec0d23f..d31572ec1 100644 --- a/tests/dWebTests/NetworkViewTests.cpp +++ b/tests/dWebTests/NetworkViewTests.cpp @@ -183,3 +183,60 @@ TEST(NetworkViewTest, WebClientsBySignedInAccountWithTheirUserName) { EXPECT_EQ(hidden["peers"][1]["user"], "bob"); for (const auto& p : hidden["peers"]) EXPECT_EQ(p["address"], NetworkView::MaskAddress("198.51.100.7", 5)); } + +TEST(NetworkViewTest, ListeningPortsAndMachinesFromTheServerList) { + // Auth and a world on master's machine, a second world on another; chat is reporting but not in the list yet + TrafficHistory history; + history.Ingest(static_cast(ServiceType::AUTH), 0, 0, WorldReport(true), NOW); + history.Ingest(static_cast(ServiceType::WORLD), 1200, 3, WorldReport(true), NOW); + history.Ingest(static_cast(ServiceType::WORLD), 1200, 4, WorldReport(true), NOW); + history.Ingest(static_cast(ServiceType::CHAT), 0, 0, WorldReport(true), NOW); + const NetworkView::Endpoints endpoints{ { "auth", { 1001, "192.0.2.1" } }, { "world:1200:3", { 3015, "192.0.2.1" } }, + { "world:1200:4", { 3016, "198.51.100.20" } }, { "master", { 2000, "192.0.2.1" } } }; + + const auto json = NetworkView::Summary(history, NOW, 20, Label, endpoints, 77); + const auto& servers = json["servers"]; + EXPECT_EQ(servers["auth"]["port"], 1001); + EXPECT_EQ(servers["world:1200:3"]["port"], 3015); + EXPECT_EQ(servers["world:1200:4"]["port"], 3016); + // The machine is a token, the same for servers on the same machine + EXPECT_EQ(servers["auth"]["host"], NetworkView::MaskAddress("192.0.2.1", 77)); + EXPECT_EQ(servers["auth"]["host"], servers["world:1200:3"]["host"]); + EXPECT_EQ(servers["world:1200:4"]["host"], NetworkView::MaskAddress("198.51.100.20", 77)); + EXPECT_NE(servers["auth"]["host"], servers["world:1200:4"]["host"]); + // Not in the list: no port or machine + EXPECT_TRUE(servers["chat"]["port"].is_null()); + EXPECT_TRUE(servers["chat"]["host"].is_null()); + // It goes to everyone: no addresses + const auto dump = json.dump(); + EXPECT_EQ(dump.find("192.0.2.1"), std::string::npos); + EXPECT_EQ(dump.find("198.51.100.20"), std::string::npos); + + // The connection list names the machines only with network_ips + const auto shown = NetworkView::Connections(history, NOW, 20, true, 77, Label, endpoints); + ASSERT_EQ(shown["hosts"].size(), 2u); + EXPECT_EQ(shown["hosts"][NetworkView::MaskAddress("192.0.2.1", 77)], "192.0.2.1"); + EXPECT_EQ(shown["hosts"][NetworkView::MaskAddress("198.51.100.20", 77)], "198.51.100.20"); + const auto hidden = NetworkView::Connections(history, NOW, 20, false, 77, Label, endpoints); + EXPECT_TRUE(hidden["hosts"].empty()); + EXPECT_EQ(hidden.dump().find("192.0.2.1"), std::string::npos); +} + +TEST(NetworkViewTest, RemotePortsOnlyWithAddresses) { + // A player's and a server link's remote port: shown with network_ips, left out without + TrafficHistory history; + auto world = WorldReport(true); + world.connections = { Connection{ .address = "203.0.113.9", .port = 51234, .peer = Peer::CLIENTS, .bytesIn = 10, .accountId = 3, .account = "carol" }, + Connection{ .address = "192.0.2.1", .port = 2000, .peer = Peer::MASTER, .bytesIn = 10 } }; + world.hasConnections = true; + history.Ingest(static_cast(ServiceType::WORLD), 1200, 3, world, NOW); + for (const bool show : { true, false }) { + const auto json = NetworkView::Connections(history, NOW, 20, show, 1, Label); + ASSERT_EQ(json["peers"].size(), 2u); + for (const auto& p : json["peers"]) { + const auto& port = p["servers"][0]["port"]; + if (!show) EXPECT_TRUE(port.is_null()); + else EXPECT_EQ(port, p["kind"] == "game" ? 51234 : 2000); + } + } +}