From b2b037fb9472670ce8818b932593ecdbd2119cb8 Mon Sep 17 00:00:00 2001 From: server Date: Tue, 14 Apr 2026 08:42:05 +0200 Subject: [PATCH] tests: add end-to-end login smoke utility --- tests/CMakeLists.txt | 12 ++ tests/login_smoke.cpp | 406 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 418 insertions(+) create mode 100644 tests/login_smoke.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 215f3c1..03ab9fe 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,14 +7,26 @@ add_executable(metin_smoke_tests ${CMAKE_SOURCE_DIR}/src/game/SecureCipher.cpp ) +add_executable(metin_login_smoke + login_smoke.cpp + ${CMAKE_SOURCE_DIR}/src/game/SecureCipher.cpp +) + target_link_libraries(metin_smoke_tests libthecore sodium pthread ) +target_link_libraries(metin_login_smoke + libthecore + sodium + pthread +) + if (CMAKE_SYSTEM_NAME STREQUAL "FreeBSD") target_link_libraries(metin_smoke_tests md) + target_link_libraries(metin_login_smoke md) endif() add_test(NAME metin_smoke_tests COMMAND metin_smoke_tests) diff --git a/tests/login_smoke.cpp b/tests/login_smoke.cpp new file mode 100644 index 0000000..86a8b42 --- /dev/null +++ b/tests/login_smoke.cpp @@ -0,0 +1,406 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "common/packet_headers.h" +#include "game/SecureCipher.h" + +namespace +{ +constexpr size_t LOGIN_MAX_LEN_LOCAL = 30; +constexpr size_t PASSWD_MAX_LEN_LOCAL = 16; +constexpr size_t ACCOUNT_STATUS_MAX_LEN_LOCAL = 8; + +#pragma pack(push, 1) +struct PacketGCPhase +{ + uint16_t header; + uint16_t length; + uint8_t phase; +}; + +struct PacketGCKeyChallenge +{ + uint16_t header; + uint16_t length; + uint8_t server_pk[SecureCipher::PK_SIZE]; + uint8_t challenge[SecureCipher::CHALLENGE_SIZE]; + uint32_t server_time; +}; + +struct PacketCGKeyResponse +{ + uint16_t header; + uint16_t length; + uint8_t client_pk[SecureCipher::PK_SIZE]; + uint8_t challenge_response[SecureCipher::HMAC_SIZE]; +}; + +struct PacketGCKeyComplete +{ + uint16_t header; + uint16_t length; + uint8_t encrypted_token[SecureCipher::SESSION_TOKEN_SIZE + SecureCipher::TAG_SIZE]; + uint8_t nonce[SecureCipher::NONCE_SIZE]; +}; + +struct PacketCGLogin3 +{ + uint16_t header; + uint16_t length; + char login[LOGIN_MAX_LEN_LOCAL + 1]; + char passwd[PASSWD_MAX_LEN_LOCAL + 1]; +}; + +struct PacketCGLogin2 +{ + uint16_t header; + uint16_t length; + char login[LOGIN_MAX_LEN_LOCAL + 1]; + uint32_t login_key; +}; + +struct PacketGCAuthSuccess +{ + uint16_t header; + uint16_t length; + uint32_t login_key; + uint8_t result; +}; + +struct PacketGCLoginFailure +{ + uint16_t header; + uint16_t length; + char status[ACCOUNT_STATUS_MAX_LEN_LOCAL + 1]; +}; + +struct PacketGCEmpire +{ + uint16_t header; + uint16_t length; + uint8_t empire; +}; +#pragma pack(pop) + +void Expect(bool condition, std::string_view message) +{ + if (!condition) + throw std::runtime_error(std::string(message)); +} + +void WriteExact(int fd, const void* data, size_t length, std::string_view context) +{ + const uint8_t* cursor = static_cast(data); + size_t remaining = length; + + while (remaining > 0) + { + const ssize_t written = send(fd, cursor, remaining, 0); + if (written <= 0) + throw std::runtime_error(std::string(context) + ": send failed: " + std::strerror(errno)); + + cursor += written; + remaining -= static_cast(written); + } +} + +void ReadExact(int fd, void* data, size_t length, std::string_view context) +{ + uint8_t* cursor = static_cast(data); + size_t remaining = length; + + while (remaining > 0) + { + const ssize_t bytes_read = recv(fd, cursor, remaining, 0); + if (bytes_read <= 0) + throw std::runtime_error(std::string(context) + ": recv failed: " + std::strerror(errno)); + + cursor += bytes_read; + remaining -= static_cast(bytes_read); + } +} + +bool WaitForReadable(int fd, int timeout_ms) +{ + pollfd descriptor {}; + descriptor.fd = fd; + descriptor.events = POLLIN; + + const int rc = poll(&descriptor, 1, timeout_ms); + if (rc < 0) + throw std::runtime_error("poll failed: " + std::string(std::strerror(errno))); + + return rc > 0 && (descriptor.revents & POLLIN); +} + +int ConnectTcp(const std::string& host, uint16_t port) +{ + const int fd = socket(AF_INET, SOCK_STREAM, 0); + Expect(fd >= 0, "socket() failed"); + + timeval timeout {}; + timeout.tv_sec = 5; + timeout.tv_usec = 0; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); + + sockaddr_in addr {}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + Expect(inet_pton(AF_INET, host.c_str(), &addr.sin_addr) == 1, "inet_pton() failed"); + Expect(connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0, + "connect() failed: " + std::string(std::strerror(errno))); + return fd; +} + +class EncryptedClient +{ +public: + EncryptedClient(const std::string& host, uint16_t port) + : m_fd(ConnectTcp(host, port)) + { + Expect(m_cipher.Initialize(), "SecureCipher init failed"); + } + + ~EncryptedClient() + { + if (m_fd >= 0) + close(m_fd); + } + + void Handshake() + { + PacketGCPhase phase {}; + ReadExact(m_fd, &phase, sizeof(phase), "read initial phase"); + Expect(phase.header == GC::PHASE, "unexpected initial phase header"); + + PacketGCKeyChallenge challenge {}; + ReadExact(m_fd, &challenge, sizeof(challenge), "read key challenge"); + Expect(challenge.header == GC::KEY_CHALLENGE, "unexpected key challenge header"); + Expect(m_cipher.ComputeClientKeys(challenge.server_pk), "client key derivation failed"); + + PacketCGKeyResponse response {}; + response.header = CG::KEY_RESPONSE; + response.length = sizeof(response); + m_cipher.GetPublicKey(response.client_pk); + m_cipher.ComputeChallengeResponse(challenge.challenge, response.challenge_response); + WriteExact(m_fd, &response, sizeof(response), "write key response"); + + PacketGCKeyComplete complete {}; + ReadExact(m_fd, &complete, sizeof(complete), "read key complete"); + Expect(complete.header == GC::KEY_COMPLETE, "unexpected key complete header"); + + std::array session_token {}; + Expect(m_cipher.DecryptToken(complete.encrypted_token, sizeof(complete.encrypted_token), complete.nonce, session_token.data()), + "decrypt token failed"); + m_cipher.SetSessionToken(session_token.data()); + m_cipher.SetActivated(true); + } + + template + void SendEncryptedPacket(const TPacket& packet) + { + TPacket encrypted = packet; + m_cipher.EncryptInPlace(&encrypted, sizeof(encrypted)); + WriteExact(m_fd, &encrypted, sizeof(encrypted), "write encrypted packet"); + } + + bool WaitForFrame(std::vector& frame, int timeout_ms) + { + while (true) + { + if (TryPopFrame(frame)) + return true; + + if (!WaitForReadable(m_fd, timeout_ms)) + return false; + + ReadEncryptedChunk(); + } + } + +private: + bool TryPopFrame(std::vector& frame) + { + if (m_stream.size() < PACKET_HEADER_SIZE) + return false; + + const auto header = *reinterpret_cast(m_stream.data()); + const auto length = *reinterpret_cast(m_stream.data() + sizeof(uint16_t)); + Expect(length >= PACKET_HEADER_SIZE && length <= 8192, + "invalid encrypted packet length header=" + std::to_string(header) + + " length=" + std::to_string(length)); + + if (m_stream.size() < length) + return false; + + frame.assign(m_stream.begin(), m_stream.begin() + length); + m_stream.erase(m_stream.begin(), m_stream.begin() + length); + return true; + } + + void ReadEncryptedChunk() + { + std::array buffer {}; + const ssize_t bytes_read = recv(m_fd, buffer.data(), buffer.size(), 0); + if (bytes_read <= 0) + throw std::runtime_error("recv failed: " + std::string(std::strerror(errno))); + + const size_t old_size = m_stream.size(); + m_stream.resize(old_size + static_cast(bytes_read)); + std::memcpy(m_stream.data() + old_size, buffer.data(), static_cast(bytes_read)); + m_cipher.DecryptInPlace(m_stream.data() + old_size, static_cast(bytes_read)); + } + + int m_fd = -1; + SecureCipher m_cipher; + std::vector m_stream; +}; + +uint16_t FrameHeader(const std::vector& frame) +{ + return *reinterpret_cast(frame.data()); +} + +uint16_t FrameLength(const std::vector& frame) +{ + return *reinterpret_cast(frame.data() + sizeof(uint16_t)); +} + +uint32_t Authenticate(const std::string& host, uint16_t auth_port, const std::string& login, const std::string& password) +{ + EncryptedClient auth_client(host, auth_port); + auth_client.Handshake(); + + PacketCGLogin3 login_packet {}; + login_packet.header = CG::LOGIN3; + login_packet.length = sizeof(login_packet); + std::strncpy(login_packet.login, login.c_str(), sizeof(login_packet.login) - 1); + std::strncpy(login_packet.passwd, password.c_str(), sizeof(login_packet.passwd) - 1); + auth_client.SendEncryptedPacket(login_packet); + + std::vector frame; + for (int i = 0; i < 8; ++i) + { + Expect(auth_client.WaitForFrame(frame, 5000), "timed out waiting for auth response"); + const auto header = FrameHeader(frame); + + if (header == GC::PHASE) + continue; + + if (header == GC::LOGIN_FAILURE) + { + Expect(frame.size() == sizeof(PacketGCLoginFailure), "unexpected login failure size"); + const auto* failure = reinterpret_cast(frame.data()); + throw std::runtime_error(std::string("auth login failed: ") + failure->status); + } + + if (header == GC::AUTH_SUCCESS) + { + Expect(frame.size() == sizeof(PacketGCAuthSuccess), "unexpected auth success size"); + const auto* success = reinterpret_cast(frame.data()); + Expect(success->result != 0, "auth result returned failure"); + std::cout << "auth_success login_key=" << success->login_key << "\n"; + return success->login_key; + } + } + + throw std::runtime_error("did not receive AUTH_SUCCESS"); +} + +void LoginToChannel(const std::string& host, uint16_t channel_port, const std::string& login, uint32_t login_key) +{ + EncryptedClient channel_client(host, channel_port); + channel_client.Handshake(); + + PacketCGLogin2 login_packet {}; + login_packet.header = CG::LOGIN2; + login_packet.length = sizeof(login_packet); + login_packet.login_key = login_key; + std::strncpy(login_packet.login, login.c_str(), sizeof(login_packet.login) - 1); + channel_client.SendEncryptedPacket(login_packet); + + bool saw_empire = false; + bool saw_login_success = false; + uint8_t empire = 0; + std::vector frame; + + for (int i = 0; i < 16; ++i) + { + Expect(channel_client.WaitForFrame(frame, 5000), "timed out waiting for channel response"); + const auto header = FrameHeader(frame); + const auto length = FrameLength(frame); + + if (header == GC::PHASE) + continue; + + if (header == GC::LOGIN_FAILURE) + { + Expect(frame.size() == sizeof(PacketGCLoginFailure), "unexpected channel login failure size"); + const auto* failure = reinterpret_cast(frame.data()); + throw std::runtime_error(std::string("channel login failed: ") + failure->status); + } + + if (header == GC::EMPIRE) + { + Expect(frame.size() == sizeof(PacketGCEmpire), "unexpected empire packet size"); + const auto* empire_packet = reinterpret_cast(frame.data()); + saw_empire = true; + empire = empire_packet->empire; + std::cout << "channel_empire empire=" << static_cast(empire) << "\n"; + continue; + } + + if (header == GC::LOGIN_SUCCESS4) + { + saw_login_success = true; + std::cout << "channel_login_success length=" << length << "\n"; + break; + } + } + + Expect(saw_empire, "did not receive EMPIRE"); + Expect(saw_login_success, "did not receive LOGIN_SUCCESS4"); +} +} + +int main(int argc, char** argv) +{ + try + { + Expect(argc == 6, "usage: metin_login_smoke "); + + const std::string host = argv[1]; + const uint16_t auth_port = static_cast(std::stoi(argv[2])); + const uint16_t channel_port = static_cast(std::stoi(argv[3])); + const std::string login = argv[4]; + const std::string password = argv[5]; + + Expect(login.size() <= LOGIN_MAX_LEN_LOCAL, "login too long"); + Expect(password.size() <= PASSWD_MAX_LEN_LOCAL, "password too long"); + + const uint32_t login_key = Authenticate(host, auth_port, login, password); + LoginToChannel(host, channel_port, login, login_key); + + std::cout << "full_login_success\n"; + return 0; + } + catch (const std::exception& e) + { + std::cerr << "metin_login_smoke failed: " << e.what() << "\n"; + return 1; + } +}