From 7bc41f2246ef1ba6daaebcdc30f45c7a5a2608a2 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 18:40:22 -0500 Subject: [PATCH] feat(db): client system info as reported by the client client_sysinfo (migrations mysql 102, sqlite 85) keeps the system description each account's client sent at login, exactly as sent, plus the physical memory read from it. While nothing but the memory in use changes, the account's newest row gets the new time and one more login; otherwise a new row starts. Log pruning deletes rows not seen for a while (eLog::CLIENT_SYSINFO) and deleting an account deletes its rows. Tests on SQLite alone (dDatabaseSqliteTests) and in the MySQL parity tests. Co-Authored-By: Claude Opus 5.5 --- dDatabase/GameDatabase/GameDatabase.h | 3 +- .../GameDatabase/ITables/IClientSysInfo.h | 58 +++++++++++++ .../GameDatabase/ITables/IDashboardAdmin.h | 2 +- dDatabase/GameDatabase/MySQL/MySQLDatabase.h | 3 + .../GameDatabase/MySQL/Tables/Accounts.cpp | 2 +- dDatabase/GameDatabase/MySQL/Tables/Admin.cpp | 1 + .../GameDatabase/MySQL/Tables/CMakeLists.txt | 1 + .../MySQL/Tables/ClientSysInfo.cpp | 58 +++++++++++++ .../GameDatabase/SQLite/SQLiteDatabase.h | 3 + .../GameDatabase/SQLite/Tables/Accounts.cpp | 2 +- .../GameDatabase/SQLite/Tables/Admin.cpp | 1 + .../GameDatabase/SQLite/Tables/CMakeLists.txt | 1 + .../SQLite/Tables/ClientSysInfo.cpp | 60 +++++++++++++ .../GameDatabase/TestSQL/TestSQLDatabase.h | 3 + migrations/dlu/mysql/102_client_sysinfo.sql | 28 ++++++ migrations/dlu/sqlite/85_client_sysinfo.sql | 24 ++++++ .../dDatabaseTests/ChatHistorySqliteTests.cpp | 86 ++++++++++++++++++- tests/dDatabaseTests/DatabaseParityTests.cpp | 42 ++++++++- 18 files changed, 371 insertions(+), 7 deletions(-) create mode 100644 dDatabase/GameDatabase/ITables/IClientSysInfo.h create mode 100644 dDatabase/GameDatabase/MySQL/Tables/ClientSysInfo.cpp create mode 100644 dDatabase/GameDatabase/SQLite/Tables/ClientSysInfo.cpp create mode 100644 migrations/dlu/mysql/102_client_sysinfo.sql create mode 100644 migrations/dlu/sqlite/85_client_sysinfo.sql diff --git a/dDatabase/GameDatabase/GameDatabase.h b/dDatabase/GameDatabase/GameDatabase.h index 995b25652..fd7af7f1d 100644 --- a/dDatabase/GameDatabase/GameDatabase.h +++ b/dDatabase/GameDatabase/GameDatabase.h @@ -41,6 +41,7 @@ #include "IServerHealth.h" #include "IChatLog.h" #include "IChatFlags.h" +#include "IClientSysInfo.h" #include "IRelatedData.h" #include "IServerOperations.h" #include "IPlayerPositions.h" @@ -70,7 +71,7 @@ class GameDatabase : public IPropertyContents, public IProperty, public IPetNames, public ICharXml, public IMigrationHistory, public IUgc, public IFriends, public ICharInfo, public IAccounts, public IActivityLog, public IAccountsRewardCodes, public IIgnoreList, - public IBehaviors, public IUgcModularBuild, public IAuditLog, public IDashboardStats, public IAccountEmails, public IDashboardMaintenance, public IEconomyLedger, public IDashboardAdmin, public IServerConfig, public IScheduledTasks, public ICharacterSnapshots, public IAccountNotes, public IServerHealth, public IRelatedData, public IChatLog, public IChatFlags, public IAccountStrikes, public ISlashCommands, public IModeration, public IServerOperations, public IPlayerPositions, public IAiSuggestions, public ILiveOps, public IFeaturedProperties, public IMessageCaptures, public IContraband, public IPropertyRent, public IPropertyReputation, public IBbbAutosave, public IServerTraffic, public IApiKeys, public IUgcLookup, public IPermissionGrants, public IGuilds { + public IBehaviors, public IUgcModularBuild, public IAuditLog, public IDashboardStats, public IAccountEmails, public IDashboardMaintenance, public IEconomyLedger, public IDashboardAdmin, public IServerConfig, public IScheduledTasks, public ICharacterSnapshots, public IAccountNotes, public IServerHealth, public IRelatedData, public IChatLog, public IChatFlags, public IClientSysInfo, public IAccountStrikes, public ISlashCommands, public IModeration, public IServerOperations, public IPlayerPositions, public IAiSuggestions, public ILiveOps, public IFeaturedProperties, public IMessageCaptures, public IContraband, public IPropertyRent, public IPropertyReputation, public IBbbAutosave, public IServerTraffic, public IApiKeys, public IUgcLookup, public IPermissionGrants, public IGuilds { public: virtual ~GameDatabase() = default; // TODO: These should be made private. diff --git a/dDatabase/GameDatabase/ITables/IClientSysInfo.h b/dDatabase/GameDatabase/ITables/IClientSysInfo.h new file mode 100644 index 000000000..a07de2a0e --- /dev/null +++ b/dDatabase/GameDatabase/ITables/IClientSysInfo.h @@ -0,0 +1,58 @@ +#ifndef __ICLIENTSYSINFO__H__ +#define __ICLIENTSYSINFO__H__ + +#include +#include +#include + +/** + * The system description each client sent with its login request, as reported by the client (see ClientSysInfo.h: + * compatibility values from old Windows calls, not necessarily the real hardware). The auth server keeps one row per + * account while the description stays the same, and starts a new row when it changes. + */ +class IClientSysInfo { +public: + struct SysInfoRow { + uint64_t id{}; + uint32_t accountId{}; + int64_t firstSeen{}; + int64_t lastSeen{}; + uint32_t logins{ 1 }; + std::string ip; // empty while log_login_addresses is off + uint32_t clientOs{}; // the request's clientOS (1 Windows, 2 Mac) + std::string memoryStats; // the raw text of the newest login in this row + uint64_t memoryTotalKb{}; // physical memory from memoryStats (0: not read) + std::string videoCard; + uint32_t numberOfProcessors{}; + uint32_t processorType{}; + uint16_t processorLevel{}; + uint16_t processorRevision{}; + uint32_t osVersionInfoSize{}; + uint32_t majorVersion{}; + uint32_t minorVersion{}; + uint32_t buildNumber{}; + uint32_t platformId{}; + + // Whether two logins describe the same client (memory in use and the time are left out: they change each login) + bool SameAs(const SysInfoRow& other) const { + return ip == other.ip && clientOs == other.clientOs && memoryTotalKb == other.memoryTotalKb && videoCard == other.videoCard && + numberOfProcessors == other.numberOfProcessors && processorType == other.processorType && processorLevel == other.processorLevel && + processorRevision == other.processorRevision && osVersionInfoSize == other.osVersionInfoSize && majorVersion == other.majorVersion && + minorVersion == other.minorVersion && buildNumber == other.buildNumber && platformId == other.platformId; + } + }; + + /** + * A successful login's description. If it is the same as the account's newest row, that row gets the new time, one + * more login and this login's memoryStats text; otherwise a new row is added. + */ + virtual void RecordClientSysInfo(const SysInfoRow& info) = 0; + + // An account's rows, newest first + virtual std::vector GetClientSysInfo(uint32_t accountId, uint32_t limit) = 0; + + // Each account's newest row, for the spread across players + virtual std::vector GetLatestClientSysInfo(uint32_t limit) = 0; +}; + +#endif //!__ICLIENTSYSINFO__H__ diff --git a/dDatabase/GameDatabase/ITables/IDashboardAdmin.h b/dDatabase/GameDatabase/ITables/IDashboardAdmin.h index 44151ce72..bebe1e46e 100644 --- a/dDatabase/GameDatabase/ITables/IDashboardAdmin.h +++ b/dDatabase/GameDatabase/ITables/IDashboardAdmin.h @@ -100,7 +100,7 @@ public: virtual uint32_t CompactEconomy(uint32_t cutoffDay, uint32_t mapCutoffDay) = 0; virtual uint32_t PruneTransfers(int64_t beforeTime) = 0; - enum class eLog : uint8_t { ACTIVITY, COMMAND, AUDIT, CHEAT_DETECTION, CHAT, LOGIN_ADDRESS }; + enum class eLog : uint8_t { ACTIVITY, COMMAND, AUDIT, CHEAT_DETECTION, CHAT, LOGIN_ADDRESS, CLIENT_SYSINFO }; // Delete log rows older than a unix time; rows without a time (0) are kept. Returns rows deleted. virtual uint32_t PruneLog(eLog log, int64_t beforeTime) = 0; diff --git a/dDatabase/GameDatabase/MySQL/MySQLDatabase.h b/dDatabase/GameDatabase/MySQL/MySQLDatabase.h index b47c99227..866487f25 100644 --- a/dDatabase/GameDatabase/MySQL/MySQLDatabase.h +++ b/dDatabase/GameDatabase/MySQL/MySQLDatabase.h @@ -347,6 +347,9 @@ public: void InsertChatFlagEvent(const ChatFlagEvent& event) override; std::vector GetChatFlagEvents(uint64_t flagId) override; std::vector> GetFlaggedMessages(const std::vector& messageIds) override; + void RecordClientSysInfo(const SysInfoRow& info) override; + std::vector GetClientSysInfo(uint32_t accountId, uint32_t limit) override; + std::vector GetLatestClientSysInfo(uint32_t limit) override; void InsertModerationDecision(const std::string& kind, int64_t subjectId, const std::string& subject, bool approved, const std::string& reason, int64_t time) override; nlohmann::json GetModerationDecisions(const std::string& kind, int64_t subjectId, uint32_t limit) override; Totp GetTotp(uint32_t accountId) override; diff --git a/dDatabase/GameDatabase/MySQL/Tables/Accounts.cpp b/dDatabase/GameDatabase/MySQL/Tables/Accounts.cpp index aa57dbe0c..4e9709bff 100644 --- a/dDatabase/GameDatabase/MySQL/Tables/Accounts.cpp +++ b/dDatabase/GameDatabase/MySQL/Tables/Accounts.cpp @@ -201,7 +201,7 @@ void MySQLDatabase::DeleteAccount(const uint32_t accountId) { ExecuteDelete("DELETE FROM character_snapshots WHERE character_id = ?;", characterId); } // Rows about the account itself. Audit log, chat log and reports stay as the record of what happened. - for (const auto* table : { "account_tokens", "account_recovery_codes", "account_notes", "account_strikes", "account_login_addresses", + for (const auto* table : { "account_tokens", "account_recovery_codes", "account_notes", "account_strikes", "account_login_addresses", "client_sysinfo", "dashboard_preferences", "accounts_rewardcodes", "player_cheat_detections" }) { ExecuteDelete(std::string("DELETE FROM ") + table + " WHERE account_id = ?;", accountId); } diff --git a/dDatabase/GameDatabase/MySQL/Tables/Admin.cpp b/dDatabase/GameDatabase/MySQL/Tables/Admin.cpp index 035e96a0c..df574c2dd 100644 --- a/dDatabase/GameDatabase/MySQL/Tables/Admin.cpp +++ b/dDatabase/GameDatabase/MySQL/Tables/Admin.cpp @@ -297,6 +297,7 @@ uint32_t MySQLDatabase::PruneLog(eLog log, int64_t beforeTime) { case eLog::CHEAT_DETECTION: return static_cast(ExecuteUpdate("DELETE FROM player_cheat_detections WHERE violation_time < FROM_UNIXTIME(?);", beforeTime)); case eLog::CHAT: return static_cast(ExecuteUpdate("DELETE FROM chat_log WHERE time > 0 AND time < ?;", beforeTime)); case eLog::LOGIN_ADDRESS: return static_cast(ExecuteUpdate("DELETE FROM account_login_addresses WHERE last_seen < ?;", beforeTime)); + case eLog::CLIENT_SYSINFO: return static_cast(ExecuteUpdate("DELETE FROM client_sysinfo WHERE last_seen < ?;", beforeTime)); } return 0; } diff --git a/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt b/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt index a97b16896..37b313acb 100644 --- a/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt +++ b/dDatabase/GameDatabase/MySQL/Tables/CMakeLists.txt @@ -29,6 +29,7 @@ set(DDATABASES_DATABASES_MYSQL_TABLES_SOURCES "MessageCaptures.cpp" "ChatLog.cpp" "ChatFlags.cpp" + "ClientSysInfo.cpp" "RelatedData.cpp" "ActivityLog.cpp" "Behaviors.cpp" diff --git a/dDatabase/GameDatabase/MySQL/Tables/ClientSysInfo.cpp b/dDatabase/GameDatabase/MySQL/Tables/ClientSysInfo.cpp new file mode 100644 index 000000000..ccea54b57 --- /dev/null +++ b/dDatabase/GameDatabase/MySQL/Tables/ClientSysInfo.cpp @@ -0,0 +1,58 @@ +#include "MySQLDatabase.h" + +namespace { + IClientSysInfo::SysInfoRow Row(sql::ResultSet& r) { + IClientSysInfo::SysInfoRow s; + s.id = r.getUInt64("id"); + s.accountId = r.getUInt("account_id"); + s.firstSeen = r.getInt64("first_seen"); + s.lastSeen = r.getInt64("last_seen"); + s.logins = r.getUInt("logins"); + s.ip = r.getString("ip").c_str(); + s.clientOs = r.getUInt("client_os"); + s.memoryStats = r.getString("memory_stats").c_str(); + s.memoryTotalKb = r.getUInt64("memory_total_kb"); + s.videoCard = r.getString("video_card").c_str(); + s.numberOfProcessors = r.getUInt("number_of_processors"); + s.processorType = r.getUInt("processor_type"); + s.processorLevel = static_cast(r.getUInt("processor_level")); + s.processorRevision = static_cast(r.getUInt("processor_revision")); + s.osVersionInfoSize = r.getUInt("os_version_info_size"); + s.majorVersion = r.getUInt("os_major_version"); + s.minorVersion = r.getUInt("os_minor_version"); + s.buildNumber = r.getUInt("os_build_number"); + s.platformId = r.getUInt("os_platform_id"); + return s; + } +} + +void MySQLDatabase::RecordClientSysInfo(const SysInfoRow& s) { + auto newest = ExecuteSelect("SELECT * FROM client_sysinfo WHERE account_id = ? ORDER BY id DESC LIMIT 1;", s.accountId); + if (newest->next()) { + const auto previous = Row(*newest.m_resultSet); + if (previous.SameAs(s)) { + ExecuteUpdate("UPDATE client_sysinfo SET last_seen = ?, logins = logins + 1, memory_stats = ? WHERE id = ?;", s.lastSeen, s.memoryStats, previous.id); + return; + } + } + ExecuteInsert("INSERT INTO client_sysinfo (account_id, first_seen, last_seen, logins, ip, client_os, memory_stats, memory_total_kb, video_card, " + "number_of_processors, processor_type, processor_level, processor_revision, os_version_info_size, os_major_version, os_minor_version, " + "os_build_number, os_platform_id) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", + s.accountId, s.firstSeen, s.lastSeen, s.ip, s.clientOs, s.memoryStats, s.memoryTotalKb, s.videoCard, s.numberOfProcessors, s.processorType, + s.processorLevel, s.processorRevision, s.osVersionInfoSize, s.majorVersion, s.minorVersion, s.buildNumber, s.platformId); +} + +std::vector MySQLDatabase::GetClientSysInfo(const uint32_t accountId, const uint32_t limit) { + std::vector rows; + auto result = ExecuteSelect("SELECT * FROM client_sysinfo WHERE account_id = ? ORDER BY id DESC LIMIT ?;", accountId, limit); + while (result->next()) rows.push_back(Row(*result.m_resultSet)); + return rows; +} + +std::vector MySQLDatabase::GetLatestClientSysInfo(const uint32_t limit) { + std::vector rows; + auto result = ExecuteSelect("SELECT c.* FROM client_sysinfo c JOIN (SELECT MAX(id) AS id FROM client_sysinfo GROUP BY account_id) n ON n.id = c.id " + "ORDER BY c.id DESC LIMIT ?;", limit); + while (result->next()) rows.push_back(Row(*result.m_resultSet)); + return rows; +} diff --git a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h index fb7315643..f8a117d64 100644 --- a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h +++ b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h @@ -331,6 +331,9 @@ public: void InsertChatFlagEvent(const ChatFlagEvent& event) override; std::vector GetChatFlagEvents(uint64_t flagId) override; std::vector> GetFlaggedMessages(const std::vector& messageIds) override; + void RecordClientSysInfo(const SysInfoRow& info) override; + std::vector GetClientSysInfo(uint32_t accountId, uint32_t limit) override; + std::vector GetLatestClientSysInfo(uint32_t limit) override; void InsertModerationDecision(const std::string& kind, int64_t subjectId, const std::string& subject, bool approved, const std::string& reason, int64_t time) override; nlohmann::json GetModerationDecisions(const std::string& kind, int64_t subjectId, uint32_t limit) override; Totp GetTotp(uint32_t accountId) override; diff --git a/dDatabase/GameDatabase/SQLite/Tables/Accounts.cpp b/dDatabase/GameDatabase/SQLite/Tables/Accounts.cpp index 9ecfcdcd5..3b2999be9 100644 --- a/dDatabase/GameDatabase/SQLite/Tables/Accounts.cpp +++ b/dDatabase/GameDatabase/SQLite/Tables/Accounts.cpp @@ -206,7 +206,7 @@ void SQLiteDatabase::DeleteAccount(const uint32_t accountId) { ExecuteDelete("DELETE FROM character_snapshots WHERE character_id = ?;", characterId); } // Rows about the account itself. Audit log, chat log and reports stay as the record of what happened. - for (const auto* table : { "account_tokens", "account_recovery_codes", "account_notes", "account_strikes", "account_login_addresses", + for (const auto* table : { "account_tokens", "account_recovery_codes", "account_notes", "account_strikes", "account_login_addresses", "client_sysinfo", "dashboard_preferences", "accounts_rewardcodes", "player_cheat_detections" }) { ExecuteDelete(std::string("DELETE FROM ") + table + " WHERE account_id = ?;", accountId); } diff --git a/dDatabase/GameDatabase/SQLite/Tables/Admin.cpp b/dDatabase/GameDatabase/SQLite/Tables/Admin.cpp index 80dc9f598..1df4c0fd4 100644 --- a/dDatabase/GameDatabase/SQLite/Tables/Admin.cpp +++ b/dDatabase/GameDatabase/SQLite/Tables/Admin.cpp @@ -300,6 +300,7 @@ uint32_t SQLiteDatabase::PruneLog(eLog log, int64_t beforeTime) { case eLog::CHEAT_DETECTION: return static_cast(ExecuteUpdate("DELETE FROM player_cheat_detections WHERE violation_time < datetime(?, 'unixepoch');", beforeTime)); case eLog::CHAT: return static_cast(ExecuteUpdate("DELETE FROM chat_log WHERE time > 0 AND time < ?;", beforeTime)); case eLog::LOGIN_ADDRESS: return static_cast(ExecuteUpdate("DELETE FROM account_login_addresses WHERE last_seen < ?;", beforeTime)); + case eLog::CLIENT_SYSINFO: return static_cast(ExecuteUpdate("DELETE FROM client_sysinfo WHERE last_seen < ?;", beforeTime)); } return 0; } diff --git a/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt b/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt index 2b77f9d82..95fcb5c81 100644 --- a/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt +++ b/dDatabase/GameDatabase/SQLite/Tables/CMakeLists.txt @@ -29,6 +29,7 @@ set(DDATABASES_DATABASES_SQLITE_TABLES_SOURCES "MessageCaptures.cpp" "ChatLog.cpp" "ChatFlags.cpp" + "ClientSysInfo.cpp" "RelatedData.cpp" "ActivityLog.cpp" "Behaviors.cpp" diff --git a/dDatabase/GameDatabase/SQLite/Tables/ClientSysInfo.cpp b/dDatabase/GameDatabase/SQLite/Tables/ClientSysInfo.cpp new file mode 100644 index 000000000..0073cfd06 --- /dev/null +++ b/dDatabase/GameDatabase/SQLite/Tables/ClientSysInfo.cpp @@ -0,0 +1,60 @@ +#include "SQLiteDatabase.h" + +namespace { + IClientSysInfo::SysInfoRow Row(CppSQLite3Query& r) { + IClientSysInfo::SysInfoRow s; + s.id = static_cast(r.getInt64Field("id")); + s.accountId = static_cast(r.getInt64Field("account_id")); + s.firstSeen = r.getInt64Field("first_seen"); + s.lastSeen = r.getInt64Field("last_seen"); + s.logins = static_cast(r.getInt64Field("logins")); + s.ip = r.getStringField("ip"); + s.clientOs = static_cast(r.getInt64Field("client_os")); + s.memoryStats = r.getStringField("memory_stats"); + s.memoryTotalKb = static_cast(r.getInt64Field("memory_total_kb")); + s.videoCard = r.getStringField("video_card"); + s.numberOfProcessors = static_cast(r.getInt64Field("number_of_processors")); + s.processorType = static_cast(r.getInt64Field("processor_type")); + s.processorLevel = static_cast(r.getInt64Field("processor_level")); + s.processorRevision = static_cast(r.getInt64Field("processor_revision")); + s.osVersionInfoSize = static_cast(r.getInt64Field("os_version_info_size")); + s.majorVersion = static_cast(r.getInt64Field("os_major_version")); + s.minorVersion = static_cast(r.getInt64Field("os_minor_version")); + s.buildNumber = static_cast(r.getInt64Field("os_build_number")); + s.platformId = static_cast(r.getInt64Field("os_platform_id")); + return s; + } +} + +void SQLiteDatabase::RecordClientSysInfo(const SysInfoRow& s) { + { + auto [_, newest] = ExecuteSelect("SELECT * FROM client_sysinfo WHERE account_id = ? ORDER BY id DESC LIMIT 1;", s.accountId); + if (!newest.eof()) { + const auto previous = Row(newest); + if (previous.SameAs(s)) { + ExecuteUpdate("UPDATE client_sysinfo SET last_seen = ?, logins = logins + 1, memory_stats = ? WHERE id = ?;", s.lastSeen, s.memoryStats, previous.id); + return; + } + } + } + ExecuteInsert("INSERT INTO client_sysinfo (account_id, first_seen, last_seen, logins, ip, client_os, memory_stats, memory_total_kb, video_card, " + "number_of_processors, processor_type, processor_level, processor_revision, os_version_info_size, os_major_version, os_minor_version, " + "os_build_number, os_platform_id) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", + s.accountId, s.firstSeen, s.lastSeen, s.ip, s.clientOs, s.memoryStats, s.memoryTotalKb, s.videoCard, s.numberOfProcessors, s.processorType, + s.processorLevel, s.processorRevision, s.osVersionInfoSize, s.majorVersion, s.minorVersion, s.buildNumber, s.platformId); +} + +std::vector SQLiteDatabase::GetClientSysInfo(const uint32_t accountId, const uint32_t limit) { + std::vector rows; + auto [_, result] = ExecuteSelect("SELECT * FROM client_sysinfo WHERE account_id = ? ORDER BY id DESC LIMIT ?;", accountId, limit); + for (; !result.eof(); result.nextRow()) rows.push_back(Row(result)); + return rows; +} + +std::vector SQLiteDatabase::GetLatestClientSysInfo(const uint32_t limit) { + std::vector rows; + auto [_, result] = ExecuteSelect("SELECT c.* FROM client_sysinfo c JOIN (SELECT MAX(id) AS id FROM client_sysinfo GROUP BY account_id) n ON n.id = c.id " + "ORDER BY c.id DESC LIMIT ?;", limit); + for (; !result.eof(); result.nextRow()) rows.push_back(Row(result)); + return rows; +} diff --git a/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h b/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h index 070c83e7c..0a128daca 100644 --- a/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h +++ b/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h @@ -381,6 +381,9 @@ class TestSQLDatabase : public GameDatabase { void InsertChatFlagEvent(const ChatFlagEvent& event) override {} std::vector GetChatFlagEvents(uint64_t flagId) override { return {}; } std::vector> GetFlaggedMessages(const std::vector& messageIds) override { return {}; } + void RecordClientSysInfo(const SysInfoRow& info) override {} + std::vector GetClientSysInfo(uint32_t accountId, uint32_t limit) override { return {}; } + std::vector GetLatestClientSysInfo(uint32_t limit) override { return {}; } void InsertModerationDecision(const std::string& kind, int64_t subjectId, const std::string& subject, bool approved, const std::string& reason, int64_t time) override {} nlohmann::json GetModerationDecisions(const std::string& kind, int64_t subjectId, uint32_t limit) override { return nlohmann::json::array(); } Totp GetTotp(uint32_t accountId) override { return {}; } diff --git a/migrations/dlu/mysql/102_client_sysinfo.sql b/migrations/dlu/mysql/102_client_sysinfo.sql new file mode 100644 index 000000000..03158d0ed --- /dev/null +++ b/migrations/dlu/mysql/102_client_sysinfo.sql @@ -0,0 +1,28 @@ +/* The system description clients send with their login request, exactly as the client reported it (old Windows calls: + compatibility values, not necessarily the player's real hardware). Written by the auth server at each successful + login: the account's newest row gets last_seen, logins + 1 and the new memory_stats text while everything else is the + same; otherwise a new row starts. ip is empty while log_login_addresses is off. memory_total_kb is the physical memory + read from memory_stats (0: not read). Rows not seen for log_client_sysinfo_days are deleted by the log pruning task. */ +CREATE TABLE IF NOT EXISTS client_sysinfo ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + account_id INT UNSIGNED NOT NULL, + first_seen BIGINT NOT NULL, + last_seen BIGINT NOT NULL, + logins INT UNSIGNED NOT NULL DEFAULT 1, + ip VARCHAR(64) NOT NULL DEFAULT '', + client_os INT UNSIGNED NOT NULL DEFAULT 0, + memory_stats VARCHAR(512) NOT NULL DEFAULT '', + memory_total_kb BIGINT UNSIGNED NOT NULL DEFAULT 0, + video_card VARCHAR(256) NOT NULL DEFAULT '', + number_of_processors INT UNSIGNED NOT NULL DEFAULT 0, + processor_type INT UNSIGNED NOT NULL DEFAULT 0, + processor_level SMALLINT UNSIGNED NOT NULL DEFAULT 0, + processor_revision SMALLINT UNSIGNED NOT NULL DEFAULT 0, + os_version_info_size INT UNSIGNED NOT NULL DEFAULT 0, + os_major_version INT UNSIGNED NOT NULL DEFAULT 0, + os_minor_version INT UNSIGNED NOT NULL DEFAULT 0, + os_build_number INT UNSIGNED NOT NULL DEFAULT 0, + os_platform_id INT UNSIGNED NOT NULL DEFAULT 0, + INDEX client_sysinfo_account (account_id, id), + INDEX client_sysinfo_last_seen (last_seen) +); diff --git a/migrations/dlu/sqlite/85_client_sysinfo.sql b/migrations/dlu/sqlite/85_client_sysinfo.sql new file mode 100644 index 000000000..0027503b9 --- /dev/null +++ b/migrations/dlu/sqlite/85_client_sysinfo.sql @@ -0,0 +1,24 @@ +/* The system description clients send with their login request, as reported by the client. See the MySQL migration. */ +CREATE TABLE IF NOT EXISTS client_sysinfo ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + first_seen BIGINT NOT NULL, + last_seen BIGINT NOT NULL, + logins INTEGER NOT NULL DEFAULT 1, + ip TEXT NOT NULL DEFAULT '', + client_os INTEGER NOT NULL DEFAULT 0, + memory_stats TEXT NOT NULL DEFAULT '', + memory_total_kb BIGINT NOT NULL DEFAULT 0, + video_card TEXT NOT NULL DEFAULT '', + number_of_processors INTEGER NOT NULL DEFAULT 0, + processor_type INTEGER NOT NULL DEFAULT 0, + processor_level INTEGER NOT NULL DEFAULT 0, + processor_revision INTEGER NOT NULL DEFAULT 0, + os_version_info_size INTEGER NOT NULL DEFAULT 0, + os_major_version INTEGER NOT NULL DEFAULT 0, + os_minor_version INTEGER NOT NULL DEFAULT 0, + os_build_number INTEGER NOT NULL DEFAULT 0, + os_platform_id INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS client_sysinfo_account ON client_sysinfo (account_id, id); +CREATE INDEX IF NOT EXISTS client_sysinfo_last_seen ON client_sysinfo (last_seen); diff --git a/tests/dDatabaseTests/ChatHistorySqliteTests.cpp b/tests/dDatabaseTests/ChatHistorySqliteTests.cpp index 84d92a063..407808411 100644 --- a/tests/dDatabaseTests/ChatHistorySqliteTests.cpp +++ b/tests/dDatabaseTests/ChatHistorySqliteTests.cpp @@ -1,13 +1,14 @@ /** * The chat history and chat flag tables on SQLite, with no other database needed: a fresh SQLite file gets every - * migration (MigrationRunner::RunMigrations, as the servers run them), then the IChatLog and IChatFlags methods are - * checked against what was written. DatabaseParityTests compares the same methods with MySQL when one is set up. + * migration (MigrationRunner::RunMigrations, as the servers run them), then the IChatLog, IChatFlags and IClientSysInfo + * methods are checked against what was written. DatabaseParityTests compares the same methods with MySQL when one is set up. */ #include #include #include #include +#include #include #include "Database.h" @@ -307,3 +308,84 @@ TEST_F(ChatHistorySqlite, PruningOldChatKeepsFlags) { EXPECT_EQ(g_Db->CountChatMessages(all), before - 4); EXPECT_EQ(g_Db->CountChatFlags({ .includePrivate = true, .includeWhispers = true }), flags); } + +// The client system info table (IClientSysInfo) shares this SQLite file: its rows don't touch the chat tables +namespace { + IClientSysInfo::SysInfoRow SysInfo(uint32_t account, int64_t time) { + IClientSysInfo::SysInfoRow s; + s.accountId = account; + s.firstSeen = s.lastSeen = time; + s.ip = "10.0.0.1"; + s.clientOs = 1; + s.memoryStats = " 12345 p, 67890 vbytes.40 n-use.16717048 TKb-pmem."; + s.memoryTotalKb = 16717048; + s.videoCard = "NVIDIA GeForce GTX 1080 (HAL-hw vp)"; + s.numberOfProcessors = 16; + s.processorType = 586; + s.processorLevel = 6; + s.processorRevision = 0x9e0a; + s.osVersionInfoSize = 276; + s.majorVersion = 6; + s.minorVersion = 2; + s.buildNumber = 9200; + s.platformId = 2; + return s; + } +} + +TEST(ClientSysInfoSqlite, SameClientKeepsOneRow) { + auto s = SysInfo(900, 5000); + g_Db->RecordClientSysInfo(s); + s.lastSeen = 5100; + s.memoryStats = " 99999 p, 67890 vbytes.55 n-use.16717048 TKb-pmem."; // memory in use changes every login + g_Db->RecordClientSysInfo(s); + auto rows = g_Db->GetClientSysInfo(900, 10); + ASSERT_EQ(rows.size(), 1u); + EXPECT_EQ(rows[0].firstSeen, 5000); + EXPECT_EQ(rows[0].lastSeen, 5100); + EXPECT_EQ(rows[0].logins, 2u); + EXPECT_EQ(rows[0].memoryStats, s.memoryStats); // the newest login's text, as sent + EXPECT_EQ(rows[0].videoCard, s.videoCard); + EXPECT_EQ(rows[0].processorRevision, 0x9e0a); + EXPECT_EQ(rows[0].memoryTotalKb, 16717048u); + + // Something else changes: a new row, newest first; going back to the old one starts another + s.firstSeen = s.lastSeen = 5200; + s.ip = "10.0.0.2"; + g_Db->RecordClientSysInfo(s); + s.firstSeen = s.lastSeen = 5300; + s.ip = "10.0.0.1"; + g_Db->RecordClientSysInfo(s); + rows = g_Db->GetClientSysInfo(900, 10); + ASSERT_EQ(rows.size(), 3u); + EXPECT_EQ(rows[0].ip, "10.0.0.1"); + EXPECT_EQ(rows[0].firstSeen, 5300); + EXPECT_EQ(rows[1].ip, "10.0.0.2"); + EXPECT_EQ(g_Db->GetClientSysInfo(900, 1).size(), 1u); +} + +TEST(ClientSysInfoSqlite, LatestIsEachAccountsNewestRow) { + auto a = SysInfo(901, 6000); + g_Db->RecordClientSysInfo(a); + a.lastSeen = 6100; + a.buildNumber = 2600; + g_Db->RecordClientSysInfo(a); + g_Db->RecordClientSysInfo(SysInfo(902, 6050)); + std::map builds; + for (const auto& row : g_Db->GetLatestClientSysInfo(1000)) { + EXPECT_FALSE(builds.contains(row.accountId)) << row.accountId; + builds[row.accountId] = row.buildNumber; + } + EXPECT_EQ(builds[901], 2600u); + EXPECT_EQ(builds[902], 9200u); +} + +TEST(ClientSysInfoSqlite, PruningDropsRowsNotSeenSince) { + g_Db->RecordClientSysInfo(SysInfo(903, 100)); + auto kept = SysInfo(904, 100); + kept.lastSeen = 9000; + g_Db->RecordClientSysInfo(kept); + EXPECT_GE(g_Db->PruneLog(IDashboardAdmin::eLog::CLIENT_SYSINFO, 200), 1u); + EXPECT_TRUE(g_Db->GetClientSysInfo(903, 10).empty()); + EXPECT_EQ(g_Db->GetClientSysInfo(904, 10).size(), 1u); +} diff --git a/tests/dDatabaseTests/DatabaseParityTests.cpp b/tests/dDatabaseTests/DatabaseParityTests.cpp index 3b3736df6..004f38efa 100644 --- a/tests/dDatabaseTests/DatabaseParityTests.cpp +++ b/tests/dDatabaseTests/DatabaseParityTests.cpp @@ -898,7 +898,7 @@ TEST_F(ParitySeeded, LogsAndAudit) { Both("PruneLog", [](GameDatabase& db) { json out = json::array(); for (const auto log : { IDashboardAdmin::eLog::ACTIVITY, IDashboardAdmin::eLog::COMMAND, IDashboardAdmin::eLog::AUDIT, - IDashboardAdmin::eLog::CHEAT_DETECTION, IDashboardAdmin::eLog::CHAT, IDashboardAdmin::eLog::LOGIN_ADDRESS }) { + IDashboardAdmin::eLog::CHEAT_DETECTION, IDashboardAdmin::eLog::CHAT, IDashboardAdmin::eLog::LOGIN_ADDRESS, IDashboardAdmin::eLog::CLIENT_SYSINFO }) { out.push_back(db.PruneLog(log, 1000)); // nothing is that old } return out; @@ -1446,6 +1446,46 @@ TEST_F(ParitySeeded, Moderation) { Both("GetLinkedAccounts", [](GameDatabase& db) { return json{ db.GetLinkedAccounts(1), db.GetLinkedAccounts(2), db.GetLinkedAccounts(3) }; }); Both("PruneLog login addresses", [](GameDatabase& db) { return db.PruneLog(IDashboardAdmin::eLog::LOGIN_ADDRESS, 1700000350); }); Both("CountLoginAddresses after prune", [](GameDatabase& db) { return json{ db.CountLoginAddresses(1), db.CountLoginAddresses(2) }; }); + Both("RecordClientSysInfo", [](GameDatabase& db) { + IClientSysInfo::SysInfoRow s; + s.accountId = 1; + s.firstSeen = s.lastSeen = 1700000000; + s.ip = "10.0.0.1"; + s.clientOs = 1; + s.memoryStats = "1 p,2 vbytes.3 n-use.4 TKb-pmem."; + s.memoryTotalKb = 4; + s.videoCard = "Card (HAL-hw vp)"; + s.numberOfProcessors = 8; + s.processorType = 586; + s.processorLevel = 6; + s.processorRevision = 0x9e0a; + s.osVersionInfoSize = 276; + s.majorVersion = 6; + s.minorVersion = 2; + s.buildNumber = 9200; + s.platformId = 2; + db.RecordClientSysInfo(s); + s.lastSeen = 1700000100; + s.memoryStats = "9 p,2 vbytes.3 n-use.4 TKb-pmem."; + db.RecordClientSysInfo(s); // same client: the row is kept + s.lastSeen = 1700000200; + s.buildNumber = 2600; + db.RecordClientSysInfo(s); // changed: a new row + s.accountId = 2; + db.RecordClientSysInfo(s); + }); + Both("GetClientSysInfo", [](GameDatabase& db) { + json out = json::array(); + for (const auto& rows : { db.GetClientSysInfo(1, 10), db.GetLatestClientSysInfo(10) }) { + for (const auto& r : rows) { + out.push_back({ r.accountId, r.firstSeen, r.lastSeen, r.logins, r.ip, r.clientOs, r.memoryStats, r.memoryTotalKb, r.videoCard, + r.numberOfProcessors, r.processorType, r.processorLevel, r.processorRevision, r.osVersionInfoSize, r.majorVersion, + r.minorVersion, r.buildNumber, r.platformId }); + } + } + return out; + }); + Both("PruneLog client sysinfo", [](GameDatabase& db) { return db.PruneLog(IDashboardAdmin::eLog::CLIENT_SYSINFO, 1700000150); }); Both("InsertModerationDecision", [](GameDatabase& db) { db.InsertModerationDecision("name", CHAR_ALICE2, "AliceRenamed", false, "Not allowed", 1700000000); db.InsertModerationDecision("name", CHAR_ALICE2, "AliceOther", true, "", 1700000100);