diff --git a/CMakeLists.txt b/CMakeLists.txt index 874aaaa8..8c29c49d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -50,6 +50,7 @@ add_custom_target( set(SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/Revision.cc src/Account.cc + src/AccountSync.cc src/AddressTranslator.cc src/AFSArchive.cc src/AsyncHTTPServer.cc diff --git a/src/Account.cc b/src/Account.cc index 7df886a8..a3fc939c 100644 --- a/src/Account.cc +++ b/src/Account.cc @@ -9,6 +9,7 @@ #include #include "Account.hh" +#include "AccountSync.hh" std::shared_ptr DCNTELicense::from_json(const phosg::JSON& json) { auto ret = std::make_shared(); @@ -400,6 +401,8 @@ void Account::save() const { phosg::JSON::SerializeOption::FORMAT | phosg::JSON::SerializeOption::HEX_INTEGERS); std::string filename = std::format("system/licenses/{:010}.json", this->account_id); phosg::save_file(filename, json_data); + + AccountSync::notify_account_saved(this->account_id, filename); } } diff --git a/src/AccountSync.cc b/src/AccountSync.cc new file mode 100644 index 00000000..a6680947 --- /dev/null +++ b/src/AccountSync.cc @@ -0,0 +1,298 @@ +#include "AccountSync.hh" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace AccountSync { + +static std::mutex config_mutex; +static std::mutex spool_mutex; +static Config current_config; + +static uint64_t now_usecs() { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); +} + +static Config get_config() { + std::lock_guard g(config_mutex); + return current_config; +} + +static std::string source_label(const Config& cfg) { + if (!cfg.source.empty()) { + return cfg.source; + } + if (!cfg.source_region.empty() && !cfg.source_ship.empty()) { + return cfg.source_region + "-" + cfg.source_ship; + } + if (!cfg.source_region.empty()) { + return cfg.source_region; + } + if (!cfg.source_ship.empty()) { + return cfg.source_ship; + } + return "unknown"; +} + +static std::string json_escape(const std::string& s) { + std::string ret; + ret.reserve(s.size() + 8); + for (unsigned char ch : s) { + switch (ch) { + case '\\': + ret += "\\\\"; + break; + case '"': + ret += "\\\""; + break; + case '\b': + ret += "\\b"; + break; + case '\f': + ret += "\\f"; + break; + case '\n': + ret += "\\n"; + break; + case '\r': + ret += "\\r"; + break; + case '\t': + ret += "\\t"; + break; + default: + if (ch < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04X", ch); + ret += buf; + } else { + ret += static_cast(ch); + } + } + } + return ret; +} + +static void append_spool_line(const Config& cfg, const std::string& line) { + if (cfg.spool_directory.empty()) { + return; + } + + try { + std::lock_guard g(spool_mutex); + std::filesystem::create_directories(cfg.spool_directory); + std::filesystem::path path = std::filesystem::path(cfg.spool_directory) / "events.jsonl"; + + std::ofstream f(path, std::ios::app); + if (!f) { + throw std::runtime_error("failed to open spool file"); + } + f << line << '\n'; + + } catch (const std::exception& e) { + std::fprintf(stderr, + "[AccountSync] warning failed_to_write_spool directory=%s error=%s\n", + cfg.spool_directory.c_str(), + e.what()); + } +} + +static std::string base_event_json(const Config& cfg, const char* event, uint32_t account_id) { + return std::format( + "{{\"timestamp_usecs\":{},\"producer\":\"newserv\",\"source\":\"{}\",\"source_region\":\"{}\",\"source_ship\":\"{}\",\"account_store\":\"{}\",\"event\":\"{}\",\"account_id\":{},\"account_id_str\":\"{:010}\"", + now_usecs(), + json_escape(source_label(cfg)), + json_escape(cfg.source_region), + json_escape(cfg.source_ship), + json_escape(cfg.account_store), + json_escape(event), + static_cast(account_id), + static_cast(account_id)); +} + +void configure(const Config& cfg) { + { + std::lock_guard g(config_mutex); + current_config = cfg; + } + + if (cfg.enabled) { + std::fprintf(stderr, + "[AccountSync] config enabled source=%s source_region=%s source_ship=%s account_store=%s coordinator_url=%s notify_bb_sessions=%s spool_directory=%s login_locks=%s\n", + source_label(cfg).c_str(), + cfg.source_region.c_str(), + cfg.source_ship.c_str(), + cfg.account_store.c_str(), + cfg.coordinator_url.c_str(), + cfg.notify_bb_sessions ? "true" : "false", + cfg.spool_directory.c_str(), + cfg.enable_login_locks ? "true" : "false"); + } +} + +void configure_from_json(const phosg::JSON& json) { + Config cfg; + cfg.enabled = json.get_bool("Enabled", false); + + const std::string legacy_region = json.get_string("Region", ""); + cfg.source = json.get_string("Source", legacy_region); + cfg.source_region = json.get_string("SourceRegion", ""); + cfg.source_ship = json.get_string("SourceShip", ""); + cfg.account_store = json.get_string("AccountStore", "shared"); + + if (cfg.source_region.empty() && cfg.source_ship.empty() && !legacy_region.empty()) { + size_t dash_offset = legacy_region.find('-'); + if (dash_offset != std::string::npos) { + cfg.source_region = legacy_region.substr(0, dash_offset); + cfg.source_ship = legacy_region.substr(dash_offset + 1); + } else { + cfg.source_region = legacy_region; + } + } + + cfg.coordinator_url = json.get_string("CoordinatorURL", ""); + cfg.shared_secret = json.get_string("SharedSecret", ""); + cfg.request_timeout_usecs = json.get_int("RequestTimeoutUsecs", 3000000); + cfg.fail_open = json.get_bool("FailOpen", false); + cfg.notify_account_saves = json.get_bool("NotifyAccountSaves", true); + cfg.notify_player_saves = json.get_bool("NotifyPlayerSaves", true); + cfg.notify_backup_saves = json.get_bool("NotifyBackupSaves", true); + cfg.enable_login_locks = json.get_bool("EnableLoginLocks", false); + cfg.notify_bb_sessions = json.get_bool("NotifyBBSessions", cfg.enable_login_locks); + cfg.spool_directory = json.get_string("SpoolDirectory", "system/account-sync-spool"); + configure(cfg); +} + +void notify_account_saved(uint32_t account_id, const std::string& filename) { + auto cfg = get_config(); + if (!cfg.enabled || !cfg.notify_account_saves) { + return; + } + + std::fprintf(stderr, + "[AccountSync] event=account_saved source=%s source_region=%s source_ship=%s account_store=%s account_id=%010u filename=%s\n", + source_label(cfg).c_str(), + cfg.source_region.c_str(), + cfg.source_ship.c_str(), + cfg.account_store.c_str(), + static_cast(account_id), + filename.c_str()); + + auto line = base_event_json(cfg, "account_saved", account_id) + + std::format(",\"filename\":\"{}\"}}", json_escape(filename)); + append_spool_line(cfg, line); +} + +void notify_backup_saved(uint32_t account_id, size_t slot, const std::string& filename) { + auto cfg = get_config(); + if (!cfg.enabled || !cfg.notify_backup_saves) { + return; + } + + std::fprintf(stderr, + "[AccountSync] event=backup_saved source=%s source_region=%s source_ship=%s account_store=%s account_id=%010u slot=%zu filename=%s\n", + source_label(cfg).c_str(), + cfg.source_region.c_str(), + cfg.source_ship.c_str(), + cfg.account_store.c_str(), + static_cast(account_id), + slot, + filename.c_str()); + + auto line = base_event_json(cfg, "backup_saved", account_id) + + std::format(",\"slot\":{},\"filename\":\"{}\"}}", slot, json_escape(filename)); + append_spool_line(cfg, line); +} + +void notify_player_state_saved( + const char* reason, + uint32_t account_id, + const std::string& bb_username, + const std::string& filename) { + auto cfg = get_config(); + if (!cfg.enabled || !cfg.notify_player_saves) { + return; + } + + std::fprintf(stderr, + "[AccountSync] event=player_state_saved source=%s source_region=%s source_ship=%s account_store=%s reason=%s account_id=%010u bb_username=%s filename=%s\n", + source_label(cfg).c_str(), + cfg.source_region.c_str(), + cfg.source_ship.c_str(), + cfg.account_store.c_str(), + reason, + static_cast(account_id), + bb_username.c_str(), + filename.c_str()); + + auto line = base_event_json(cfg, "player_state_saved", account_id) + + std::format(",\"reason\":\"{}\",\"bb_username\":\"{}\",\"filename\":\"{}\"}}", + json_escape(reason), + json_escape(bb_username), + json_escape(filename)); + append_spool_line(cfg, line); +} + +void notify_bb_login_start( + uint32_t account_id, + const std::string& bb_username, + int64_t character_slot, + uint8_t connection_phase) { + auto cfg = get_config(); + if (!cfg.enabled || !cfg.notify_bb_sessions) { + return; + } + + std::fprintf(stderr, + "[AccountSync] event=bb_login_start source=%s source_region=%s source_ship=%s account_store=%s account_id=%010u bb_username=%s character_slot=%lld connection_phase=%u\n", + source_label(cfg).c_str(), + cfg.source_region.c_str(), + cfg.source_ship.c_str(), + cfg.account_store.c_str(), + static_cast(account_id), + bb_username.c_str(), + static_cast(character_slot), + static_cast(connection_phase)); + + auto line = base_event_json(cfg, "bb_login_start", account_id) + + std::format(",\"bb_username\":\"{}\",\"character_slot\":{},\"connection_phase\":{}}}", + json_escape(bb_username), + static_cast(character_slot), + static_cast(connection_phase)); + append_spool_line(cfg, line); +} + +void notify_bb_login_end( + uint32_t account_id, + const std::string& bb_username, + int64_t character_slot) { + auto cfg = get_config(); + if (!cfg.enabled || !cfg.notify_bb_sessions) { + return; + } + + std::fprintf(stderr, + "[AccountSync] event=bb_login_end source=%s source_region=%s source_ship=%s account_store=%s account_id=%010u bb_username=%s character_slot=%lld\n", + source_label(cfg).c_str(), + cfg.source_region.c_str(), + cfg.source_ship.c_str(), + cfg.account_store.c_str(), + static_cast(account_id), + bb_username.c_str(), + static_cast(character_slot)); + + auto line = base_event_json(cfg, "bb_login_end", account_id) + + std::format(",\"bb_username\":\"{}\",\"character_slot\":{}}}", + json_escape(bb_username), + static_cast(character_slot)); + append_spool_line(cfg, line); +} + +} // namespace AccountSync diff --git a/src/AccountSync.hh b/src/AccountSync.hh new file mode 100644 index 00000000..cbfeb78d --- /dev/null +++ b/src/AccountSync.hh @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include + +#include + +namespace AccountSync { + +struct Config { + bool enabled = false; + + // Source identity. Region is no longer enough because account JSON is shared + // across live/test/hardcore in a region. + std::string source; + std::string source_region; + std::string source_ship; + std::string account_store = "shared"; + + std::string coordinator_url; + std::string shared_secret; + uint64_t request_timeout_usecs = 3000000; + bool fail_open = false; + bool notify_account_saves = true; + bool notify_player_saves = true; + bool notify_backup_saves = true; + bool notify_bb_sessions = false; + bool enable_login_locks = false; // Reserved for future blocking lock behavior + std::string spool_directory = "system/account-sync-spool"; +}; + +void configure(const Config& cfg); +void configure_from_json(const phosg::JSON& json); + +void notify_account_saved(uint32_t account_id, const std::string& filename); +void notify_backup_saved(uint32_t account_id, size_t slot, const std::string& filename); + +void notify_player_state_saved( + const char* reason, + uint32_t account_id, + const std::string& bb_username, + const std::string& filename); + +void notify_bb_login_start( + uint32_t account_id, + const std::string& bb_username, + int64_t character_slot, + uint8_t connection_phase); + +void notify_bb_login_end( + uint32_t account_id, + const std::string& bb_username, + int64_t character_slot); + +} // namespace AccountSync diff --git a/src/ChatCommands.cc b/src/ChatCommands.cc index 2a911c20..7ca6f917 100644 --- a/src/ChatCommands.cc +++ b/src/ChatCommands.cc @@ -22,6 +22,7 @@ #include "Server.hh" #include "StaticGameData.hh" #include "Text.hh" +#include "AccountSync.hh" /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Tools @@ -491,12 +492,22 @@ static asio::awaitable server_command_bbchar_savechar(const Args& a, bool ? Client::character_filename(dest_bb_license->username, dest_character_index) : Client::backup_character_filename(dest_account->account_id, dest_character_index, is_ep3(a.c->version())); + auto log_account_sync_backup_saved = [&]() -> void { + if (!is_bb_conversion) { + AccountSync::notify_backup_saved( + dest_account->account_id, + dest_character_index, + filename); + } + }; + if (ch.is_full_info) { // Client sent 30; ch contains the verbatim save file from the client if (ch.ep3_character) { try { Client::save_ep3_character_file(filename, *ch.ep3_character); send_text_message(a.c, "$C7Character data saved\n(full save file)"); + log_account_sync_backup_saved(); } catch (const std::exception& e) { send_text_message_fmt(a.c, "$C6Character data could\nnot be saved:\n{}", e.what()); } @@ -505,6 +516,7 @@ static asio::awaitable server_command_bbchar_savechar(const Args& a, bool try { Client::save_character_file(filename, a.c->system_file(), ch.character); send_text_message(a.c, "$C7Character data saved\n(full save file)"); + log_account_sync_backup_saved(); } catch (const std::exception& e) { send_text_message_fmt(a.c, "$C6Character data could\nnot be saved:\n{}", e.what()); } @@ -546,6 +558,7 @@ static asio::awaitable server_command_bbchar_savechar(const Args& a, bool try { Client::save_character_file(filename, a.c->system_file(), bb_player); send_text_message(a.c, "$C7Character data saved\n(basic only)"); + log_account_sync_backup_saved(); } catch (const std::exception& e) { send_text_message_fmt(a.c, "$C6Character data could\nnot be saved:\n{}", e.what()); } diff --git a/src/Client.cc b/src/Client.cc index a27c682c..fad4c7c7 100644 --- a/src/Client.cc +++ b/src/Client.cc @@ -15,6 +15,7 @@ #include "SendCommands.hh" #include "Server.hh" #include "Version.hh" +#include "AccountSync.hh" const uint64_t CLIENT_CONFIG_MAGIC = 0x8399AC32; @@ -232,6 +233,13 @@ Client::~Client() { if ((this->version() == Version::BB_V4) && (this->character_data.get())) { this->save_all(); + + if (this->login && this->login->account && this->login->bb_license) { + AccountSync::notify_bb_login_end( + this->login->account->account_id, + this->login->bb_license->username, + this->bb_character_index); + } } this->log.info_f("Deleted"); } @@ -503,6 +511,13 @@ void Client::save_system_file() const { std::string filename = this->system_filename(); phosg::save_object_file(filename, *this->system_data); this->log.info_f("Saved system file {}", filename); + if (this->login && this->login->account && this->login->bb_license) { + AccountSync::notify_player_state_saved( + "system_saved", + this->login->account->account_id, + this->login->bb_license->username, + filename); + } } // Guild Card file @@ -542,6 +557,13 @@ void Client::save_guild_card_file() const { std::string filename = this->guild_card_filename(); phosg::save_object_file(filename, *this->guild_card_data); this->log.info_f("Saved Guild Card file {}", filename); + if (this->login && this->login->account && this->login->bb_license) { + AccountSync::notify_player_state_saved( + "guild_card_saved", + this->login->account->account_id, + this->login->bb_license->username, + filename); + } } // Character file @@ -634,6 +656,13 @@ void Client::save_character_file() { auto filename = this->character_filename(); this->save_character_file(filename, this->system_data, this->character_data); this->log.info_f("Saved character file {}", filename); + if (this->login && this->login->account && this->login->bb_license) { + AccountSync::notify_player_state_saved( + "character_saved", + this->login->account->account_id, + this->login->bb_license->username, + filename); + } } void Client::create_character_file( @@ -834,6 +863,13 @@ void Client::save_bank_file() const { auto filename = this->bank_filename(); this->save_bank_file(filename, *this->bank_data); this->log.info_f("Saved bank file {}", filename); + if (this->login && this->login->account && this->login->bb_license) { + AccountSync::notify_player_state_saved( + "bank_saved", + this->login->account->account_id, + this->login->bb_license->username, + filename); + } } void Client::change_bank(ssize_t index) { diff --git a/src/ReceiveCommands.cc b/src/ReceiveCommands.cc index 79aeeb8e..acf99d35 100644 --- a/src/ReceiveCommands.cc +++ b/src/ReceiveCommands.cc @@ -27,6 +27,7 @@ #include "StaticGameData.hh" #include "Text.hh" #include "BrutalPeeps.hh" +#include "AccountSync.hh" const char* BATTLE_TABLE_DISCONNECT_HOOK_NAME = "battle_table_state"; const char* QUEST_BARRIER_DISCONNECT_HOOK_NAME = "quest_barrier"; @@ -1672,6 +1673,14 @@ static asio::awaitable on_93_BB(std::shared_ptr c, Channel::Messag co_return; } + if (c->login->account && c->login->bb_license) { + AccountSync::notify_bb_login_start( + c->login->account->account_id, + c->login->bb_license->username, + c->bb_character_index, + c->bb_connection_phase); + } + std::string version_string = c->bb_client_config.as_string(); phosg::strip_trailing_zeroes(version_string); // If the version std::string starts with "Ver.", assume it's Sega and apply the normal version encoding logic. Otherwise, diff --git a/src/ServerState.cc b/src/ServerState.cc index c56f3477..0384ede7 100644 --- a/src/ServerState.cc +++ b/src/ServerState.cc @@ -18,6 +18,7 @@ #include "SendCommands.hh" #include "Text.hh" #include "TextIndex.hh" +#include "AccountSync.hh" #ifdef PHOSG_WINDOWS static constexpr bool IS_WINDOWS = true; @@ -879,6 +880,7 @@ void ServerState::load_config_early() { this->client_ping_interval_usecs = this->config_json->get_int("ClientPingInterval", 30000000); this->client_idle_timeout_usecs = this->config_json->get_int("ClientIdleTimeout", 60000000); this->patch_client_idle_timeout_usecs = this->config_json->get_int("PatchClientIdleTimeout", 300000000); + AccountSync::configure_from_json(this->config_json->get("AccountSync", phosg::JSON::dict())); this->psopeeps_dcv2_exp_multiplier = this->config_json->get_int("PsoPeepsDCV2EXPMultiplier", 5); if ((this->psopeeps_dcv2_exp_multiplier != 5) && (this->psopeeps_dcv2_exp_multiplier != 10)) { throw std::runtime_error("PsoPeepsDCV2EXPMultiplier must be 5 or 10");