#include "ProxyServer.hh" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "PSOProtocol.hh" #include "SendCommands.hh" #include "ReceiveCommands.hh" #include "ReceiveSubcommands.hh" using namespace std; static const uint32_t SESSION_TIMEOUT_USECS = 30000000; // 30 seconds static void flush_and_free_bufferevent(struct bufferevent* bev) { bufferevent_flush(bev, EV_READ | EV_WRITE, BEV_FINISHED); bufferevent_free(bev); } ProxyServer::ProxyServer( shared_ptr base, shared_ptr state) : base(base), state(state), next_unlicensed_session_id(0xFF00000000000001) { } void ProxyServer::listen(uint16_t port, GameVersion version, const struct sockaddr_storage* default_destination) { shared_ptr socket_obj(new ListeningSocket( this, port, version, default_destination)); auto l = this->listeners.emplace(port, socket_obj).first->second; log(INFO, "[ProxyServer] Listening on TCP port %hu (%s) on fd %d", port, name_for_version(version), static_cast(l->fd)); } ProxyServer::ListeningSocket::ListeningSocket( ProxyServer* server, uint16_t port, GameVersion version, const struct sockaddr_storage* default_destination) : server(server), port(port), fd(::listen("", port, SOMAXCONN)), listener(nullptr, evconnlistener_free), version(version) { if (!this->fd.is_open()) { throw runtime_error("cannot listen on port"); } this->listener.reset(evconnlistener_new( this->server->base.get(), &ProxyServer::ListeningSocket::dispatch_on_listen_accept, this, LEV_OPT_CLOSE_ON_FREE | LEV_OPT_REUSEABLE, 0, this->fd)); if (!listener) { throw runtime_error("cannot create listener"); } evconnlistener_set_error_cb( this->listener.get(), &ProxyServer::ListeningSocket::dispatch_on_listen_error); if (default_destination) { this->default_destination = *default_destination; } else { this->default_destination.ss_family = 0; } } void ProxyServer::ListeningSocket::dispatch_on_listen_accept( struct evconnlistener*, evutil_socket_t fd, struct sockaddr*, int, void* ctx) { reinterpret_cast(ctx)->on_listen_accept(fd); } void ProxyServer::ListeningSocket::dispatch_on_listen_error( struct evconnlistener*, void* ctx) { reinterpret_cast(ctx)->on_listen_error(); } void ProxyServer::ListeningSocket::on_listen_accept(int fd) { log(INFO, "[ProxyServer] Client connected on fd %d", fd); auto* bev = bufferevent_socket_new(this->server->base.get(), fd, BEV_OPT_CLOSE_ON_FREE | BEV_OPT_DEFER_CALLBACKS); this->server->on_client_connect(bev, this->port, this->version, (this->default_destination.ss_family == AF_INET) ? &this->default_destination : nullptr); } void ProxyServer::ListeningSocket::on_listen_error() { int err = EVUTIL_SOCKET_ERROR(); log(ERROR, "[ProxyServer] Failure on listening socket %d: %d (%s)", evconnlistener_get_fd(this->listener.get()), err, evutil_socket_error_to_string(err)); event_base_loopexit(this->server->base.get(), nullptr); } void ProxyServer::connect_client(struct bufferevent* bev, uint16_t server_port) { // Look up the listening socket for the given port, and use that game version. // We don't support default-destination proxying for virtual connections (yet) GameVersion version; try { version = this->listeners.at(server_port)->version; } catch (const out_of_range&) { log(INFO, "[ProxyServer] Virtual connection received on unregistered port %hu; closing it", server_port); flush_and_free_bufferevent(bev); return; } log(INFO, "[ProxyServer] Client connected on virtual connection %p", bev); this->on_client_connect(bev, server_port, version, nullptr); } void ProxyServer::on_client_connect( struct bufferevent* bev, uint16_t listen_port, GameVersion version, const struct sockaddr_storage* default_destination) { // If a default destination exists for this client, create a linked session // immediately and connect to the remote server. This creates a direct session if (default_destination) { uint64_t session_id = this->next_unlicensed_session_id++; if (this->next_unlicensed_session_id == 0) { this->next_unlicensed_session_id = 0xFF00000000000001; } auto emplace_ret = this->id_to_session.emplace(session_id, new LinkedSession( this, session_id, listen_port, version, *default_destination)); if (!emplace_ret.second) { throw logic_error("linked session already exists for unlicensed client"); } auto session = emplace_ret.first->second; log(INFO, "[ProxyServer/%08" PRIX64 "] Opened linked session", session->id); session->resume(bev); // If no default destination exists, create an unlinked session - we'll have // to get the destination from the client's config, which we'll get via a 9E // command soon } else { auto emplace_ret = this->bev_to_unlinked_session.emplace(bev, new UnlinkedSession( this, bev, listen_port, version)); if (!emplace_ret.second) { throw logic_error("stale unlinked session exists"); } auto session = emplace_ret.first->second; log(INFO, "[ProxyServer] Opened unlinked session"); switch (version) { case GameVersion::PATCH: throw logic_error("cannot create unlinked patch session"); case GameVersion::PC: case GameVersion::GC: { uint32_t server_key = random_object(); uint32_t client_key = random_object(); auto cmd = prepare_server_init_contents_dc_pc_gc( false, server_key, client_key); send_command( session->bev.get(), session->version, session->crypt_out.get(), 0x02, 0, &cmd, sizeof(cmd), "unlinked proxy client"); bufferevent_flush(session->bev.get(), EV_READ | EV_WRITE, BEV_FLUSH); if (version == GameVersion::PC) { session->crypt_out.reset(new PSOPCEncryption(server_key)); session->crypt_in.reset(new PSOPCEncryption(client_key)); } else { session->crypt_out.reset(new PSOGCEncryption(server_key)); session->crypt_in.reset(new PSOGCEncryption(client_key)); } break; } default: throw logic_error("unsupported game version on proxy server"); } } } ProxyServer::UnlinkedSession::UnlinkedSession( ProxyServer* server, struct bufferevent* bev, uint16_t local_port, GameVersion version) : server(server), bev(bev, flush_and_free_bufferevent), local_port(local_port), version(version) { bufferevent_setcb(this->bev.get(), &UnlinkedSession::dispatch_on_client_input, nullptr, &UnlinkedSession::dispatch_on_client_error, this); bufferevent_enable(this->bev.get(), EV_READ | EV_WRITE); } void ProxyServer::UnlinkedSession::dispatch_on_client_input( struct bufferevent*, void* ctx) { reinterpret_cast(ctx)->on_client_input(); } void ProxyServer::UnlinkedSession::dispatch_on_client_error( struct bufferevent*, short events, void* ctx) { reinterpret_cast(ctx)->on_client_error(events); } void ProxyServer::UnlinkedSession::on_client_input() { bool should_close_unlinked_session = false; shared_ptr license; uint32_t sub_version = 0; string character_name; ClientConfig client_config; for_each_received_command(this->bev.get(), this->version, this->crypt_in.get(), [&](uint16_t command, uint32_t flag, const string& data) { print_received_command(command, flag, data.data(), data.size(), this->version, "unlinked proxy client"); if (this->version == GameVersion::PC) { // We should only get a 9D while the session is unlinked; if we get // anything else, disconnect if (command != 0x9D) { log(ERROR, "[ProxyServer] Received unexpected command %02hX", command); should_close_unlinked_session = true; } else if (data.size() < sizeof(C_Login_GC_9E) - 0x64) { log(ERROR, "[ProxyServer] Login command is too small"); should_close_unlinked_session = true; } else { const auto* cmd = reinterpret_cast(data.data()); uint32_t serial_number = strtoul(cmd->serial_number.c_str(), nullptr, 16); try { license = this->server->state->license_manager->verify_pc( serial_number, cmd->access_key.c_str(), nullptr); sub_version = cmd->sub_version; character_name = cmd->name; client_config = cmd->client_config.cfg; } catch (const exception& e) { log(ERROR, "[ProxyServer] Unlinked client has no valid license"); should_close_unlinked_session = true; } } } else if (this->version == GameVersion::GC) { // We should only get a 9E while the session is unlinked; if we get // anything else, disconnect if (command != 0x9E) { log(ERROR, "[ProxyServer] Received unexpected command %02hX", command); should_close_unlinked_session = true; } else if (data.size() < sizeof(C_Login_GC_9E) - 0x64) { log(ERROR, "[ProxyServer] Login command is too small"); should_close_unlinked_session = true; } else { const auto* cmd = reinterpret_cast(data.data()); uint32_t serial_number = strtoul(cmd->serial_number.c_str(), nullptr, 16); try { license = this->server->state->license_manager->verify_gc( serial_number, cmd->access_key.c_str(), nullptr); sub_version = cmd->sub_version; character_name = cmd->name; client_config = cmd->client_config.cfg; } catch (const exception& e) { log(ERROR, "[ProxyServer] Unlinked client has no valid license"); should_close_unlinked_session = true; } } } else { throw logic_error("unsupported unlinked session version"); } }); struct bufferevent* session_key = this->bev.get(); // If license is non-null, then the client has a password and can be connected // to the remote lobby server. if (license.get()) { // At this point, we will always close the unlinked session, even if it // doesn't get converted/merged to a linked session should_close_unlinked_session = true; // Look up the linked session for this license (if any) shared_ptr session; try { session = this->server->id_to_session.at(license->serial_number); log(INFO, "[ProxyServer/%08" PRIX64 "] Resuming linked session from unlinked session", session->id); } catch (const out_of_range&) { // If there's no open session for this license, then there must be a valid // destination in the client config. If there is, open a new linked // session and set its initial destination if (client_config.magic != CLIENT_CONFIG_MAGIC) { log(ERROR, "[ProxyServer] Client configuration is invalid; cannot open session"); } else { session.reset(new LinkedSession( this->server, this->local_port, this->version, license, client_config)); this->server->id_to_session.emplace(license->serial_number, session); log(INFO, "[ProxyServer/%08" PRIX64 "] Opened licensed session for unlinked session", session->id); } } if (session.get() && (session->version != this->version)) { log(ERROR, "[ProxyServer/%08" PRIX64 "] Linked session has different game version", session->id); } else { // Resume the linked session using the unlinked session try { session->resume(move(this->bev), this->crypt_in, this->crypt_out, sub_version, character_name); this->crypt_in.reset(); this->crypt_out.reset(); } catch (const exception& e) { log(ERROR, "[ProxyServer/%08" PRIX64 "] Failed to resume linked session: %s", session->id, e.what()); } } } if (should_close_unlinked_session) { this->server->bev_to_unlinked_session.erase(session_key); // At this point, (*this) is destroyed! We must be careful not to touch it. } } void ProxyServer::UnlinkedSession::on_client_error(short events) { if (events & BEV_EVENT_ERROR) { int err = EVUTIL_SOCKET_ERROR(); log(WARNING, "[ProxyServer] Error %d (%s) in unlinked client stream", err, evutil_socket_error_to_string(err)); } if (events & (BEV_EVENT_ERROR | BEV_EVENT_EOF)) { log(WARNING, "[ProxyServer] Unlinked client has disconnected"); this->server->bev_to_unlinked_session.erase(this->bev.get()); } } ProxyServer::LinkedSession::LinkedSession( ProxyServer* server, uint64_t id, uint16_t local_port, GameVersion version) : server(server), id(id), timeout_event(event_new(this->server->base.get(), -1, EV_TIMEOUT, &LinkedSession::dispatch_on_timeout, this), event_free), license(nullptr), client_bev(nullptr, flush_and_free_bufferevent), server_bev(nullptr, flush_and_free_bufferevent), local_port(local_port), version(version), sub_version(0), // This is set during resume() guild_card_number(0), suppress_newserv_commands(true), enable_chat_filter(true), enable_switch_assist(false), save_files(false), override_section_id(-1), override_lobby_event(-1), override_lobby_number(-1), lobby_players(12), lobby_client_id(0) { } ProxyServer::LinkedSession::LinkedSession( ProxyServer* server, uint16_t local_port, GameVersion version, std::shared_ptr license, const ClientConfig& newserv_client_config) : LinkedSession(server, license->serial_number, local_port, version) { this->license = license; this->newserv_client_config = newserv_client_config; memset(&this->next_destination, 0, sizeof(this->next_destination)); struct sockaddr_in* dest_sin = reinterpret_cast(&this->next_destination); dest_sin->sin_family = AF_INET; dest_sin->sin_port = htons(this->newserv_client_config.proxy_destination_port); dest_sin->sin_addr.s_addr = htonl(this->newserv_client_config.proxy_destination_address); } ProxyServer::LinkedSession::LinkedSession( ProxyServer* server, uint64_t id, uint16_t local_port, GameVersion version, const struct sockaddr_storage& destination) : LinkedSession(server, id, local_port, version) { this->next_destination = destination; } void ProxyServer::LinkedSession::resume( std::unique_ptr&& client_bev, std::shared_ptr client_input_crypt, std::shared_ptr client_output_crypt, uint32_t sub_version, const string& character_name) { if (this->client_bev.get()) { throw runtime_error("client connection is already open for this session"); } if (this->next_destination.ss_family != AF_INET) { throw logic_error("attempted to resume an unlicensed linked session wihout destination set"); } this->client_bev = move(client_bev); bufferevent_setcb(this->client_bev.get(), &ProxyServer::LinkedSession::dispatch_on_client_input, nullptr, &ProxyServer::LinkedSession::dispatch_on_client_error, this); bufferevent_enable(this->client_bev.get(), EV_READ | EV_WRITE); this->client_input_crypt = client_input_crypt; this->client_output_crypt = client_output_crypt; this->sub_version = sub_version; this->character_name = character_name; this->server_input_crypt.reset(); this->server_output_crypt.reset(); this->saving_files.clear(); this->connect(); } void ProxyServer::LinkedSession::resume(struct bufferevent* client_bev) { if (this->client_bev.get()) { throw runtime_error("client connection is already open for this session"); } this->client_bev.reset(client_bev); bufferevent_setcb(this->client_bev.get(), &ProxyServer::LinkedSession::dispatch_on_client_input, nullptr, &ProxyServer::LinkedSession::dispatch_on_client_error, this); bufferevent_enable(this->client_bev.get(), EV_READ | EV_WRITE); this->client_input_crypt.reset(); this->client_output_crypt.reset(); this->server_input_crypt.reset(); this->server_output_crypt.reset(); this->sub_version = 0; this->character_name.clear(); this->saving_files.clear(); this->connect(); } void ProxyServer::LinkedSession::connect() { // Connect to the remote server. The command handlers will do the login steps // and set up forwarding this->server_bev.reset(bufferevent_socket_new(this->server->base.get(), -1, BEV_OPT_CLOSE_ON_FREE | BEV_OPT_DEFER_CALLBACKS)); struct sockaddr_storage local_ss; struct sockaddr_in* local_sin = reinterpret_cast(&local_ss); memset(local_sin, 0, sizeof(*local_sin)); local_sin->sin_family = AF_INET; const struct sockaddr_in* dest_sin = reinterpret_cast(&this->next_destination); if (dest_sin->sin_family != AF_INET) { throw logic_error("ss not AF_INET"); } local_sin->sin_port = dest_sin->sin_port; local_sin->sin_addr.s_addr = dest_sin->sin_addr.s_addr; string netloc_str = render_sockaddr_storage(local_ss); log(INFO, "[ProxyServer/%08" PRIX64 "] Connecting to %s", this->id, netloc_str.c_str()); if (bufferevent_socket_connect(this->server_bev.get(), reinterpret_cast(local_sin), sizeof(*local_sin)) != 0) { throw runtime_error(string_printf("failed to connect (%d)", EVUTIL_SOCKET_ERROR())); } bufferevent_setcb(this->server_bev.get(), &ProxyServer::LinkedSession::dispatch_on_server_input, nullptr, &ProxyServer::LinkedSession::dispatch_on_server_error, this); bufferevent_enable(this->server_bev.get(), EV_READ | EV_WRITE); // Cancel the session delete timeout event_del(this->timeout_event.get()); } ProxyServer::LinkedSession::SavingFile::SavingFile( const std::string& basename, const std::string& output_filename, uint32_t remaining_bytes) : basename(basename), output_filename(output_filename), remaining_bytes(remaining_bytes), f(fopen_unique(this->output_filename, "wb")) { } void ProxyServer::LinkedSession::dispatch_on_client_input( struct bufferevent*, void* ctx) { reinterpret_cast(ctx)->on_client_input(); } void ProxyServer::LinkedSession::dispatch_on_client_error( struct bufferevent*, short events, void* ctx) { reinterpret_cast(ctx)->on_stream_error(events, false); } void ProxyServer::LinkedSession::dispatch_on_server_input( struct bufferevent*, void* ctx) { reinterpret_cast(ctx)->on_server_input(); } void ProxyServer::LinkedSession::dispatch_on_server_error( struct bufferevent*, short events, void* ctx) { reinterpret_cast(ctx)->on_stream_error(events, true); } void ProxyServer::LinkedSession::dispatch_on_timeout( evutil_socket_t, short, void* ctx) { reinterpret_cast(ctx)->on_timeout(); } void ProxyServer::LinkedSession::on_timeout() { log(INFO, "[ProxyServer/%08" PRIX64 "] Session timed out", this->id); this->server->delete_session(this->id); } void ProxyServer::LinkedSession::on_stream_error( short events, bool is_server_stream) { if (events & BEV_EVENT_ERROR) { int err = EVUTIL_SOCKET_ERROR(); log(WARNING, "[ProxyServer/%08" PRIX64 "] Error %d (%s) in %s stream", this->id, err, evutil_socket_error_to_string(err), is_server_stream ? "server" : "client"); } if (events & (BEV_EVENT_EOF | BEV_EVENT_ERROR)) { log(INFO, "[ProxyServer/%08" PRIX64 "] %s has disconnected", this->id, is_server_stream ? "Server" : "Client"); this->disconnect(); } } void ProxyServer::LinkedSession::disconnect() { // Forward the disconnection to the other end this->server_bev.reset(); this->client_bev.reset(); // Disable encryption for the next connection this->server_input_crypt.reset(); this->server_output_crypt.reset(); this->client_input_crypt.reset(); this->client_output_crypt.reset(); // Set a timeout to delete the session entirely (in case the client doesn't // reconnect) struct timeval tv = usecs_to_timeval(SESSION_TIMEOUT_USECS); event_add(this->timeout_event.get(), &tv); } static void check_implemented_subcommand(uint64_t id, const string& data) { if (data.size() < 4) { log(WARNING, "[ProxyServer/%08" PRIX64 "] Received broadcast/target command with no contents", id); } else { if (!subcommand_is_implemented(data[0])) { log(WARNING, "[ProxyServer/%08" PRIX64 "] Received subcommand %02hhX which is not implemented on the server", id, data[0]); } } } void ProxyServer::LinkedSession::on_client_input() { string name = string_printf("ProxySession:%08" PRIX64 ":client", this->id); for_each_received_command(this->client_bev.get(), this->version, this->client_input_crypt.get(), [&](uint16_t command, uint32_t flag, string& data) { print_received_command(command, flag, data.data(), data.size(), this->version, name.c_str()); bool should_forward = true; switch (command) { case 0x06: if (this->version != GameVersion::GC) { break; } if (data.size() < 12) { break; } // If this chat message looks like a newserv chat command, suppress it if (this->suppress_newserv_commands && (data[8] == '$' || (data[8] == '\t' && data[9] != 'C' && data[10] == '$'))) { log(WARNING, "[ProxyServer/%08" PRIX64 "] Chat message appears to be a server command; dropping it", this->id); should_forward = false; } else if (this->enable_chat_filter) { // Turn all $ into \t and all # into \n add_color_inplace(data.data() + 8, data.size() - 8); } break; case 0x60: case 0x62: case 0x6C: case 0x6D: case 0xC9: case 0xCB: check_implemented_subcommand(this->id, data); if (!data.empty() && (data[0] == 0x05) && (data[data.size() - 1] == 0x01) && this->enable_switch_assist) { if (!this->last_switch_enabled_subcommand.empty()) { log(WARNING, "[ProxyServer/%08" PRIX64 "] Switch assist: replaying previous enable subcommand", this->id); send_command(this->client_bev.get(), this->version, this->client_output_crypt.get(), 0x60, 0x00, this->last_switch_enabled_subcommand.data(), this->last_switch_enabled_subcommand.size(), name.c_str()); } this->last_switch_enabled_subcommand = data; } break; case 0xA0: // Change ship case 0xA1: { // Change block if ((this->version == GameVersion::PATCH) || (this->version == GameVersion::BB)) { break; } if (!this->license) { break; } // These will take you back to the newserv main menu instead of the // proxied service's menu // Delete all the other players for (size_t x = 0; x < this->lobby_players.size(); x++) { if (this->lobby_players[x].guild_card_number == 0) { continue; } uint8_t leaving_id = x; uint8_t leader_id = this->lobby_client_id; S_LeaveLobby_66_69 cmd = {leaving_id, leader_id, 0}; send_command(this->client_bev.get(), this->version, this->client_output_crypt.get(), 0x69, leaving_id, &cmd, sizeof(cmd), name.c_str()); } // Restore the newserv client config, so the client gets its newserv // guild card number back and the login server knows e.g. not to show // the welcome message (if the appropriate flag is set) S_UpdateClientConfig_DC_PC_GC_04 update_client_config_cmd = { 0x00010000, this->license->serial_number, this->newserv_client_config, }; send_command(this->client_bev.get(), this->version, this->client_output_crypt.get(), 0x04, 0x00, &update_client_config_cmd, sizeof(update_client_config_cmd), name.c_str()); static const vector version_to_port_name({ "dc-login", "pc-login", "bb-patch", "gc-us3", "bb-login"}); const auto& port_name = version_to_port_name.at(static_cast( this->version)); S_Reconnect_19 reconnect_cmd = { 0, this->server->state->name_to_port_config.at(port_name)->port, 0}; // If the client is on a virtual connection, we can use any address // here and they should be able to connect back to the game server. If // the client is on a real connection, we'll use the sockname of the // existing connection (like we do in the server 19 command handler). int fd = bufferevent_getfd(this->client_bev.get()); if (fd < 0) { struct sockaddr_in* dest_sin = reinterpret_cast(&this->next_destination); if (dest_sin->sin_family != AF_INET) { throw logic_error("ss not AF_INET"); } reconnect_cmd.address.store_raw(dest_sin->sin_addr.s_addr); } else { struct sockaddr_storage sockname_ss; socklen_t len = sizeof(sockname_ss); getsockname(fd, reinterpret_cast(&sockname_ss), &len); if (sockname_ss.ss_family != AF_INET) { throw logic_error("existing connection is not ipv4"); } struct sockaddr_in* sockname_sin = reinterpret_cast( &sockname_ss); reconnect_cmd.address.store_raw(sockname_sin->sin_addr.s_addr); } send_command(this->client_bev.get(), this->version, this->client_output_crypt.get(), 0x19, 0x00, &reconnect_cmd, sizeof(reconnect_cmd), name.c_str()); break; } } if (should_forward) { if (!this->client_bev.get()) { log(WARNING, "[ProxyServer/%08" PRIX64 "] No server is present; dropping command", this->id); } else { // Note: we intentionally don't pass a name string here because we already // printed the command above send_command(this->server_bev.get(), this->version, this->server_output_crypt.get(), command, flag, data.data(), data.size()); } } }); } void ProxyServer::LinkedSession::on_server_input() { string name = string_printf("ProxySession:%08" PRIX64 ":server", this->id); try { for_each_received_command(this->server_bev.get(), this->version, this->server_input_crypt.get(), [&](uint16_t command, uint32_t flag, string& data) { print_received_command(command, flag, data.data(), data.size(), this->version, name.c_str()); // In the case of server init commands, the client output crypt cannot // be set until after we forwarwd the command to the client, hence this // variable. shared_ptr new_client_output_crypt; bool should_forward = true; switch (command) { case 0x02: case 0x17: { if (this->version == GameVersion::PATCH && command == 0x17) { throw invalid_argument("patch server sent 17 server init"); } if (this->version == GameVersion::BB) { throw invalid_argument("console server init received on BB"); } // Most servers don't include after_message or have a shorter // after_message than newserv does, so don't require it if (data.size() < offsetof(S_ServerInit_DC_PC_GC_02_17, after_message)) { throw std::runtime_error("init encryption command is too small"); } const auto* cmd = reinterpret_cast( data.data()); if (!this->license) { log(INFO, "[ProxyServer/%08" PRIX64 "] No license in linked session", this->id); if ((this->version == GameVersion::PC) || (this->version == GameVersion::PATCH)) { this->server_input_crypt.reset(new PSOPCEncryption(cmd->server_key)); this->server_output_crypt.reset(new PSOPCEncryption(cmd->client_key)); this->client_input_crypt.reset(new PSOPCEncryption(cmd->client_key)); new_client_output_crypt.reset(new PSOPCEncryption(cmd->server_key)); } else if (this->version == GameVersion::GC) { this->server_input_crypt.reset(new PSOGCEncryption(cmd->server_key)); this->server_output_crypt.reset(new PSOGCEncryption(cmd->client_key)); this->client_input_crypt.reset(new PSOGCEncryption(cmd->client_key)); new_client_output_crypt.reset(new PSOGCEncryption(cmd->server_key)); } else { throw invalid_argument("unsupported version"); } break; } else { log(INFO, "[ProxyServer/%08" PRIX64 "] Existing license in linked session", this->id); // This doesn't get forwarded to the client, so don't recreate the // client's crypts if (this->version == GameVersion::PATCH) { throw logic_error("patch session is indirect"); } else if (this->version == GameVersion::PC) { this->server_input_crypt.reset(new PSOPCEncryption(cmd->server_key)); this->server_output_crypt.reset(new PSOPCEncryption(cmd->client_key)); } else if (this->version == GameVersion::GC) { this->server_input_crypt.reset(new PSOGCEncryption(cmd->server_key)); this->server_output_crypt.reset(new PSOGCEncryption(cmd->client_key)); } else { throw invalid_argument("unsupported version"); } should_forward = false; // Respond with an appropriate login command. We don't let the // client do this because it believes it already did (when it was // in an unlinked session). if (this->version == GameVersion::PC) { C_Login_PC_9D cmd; if (this->guild_card_number == 0) { cmd.player_tag = 0xFFFF0000; cmd.guild_card_number = 0xFFFFFFFF; } else { cmd.player_tag = 0x00010000; cmd.guild_card_number = this->guild_card_number; } cmd.unused = 0xFFFFFFFFFFFF0000; cmd.sub_version = this->sub_version; cmd.unused2.data()[1] = 1; cmd.serial_number = string_printf("%08" PRIX32 "", this->license->serial_number); cmd.access_key = this->license->access_key; cmd.serial_number2 = cmd.serial_number; cmd.access_key2 = cmd.access_key; cmd.name = this->character_name; send_command(this->server_bev.get(), this->version, this->server_output_crypt.get(), 0x9D, 0, &cmd, sizeof(cmd), name.c_str()); break; } else if (this->version == GameVersion::GC) { if (command == 0x17) { C_VerifyLicense_GC_DB cmd; cmd.serial_number = string_printf("%08" PRIX32 "", this->license->serial_number); cmd.access_key = this->license->access_key; cmd.sub_version = this->sub_version; cmd.serial_number2 = cmd.serial_number; cmd.access_key2 = cmd.access_key; cmd.password = this->license->gc_password; send_command(this->server_bev.get(), this->version, this->server_output_crypt.get(), 0xDB, 0, &cmd, sizeof(cmd), name.c_str()); break; } } else { throw logic_error("invalid game version in server init handler"); } } // If we get here, then: // - The session has a license // - The session's version is GC // - The received command is 02, not 17 // The command should be handled like 9A at this point (we should // send a 9E in response). GCC can't seem to understand this // fallthrough label unless it's right here, so we can't put it in a // more intuitive place (e.g. in an `else` above), unfortunately. [[fallthrough]]; } case 0x9A: { if (!this->license) { break; } if (this->version != GameVersion::GC) { throw runtime_error("9A received in non-GC session"); } should_forward = false; C_LoginWithUnusedSpace_GC_9E cmd; if (this->guild_card_number == 0) { cmd.player_tag = 0xFFFF0000; cmd.guild_card_number = 0xFFFFFFFF; } else { cmd.player_tag = 0x00010000; cmd.guild_card_number = this->guild_card_number; } cmd.sub_version = this->sub_version; cmd.unused2.data()[1] = 1; cmd.serial_number = string_printf("%08" PRIX32 "", this->license->serial_number); cmd.access_key = this->license->access_key; cmd.serial_number2 = cmd.serial_number; cmd.access_key2 = cmd.access_key; cmd.name = this->character_name; cmd.client_config.data = this->remote_client_config_data; // If there's a guild card number, a shorter 9E is sent that ends // right after the client config data send_command( this->server_bev.get(), this->version, this->server_output_crypt.get(), 0x9E, 0x01, &cmd, sizeof(C_LoginWithUnusedSpace_GC_9E) - (this->guild_card_number ? sizeof(cmd.unused_space) : 0), name.c_str()); break; } case 0x04: { // Some servers send a short 04 command if they don't use all of the // 0x20 bytes available. We should be prepared to handle that. if (data.size() < offsetof(S_UpdateClientConfig_DC_PC_GC_04, cfg)) { throw std::runtime_error("set security data command is too small"); } bool had_guild_card_number = (this->guild_card_number != 0); const auto* cmd = reinterpret_cast(data.data()); this->guild_card_number = cmd->guild_card_number; log(INFO, "[ProxyServer/%08" PRIX64 "] Guild card number set to %" PRIX32, this->id, this->guild_card_number); // It seems the client ignores the length of the 04 command, and // always copies 0x20 bytes to its config data. So if the server // sends a short 04 command, part of the previous command ends up in // the security data (usually part of the copyright string from the // server init command). We simulate that bug here. // If there was previously a guild card number, assume we got the // lobby server init text instead of the port map init text. memcpy(this->remote_client_config_data.data(), had_guild_card_number ? "t Lobby Server. Copyright SEGA E" : "t Port Map. Copyright SEGA Enter", this->remote_client_config_data.bytes()); memcpy(this->remote_client_config_data.data(), &cmd->cfg, min(data.size() - sizeof(S_UpdateClientConfig_DC_PC_GC_04), this->remote_client_config_data.bytes())); // If the guild card number was not set, pretend (to the server) // that this is the first 04 command the client has received. The // client responds with a 96 (checksum) in that case. if (!had_guild_card_number) { // We don't actually have a client checksum, of course... // hopefully just random data will do (probably no private servers // check this at all) le_uint64_t checksum = random_object() & 0x0000FFFFFFFFFFFF; send_command(this->server_bev.get(), this->version, this->server_output_crypt.get(), 0x96, 0x00, &checksum, sizeof(checksum), name.c_str()); } break; } case 0x19: { if (this->version == GameVersion::PATCH) { break; } if (data.size() < sizeof(S_Reconnect_19)) { throw std::runtime_error("reconnect command is too small"); } auto* args = reinterpret_cast(data.data()); memset(&this->next_destination, 0, sizeof(this->next_destination)); struct sockaddr_in* sin = reinterpret_cast( &this->next_destination); sin->sin_family = AF_INET; sin->sin_addr.s_addr = args->address.load_raw(); sin->sin_port = htons(args->port); if (!this->client_bev.get()) { log(WARNING, "[ProxyServer/%08" PRIX64 "] Received reconnect command with no destination present", this->id); } else { // If the client is on a virtual connection (fd < 0), only change // the port (so we'll know which version to treat the next // connection as). It's better to leave the address as-is so we // can circumvent the Plus/Ep3 same-network-server check. int fd = bufferevent_getfd(this->client_bev.get()); if (fd >= 0) { struct sockaddr_storage sockname_ss; socklen_t len = sizeof(sockname_ss); getsockname(fd, reinterpret_cast(&sockname_ss), &len); if (sockname_ss.ss_family != AF_INET) { throw logic_error("existing connection is not ipv4"); } struct sockaddr_in* sockname_sin = reinterpret_cast( &sockname_ss); args->address.store_raw(sockname_sin->sin_addr.s_addr); args->port = ntohs(sockname_sin->sin_port); } else { args->port = this->local_port; } } break; } case 0x1A: case 0xD5: { if (this->version != GameVersion::GC) { break; } // If the client has the no-close-confirmation flag set in its // newserv client config, send a fake confirmation to the remote // server immediately. if (this->newserv_client_config.flags & Client::Flag::NO_MESSAGE_BOX_CLOSE_CONFIRMATION) { send_command(this->server_bev.get(), this->version, this->server_output_crypt.get(), 0xD6, 0x00, "", 0, name.c_str()); } break; } case 0x60: case 0x62: case 0x6C: case 0x6D: case 0xC9: case 0xCB: check_implemented_subcommand(this->id, data); break; case 0x44: case 0xA6: { if (this->version != GameVersion::GC) { break; } if (!this->save_files) { break; } bool is_download_quest = (command == 0xA6); if (data.size() < sizeof(S_OpenFile_PC_GC_44_A6)) { log(WARNING, "[ProxyServer/%08" PRIX64 "] Open file command is too small; skipping file", this->id); break; } const auto* cmd = reinterpret_cast(data.data()); string output_filename = string_printf("%s.%s.%" PRIu64, cmd->filename.c_str(), is_download_quest ? "download" : "online", now()); for (size_t x = 0; x < output_filename.size(); x++) { if (output_filename[x] < 0x20 || output_filename[x] > 0x7E || output_filename[x] == '/') { output_filename[x] = '_'; } } if (output_filename[0] == '.') { output_filename[0] = '_'; } SavingFile sf(cmd->filename, output_filename, cmd->file_size); this->saving_files.emplace(cmd->filename, move(sf)); log(INFO, "[ProxyServer/%08" PRIX64 "] Opened file %s", this->id, output_filename.c_str()); break; } case 0x13: case 0xA7: { if (this->version != GameVersion::GC) { break; } if (!this->save_files) { break; } if (data.size() < sizeof(S_WriteFile_13_A7)) { log(WARNING, "[ProxyServer/%08" PRIX64 "] Write file command is too small", this->id); break; } const auto* cmd = reinterpret_cast(data.data()); SavingFile* sf = nullptr; try { sf = &this->saving_files.at(cmd->filename); } catch (const out_of_range&) { log(WARNING, "[ProxyServer/%08" PRIX64 "] Received data for non-open file %s", this->id, cmd->filename.c_str()); break; } size_t bytes_to_write = cmd->data_size; if (bytes_to_write > 0x400) { log(WARNING, "[ProxyServer/%08" PRIX64 "] Chunk data size is invalid; truncating to 0x400", this->id); bytes_to_write = 0x400; } log(INFO, "[ProxyServer/%08" PRIX64 "] Writing %zu bytes to %s", this->id, bytes_to_write, sf->output_filename.c_str()); fwritex(sf->f.get(), cmd->data, bytes_to_write); if (bytes_to_write > sf->remaining_bytes) { log(WARNING, "[ProxyServer/%08" PRIX64 "] Chunk size extends beyond original file size; file may be truncated", this->id); sf->remaining_bytes = 0; } else { sf->remaining_bytes -= bytes_to_write; } if (sf->remaining_bytes == 0) { log(INFO, "[ProxyServer/%08" PRIX64 "] File %s is complete", this->id, sf->output_filename.c_str()); this->saving_files.erase(cmd->filename); } break; } case 0xB8: { if (this->version != GameVersion::GC) { break; } if (!this->save_files) { break; } if (data.size() < 4) { log(WARNING, "[ProxyServer/%08" PRIX64 "] Card list data size is too small; skipping file", this->id); break; } StringReader r(data); size_t size = r.get_u32l(); if (r.remaining() < size) { log(WARNING, "[ProxyServer/%08" PRIX64 "] Card list data size extends beyond end of command; skipping file", this->id); break; } string output_filename = string_printf("cardupdate.mnr.%" PRIu64, now()); save_file(output_filename, r.read(size)); log(INFO, "[ProxyServer/%08" PRIX64 "] Wrote %zu bytes to %s", this->id, size, output_filename.c_str()); break; } case 0x67: // join lobby if (this->version != GameVersion::GC) { break; } this->lobby_players.clear(); this->lobby_players.resize(12); log(WARNING, "[ProxyServer/%08" PRIX64 "] Cleared lobby players", this->id); // This command can cause the client to no longer send D6 responses // when 1A/D5 large message boxes are closed. newserv keeps track of // this behavior in the client config, so if it happens during a // proxy session, update the client config that we'll restore if the // client uses the change ship or change block command. if (this->newserv_client_config.flags & Client::Flag::NO_MESSAGE_BOX_CLOSE_CONFIRMATION_AFTER_LOBBY_JOIN) { this->newserv_client_config.flags |= Client::Flag::NO_MESSAGE_BOX_CLOSE_CONFIRMATION; } [[fallthrough]]; case 0x65: // other player joined game case 0x68: { // other player joined lobby if (this->version != GameVersion::GC) { break; } size_t expected_size = offsetof(S_JoinLobby_GC_65_67_68, entries) + sizeof(S_JoinLobby_GC_65_67_68::Entry) * flag; if (data.size() < expected_size) { log(WARNING, "[ProxyServer/%08" PRIX64 "] Lobby join command is incorrect size (expected 0x%zX bytes, received 0x%zX bytes)", this->id, expected_size, data.size()); } else { auto* cmd = reinterpret_cast(data.data()); this->lobby_client_id = cmd->client_id; for (size_t x = 0; x < flag; x++) { size_t index = cmd->entries[x].lobby_data.client_id; if (index >= this->lobby_players.size()) { log(WARNING, "[ProxyServer/%08" PRIX64 "] Ignoring invalid player index %zu at position %zu", this->id, index, x); } else { this->lobby_players[index].guild_card_number = cmd->entries[x].lobby_data.guild_card; this->lobby_players[index].name = cmd->entries[x].disp.name; log(INFO, "[ProxyServer/%08" PRIX64 "] Added lobby player: (%zu) %" PRIu32 " %s", this->id, index, this->lobby_players[index].guild_card_number, this->lobby_players[index].name.c_str()); } } if (this->override_lobby_event >= 0) { cmd->event = this->override_lobby_event; } if (this->override_lobby_number >= 0) { cmd->lobby_number = this->override_lobby_number; } } break; } case 0x64: { // join game if (this->version != GameVersion::GC) { break; } // We don't need to clear lobby_players here because we always // overwrite all 4 entries in this case this->lobby_players.resize(4); log(WARNING, "[ProxyServer/%08" PRIX64 "] Cleared lobby players", this->id); const size_t expected_size = offsetof(S_JoinGame_GC_64, players_ep3); const size_t ep3_expected_size = sizeof(S_JoinGame_GC_64); if (data.size() < expected_size) { log(WARNING, "[ProxyServer/%08" PRIX64 "] Game join command is incorrect size (expected 0x%zX bytes, received 0x%zX bytes)", this->id, expected_size, data.size()); } else { auto* cmd = reinterpret_cast(data.data()); this->lobby_client_id = cmd->client_id; for (size_t x = 0; x < flag; x++) { this->lobby_players[x].guild_card_number = cmd->lobby_data[x].guild_card; if (data.size() >= ep3_expected_size) { this->lobby_players[x].name = cmd->players_ep3[x].disp.name; } else { this->lobby_players[x].name.clear(); } log(INFO, "[ProxyServer/%08" PRIX64 "] Added lobby player: (%zu) %" PRIu32 " %s", this->id, x, this->lobby_players[x].guild_card_number, this->lobby_players[x].name.c_str()); } if (this->override_section_id >= 0) { cmd->section_id = this->override_section_id; } if (this->override_lobby_event >= 0) { cmd->event = this->override_lobby_event; } } break; } case 0x66: case 0x69: { if (this->version != GameVersion::GC) { break; } if (data.size() < sizeof(S_LeaveLobby_66_69)) { log(WARNING, "[ProxyServer/%08" PRIX64 "] Lobby leave command is incorrect size", this->id); } else { const auto* cmd = reinterpret_cast(data.data()); size_t index = cmd->client_id; if (index >= this->lobby_players.size()) { log(WARNING, "[ProxyServer/%08" PRIX64 "] Lobby leave command references missing position", this->id); } else { this->lobby_players[index].guild_card_number = 0; this->lobby_players[index].name.clear(); log(INFO, "[ProxyServer/%08" PRIX64 "] Removed lobby player (%zu)", this->id, index); } } break; } } if (should_forward) { if (!this->client_bev.get()) { log(WARNING, "[ProxyServer/%08" PRIX64 "] No client is present; dropping command", this->id); } else { // Note: we intentionally don't pass name_str here because we already // printed the command above send_command(this->client_bev.get(), this->version, this->client_output_crypt.get(), command, flag, data.data(), data.size()); } } if (new_client_output_crypt.get()) { this->client_output_crypt = new_client_output_crypt; } }); } catch (const exception& e) { log(ERROR, "[ProxyServer/%08" PRIX64 "] Failed to process server command: %s", this->id, e.what()); this->disconnect(); } } void ProxyServer::LinkedSession::send_to_end( const void* data, size_t size, bool to_server) { size_t header_size = PSOCommandHeader::header_size(this->version); if (size < header_size) { throw runtime_error("command is too small for header"); } if (size & 3) { throw runtime_error("command size is not a multiple of 4"); } const auto* header = reinterpret_cast(data); string name = string_printf("ProxySession:%08" PRIX64 ":shell:%s", this->id, to_server ? "server" : "client"); send_command( to_server ? this->server_bev.get() : this->client_bev.get(), this->version, to_server ? this->server_output_crypt.get() : this->client_output_crypt.get(), header->command(this->version), header->flag(this->version), reinterpret_cast(data) + header_size, size - header_size, name.c_str()); } void ProxyServer::LinkedSession::send_to_end(const string& data, bool to_server) { this->send_to_end(data.data(), data.size(), to_server); } shared_ptr ProxyServer::get_session() { if (this->id_to_session.empty()) { throw runtime_error("no sessions exist"); } if (this->id_to_session.size() > 1) { throw runtime_error("multiple sessions exist"); } return this->id_to_session.begin()->second; } std::shared_ptr ProxyServer::create_licensed_session( std::shared_ptr l, uint16_t local_port, GameVersion version, const ClientConfig& newserv_client_config) { shared_ptr session(new LinkedSession( this, local_port, version, l, newserv_client_config)); auto emplace_ret = this->id_to_session.emplace(session->id, session); if (!emplace_ret.second) { throw runtime_error("session already exists for this license"); } log(INFO, "[ProxyServer/%08" PRIX64 "] Opening licensed session", session->id); return emplace_ret.first->second; } void ProxyServer::delete_session(uint64_t id) { if (this->id_to_session.erase(id)) { log(WARNING, "[ProxyServer/%08" PRIX64 "] Closed session", id); } }