From 28fb81ad45dd9340758b598ca0ccb41421575f4c Mon Sep 17 00:00:00 2001 From: Vladislav Khorev Date: Mon, 9 Nov 2026 23:28:18 +0600 Subject: [PATCH] Working with HTTPS --- WEBSOCKET_NETWORKING.md | 14 +- cmake/FetchDependencies.cmake | 27 +++ cmake/ThirdParty.cmake | 104 ++++++++- proj-windows/CMakeLists.txt | 4 +- src/Game.cpp | 10 +- src/game_server/WebsocketGameClient.cpp | 292 +++++++++++++++++++----- src/game_server/WebsocketGameClient.h | 16 +- 7 files changed, 409 insertions(+), 58 deletions(-) diff --git a/WEBSOCKET_NETWORKING.md b/WEBSOCKET_NETWORKING.md index 18b2346..a1c9b6a 100644 --- a/WEBSOCKET_NETWORKING.md +++ b/WEBSOCKET_NETWORKING.md @@ -41,7 +41,7 @@ configured player slot connects or disconnects. `WebsocketGameClient` implements `IGameServer`. -- It connects asynchronously to `ws://127.0.0.1:8080/`. +- It connects asynchronously to a `ws://` or `wss://` endpoint (`WebsocketEndpoint::parse`). - Received messages are queued by the network thread and applied on the game thread from `update()`. - It maintains an internal `InternalGameServer` replica so existing snapshot @@ -145,6 +145,13 @@ Networking is enabled for Windows and the Emscripten web client: replica, and remote-host code, and links Emscripten's `websocket.js` library. `WebsocketGameClient.cpp` selects the browser WebSocket API on Emscripten; it does not use Boost.Asio or Boost.Beast there. +- `wss://` (TLS) is supported by the Windows client only. `proj-windows` also defines + `NETWORK_TLS` and links static OpenSSL 3.4.1, which CMake downloads and builds + automatically at configure time (`cmake/ThirdParty.cmake`, section 12) using a + downloaded portable Strawberry Perl and the nmake from the installed Visual Studio + (located through `vswhere`). Builds go to `thirdparty/openssl-3.4.1/install-Debug` + and `install-Release`; later configures skip them. `NETWORK_TLS` unset (server tests) + means `wss://` endpoints fail with a logged error. - Linux and Android builds do not define `NETWORK` and do not compile the network transport. @@ -196,7 +203,10 @@ submission, and contiguous tick consumption. not implemented. - There is no authentication or reconnect/replay history. - A hash mismatch is logged but does not yet trigger checkpoint resynchronization. -- The endpoint is hardcoded in the graphical client. +- The endpoint is hardcoded in the graphical client (`kMultiplayerServerUrl` in + `Game.cpp`, currently `wss://api.shadowoverbishkek.com/server001/`). +- TLS certificate or hostname problems are logged but deliberately do not abort the + connection. `GameServer` itself has no TLS; it must sit behind a TLS-terminating proxy. - Multiplayer Lua and dialogue behavior remains unsupported. - Determinism is supported for identical executable/content builds. The floating-point simulation is not guaranteed bit-identical across platforms diff --git a/cmake/FetchDependencies.cmake b/cmake/FetchDependencies.cmake index 32d0504..a8de79d 100644 --- a/cmake/FetchDependencies.cmake +++ b/cmake/FetchDependencies.cmake @@ -56,3 +56,30 @@ check_and_download("https://github.com/ThePhD/sol2/archive/refs/tags/v3.3.0.zip" # 11) SDL2_mixer check_and_download("https://github.com/libsdl-org/SDL_mixer/archive/refs/tags/release-2.8.0.zip" "SDL_mixer-release-2.8.0.zip" "SDL_mixer-release-2.8.0" "CMakeLists.txt") + +# 12) OpenSSL (TLS for wss://) and a native Windows Perl needed to build it +if(WIN32) + check_and_download("https://github.com/openssl/openssl/releases/download/openssl-3.4.1/openssl-3.4.1.tar.gz" "openssl-3.4.1.tar.gz" "openssl-3.4.1" "Configure") + + # Strawberry Perl portable. The zip has no top-level folder, so it is + # extracted into its own directory. (Git's MSYS perl cannot build OpenSSL.) + set(STRAWBERRY_PERL_DIR "${THIRDPARTY_DIR}/strawberry-perl") + set(STRAWBERRY_PERL_ARCHIVE "${THIRDPARTY_DIR}/strawberry-perl-5.42.3.1-64bit-portable.zip") + if(NOT EXISTS "${STRAWBERRY_PERL_DIR}/perl/bin/perl.exe") + if(NOT EXISTS "${STRAWBERRY_PERL_ARCHIVE}") + message(STATUS "Downloading Strawberry Perl...") + file(DOWNLOAD + "https://github.com/StrawberryPerl/Perl-Dist-Strawberry/releases/download/SP_54231_64bit/strawberry-perl-5.42.3.1-64bit-portable.zip" + "${STRAWBERRY_PERL_ARCHIVE}" SHOW_PROGRESS) + endif() + message(STATUS "Extracting Strawberry Perl...") + file(MAKE_DIRECTORY "${STRAWBERRY_PERL_DIR}") + execute_process( + COMMAND ${CMAKE_COMMAND} -E tar xf "${STRAWBERRY_PERL_ARCHIVE}" + WORKING_DIRECTORY "${STRAWBERRY_PERL_DIR}" + ) + if(NOT EXISTS "${STRAWBERRY_PERL_DIR}/perl/bin/perl.exe") + message(FATAL_ERROR "Strawberry Perl extraction failed") + endif() + endif() +endif() diff --git a/cmake/ThirdParty.cmake b/cmake/ThirdParty.cmake index 4b6d015..693cbe4 100644 --- a/cmake/ThirdParty.cmake +++ b/cmake/ThirdParty.cmake @@ -725,4 +725,106 @@ set_target_properties(SDL2_mixer_external_lib PROPERTIES "$,${SDL2MIXER_BASE_DIR}-Debug/include,${SDL2MIXER_BASE_DIR}-Release/include>" INTERFACE_LINK_LIBRARIES "SDL2_external_lib" -) \ No newline at end of file +) +# =========================================== +# 12) OpenSSL (3.4.1) - TLS for wss:// (Windows only, static libs) +# =========================================== +if(WIN32) + set(OPENSSL_SRC_DIR "${THIRDPARTY_DIR}/openssl-3.4.1") + set(OPENSSL_BASE_DIR "${OPENSSL_SRC_DIR}/install") + + set(_have_openssl TRUE) + foreach(cfg IN LISTS BUILD_CONFIGS) + if(NOT EXISTS "${OPENSSL_BASE_DIR}-${cfg}/lib/libssl.lib" OR + NOT EXISTS "${OPENSSL_BASE_DIR}-${cfg}/lib/libcrypto.lib") + set(_have_openssl FALSE) + endif() + endforeach() + + if(NOT _have_openssl) + # Locate Visual Studio's vcvarsall.bat (provides cl, link, nmake). + set(_vswhere "$ENV{ProgramFiles\(x86\)}/Microsoft Visual Studio/Installer/vswhere.exe") + set(_vcvarsall "") + if(EXISTS "${_vswhere}") + execute_process( + COMMAND "${_vswhere}" -latest -products * -property installationPath + OUTPUT_VARIABLE _vs_path + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + if(_vs_path AND EXISTS "${_vs_path}/VC/Auxiliary/Build/vcvarsall.bat") + set(_vcvarsall "${_vs_path}/VC/Auxiliary/Build/vcvarsall.bat") + endif() + endif() + if(_vcvarsall STREQUAL "") + message(FATAL_ERROR "Visual Studio (vcvarsall.bat) not found; it is required to build OpenSSL") + endif() + + if(CMAKE_SIZEOF_VOID_P EQUAL 8) + set(_ossl_vc_arch x64) + set(_ossl_target VC-WIN64A) + else() + set(_ossl_vc_arch x86) + set(_ossl_target VC-WIN32) + endif() + + file(TO_NATIVE_PATH "${_vcvarsall}" _vcvarsall_native) + file(TO_NATIVE_PATH "${STRAWBERRY_PERL_DIR}/perl/bin" _perl_bin_native) + file(TO_NATIVE_PATH "${STRAWBERRY_PERL_DIR}/perl/site/bin" _perl_site_native) + + foreach(cfg IN LISTS BUILD_CONFIGS) + set(_ossl_build "${OPENSSL_SRC_DIR}/build-${cfg}") + file(REMOVE_RECURSE "${_ossl_build}") + file(MAKE_DIRECTORY "${_ossl_build}") + file(TO_NATIVE_PATH "${_ossl_build}" _ossl_build_native) + file(TO_NATIVE_PATH "${OPENSSL_SRC_DIR}/Configure" _ossl_configure_native) + file(TO_NATIVE_PATH "${OPENSSL_BASE_DIR}-${cfg}" _ossl_prefix_native) + + set(_ossl_cfg_flags "") + if(cfg STREQUAL "Debug") + # /MDd: link the dynamic debug CRT like the game (static libs default to /MTd) + set(_ossl_cfg_flags "--debug /MDd") + else() + set(_ossl_cfg_flags "--release /MD") + endif() + + # A batch file avoids cmd.exe quoting problems. + set(_ossl_bat "${_ossl_build}/build_openssl.bat") + file(WRITE "${_ossl_bat}" +"@echo off +call \"${_vcvarsall_native}\" ${_ossl_vc_arch} || exit /b 1 +set \"PATH=${_perl_bin_native};${_perl_site_native};%PATH%\" +set CL=/MP +cd /d \"${_ossl_build_native}\" || exit /b 1 +perl \"${_ossl_configure_native}\" ${_ossl_target} no-shared no-asm no-tests no-docs ${_ossl_cfg_flags} --prefix=\"${_ossl_prefix_native}\" --openssldir=\"${_ossl_prefix_native}/ssl\" || exit /b 1 +nmake || exit /b 1 +nmake install_sw || exit /b 1 +") + log("Building OpenSSL (${cfg}) - this takes several minutes ...") + execute_process( + COMMAND cmd /c "${_ossl_bat}" + RESULT_VARIABLE _ossl_res + ) + if(NOT _ossl_res EQUAL 0) + message(FATAL_ERROR "OpenSSL build failed for ${cfg} (see output above)") + endif() + endforeach() + endif() + + add_library(openssl_crypto_external_lib UNKNOWN IMPORTED GLOBAL) + set_target_properties(openssl_crypto_external_lib PROPERTIES + IMPORTED_LOCATION_DEBUG "${OPENSSL_BASE_DIR}-Debug/lib/libcrypto.lib" + IMPORTED_LOCATION_RELEASE "${OPENSSL_BASE_DIR}-Release/lib/libcrypto.lib" + INTERFACE_INCLUDE_DIRECTORIES + "$,${OPENSSL_BASE_DIR}-Debug/include,${OPENSSL_BASE_DIR}-Release/include>" + INTERFACE_LINK_LIBRARIES "ws2_32;crypt32;advapi32;user32;gdi32" + ) + + add_library(openssl_ssl_external_lib UNKNOWN IMPORTED GLOBAL) + set_target_properties(openssl_ssl_external_lib PROPERTIES + IMPORTED_LOCATION_DEBUG "${OPENSSL_BASE_DIR}-Debug/lib/libssl.lib" + IMPORTED_LOCATION_RELEASE "${OPENSSL_BASE_DIR}-Release/lib/libssl.lib" + INTERFACE_INCLUDE_DIRECTORIES + "$,${OPENSSL_BASE_DIR}-Debug/include,${OPENSSL_BASE_DIR}-Release/include>" + INTERFACE_LINK_LIBRARIES "openssl_crypto_external_lib" + ) +endif() diff --git a/proj-windows/CMakeLists.txt b/proj-windows/CMakeLists.txt index 89f9fc5..b044a8c 100644 --- a/proj-windows/CMakeLists.txt +++ b/proj-windows/CMakeLists.txt @@ -143,6 +143,7 @@ target_compile_definitions(ShadowOverBishkekDemo PRIVATE _WIN32_WINNT=0x0A00 PNG_ENABLED NETWORK + NETWORK_TLS ) # Линкуем с SDL2main, если он вообще установлен @@ -159,7 +160,8 @@ target_link_libraries(ShadowOverBishkekDemo PRIVATE eigen_external_lib boost_external_lib sol2_external_lib - SDL2_mixer_external_lib + SDL2_mixer_external_lib + openssl_ssl_external_lib ) # Линкуем OpenGL (Windows) diff --git a/src/Game.cpp b/src/Game.cpp index 87b9722..712ec8c 100644 --- a/src/Game.cpp +++ b/src/Game.cpp @@ -557,7 +557,15 @@ namespace FRG if (remoteCampaignActive) { logger() << "[network] Campaign '" << campaignId << "' selected; creating WebSocket client" << std::endl; - websocketGameClient = std::make_unique(); + // Use "ws://127.0.0.1:8080/" for a local development server. + static const char* const kMultiplayerServerUrl = + "wss://api.shadowoverbishkek.com/server001/"; + WebsocketEndpoint endpoint; + if (!endpoint.parse(kMultiplayerServerUrl)) { + logger() << "[network] Invalid multiplayer server URL: " + << kMultiplayerServerUrl << std::endl; + } + websocketGameClient = std::make_unique(endpoint); remoteGameServerHost = std::make_unique(*websocketGameClient); gameServer.use(*websocketGameClient); diff --git a/src/game_server/WebsocketGameClient.cpp b/src/game_server/WebsocketGameClient.cpp index 30fc77d..fcb4b6a 100644 --- a/src/game_server/WebsocketGameClient.cpp +++ b/src/game_server/WebsocketGameClient.cpp @@ -5,6 +5,21 @@ #else #include #include +#ifdef NETWORK_TLS +#include +// wincrypt.h defines macros that collide with OpenSSL identifiers. +#undef X509_NAME +#undef X509_EXTENSIONS +#undef X509_CERT_PAIR +#undef PKCS7_ISSUER_AND_SERIAL +#undef OCSP_REQUEST +#undef OCSP_RESPONSE +#include +#include +#include +#include +#include +#endif #endif #include #include @@ -23,19 +38,104 @@ namespace asio = boost::asio; namespace beast = boost::beast; namespace websocket = beast::websocket; using tcp = asio::ip::tcp; + +#ifdef NETWORK_TLS +// Loads the Windows "ROOT" certificate store into the OpenSSL context. +void loadWindowsRootCertificates(asio::ssl::context& context) +{ + HCERTSTORE store = CertOpenSystemStoreA(0, "ROOT"); + if (!store) { + std::cerr << "[network] Could not open the Windows root certificate store\n"; + return; + } + X509_STORE* x509Store = SSL_CTX_get_cert_store(context.native_handle()); + PCCERT_CONTEXT cert = nullptr; + while ((cert = CertEnumCertificatesInStore(store, cert)) != nullptr) { + const unsigned char* data = cert->pbCertEncoded; + X509* x509 = d2i_X509(nullptr, &data, static_cast(cert->cbCertEncoded)); + if (x509) { + X509_STORE_add_cert(x509Store, x509); + X509_free(x509); + } + } + CertCloseStore(store, 0); +} + +// Certificate problems are logged but deliberately do not abort the connection. +bool logAndAcceptCertificate(bool preverified, asio::ssl::verify_context& context) +{ + if (!preverified) { + X509_STORE_CTX* storeContext = context.native_handle(); + const int error = X509_STORE_CTX_get_error(storeContext); + const int depth = X509_STORE_CTX_get_error_depth(storeContext); + char subject[256] = "?"; + if (X509* cert = X509_STORE_CTX_get_current_cert(storeContext)) + X509_NAME_oneline(X509_get_subject_name(cert), subject, sizeof(subject)); + std::cerr << "[network] TLS certificate warning (depth " << depth + << ", subject " << subject << "): " + << X509_verify_cert_error_string(error) + << " - continuing anyway\n"; + } + return true; +} +#endif #endif } // namespace +bool WebsocketEndpoint::parse(const std::string& url) +{ + bool tls = false; + std::string rest; + if (url.rfind("wss://", 0) == 0) { + tls = true; + rest = url.substr(6); + } else if (url.rfind("ws://", 0) == 0) { + rest = url.substr(5); + } else { + return false; + } + std::string authority = rest; + std::string newPath = "/"; + const std::size_t slash = rest.find('/'); + if (slash != std::string::npos) { + authority = rest.substr(0, slash); + newPath = rest.substr(slash); + } + std::string newHost = authority; + std::string newPort = tls ? "443" : "80"; + if (!authority.empty() && authority.front() == '[') { // [ipv6]:port + const std::size_t close = authority.find(']'); + if (close == std::string::npos) return false; + newHost = authority.substr(1, close - 1); + if (close + 1 < authority.size()) { + if (authority[close + 1] != ':') return false; + newPort = authority.substr(close + 2); + } + } else { + const std::size_t colon = authority.rfind(':'); + if (colon != std::string::npos) { + newHost = authority.substr(0, colon); + newPort = authority.substr(colon + 1); + } + } + if (newHost.empty() || newPort.empty()) return false; + host = std::move(newHost); + port = std::move(newPort); + path = std::move(newPath); + useTls = tls; + return true; +} + class WebsocketGameClient::Impl { public: #ifdef __EMSCRIPTEN__ - Impl(std::string host, std::string port, std::string path) + Impl(std::string host, std::string port, std::string path, bool useTls) : host_(std::move(host)) , port_(std::move(port)) , path_(std::move(path)) { - url_ = "ws://" + host_ + ':' + port_ + + url_ = std::string(useTls ? "wss://" : "ws://") + host_ + ':' + port_ + (path_.empty() || path_.front() == '/' ? path_ : "/" + path_); std::cout << "[network] Opening " << url_ << std::endl; @@ -143,14 +243,38 @@ private: bool closing_ = false; std::vector messages_; #else - Impl(std::string host, std::string port, std::string path) - : resolver_(io_) + Impl(std::string host, std::string port, std::string path, bool useTls) + : +#ifdef NETWORK_TLS + sslContext_(asio::ssl::context::tls_client) + , resolver_(io_) + , secureSocket_(io_, sslContext_) +#else + resolver_(io_) +#endif , socket_(io_) , host_(std::move(host)) , port_(std::move(port)) , path_(std::move(path)) + , useTls_(useTls) { - std::cout << "[network] Resolving ws://" << host_ << ':' << port_ +#ifdef NETWORK_TLS + sslContext_.set_options( + asio::ssl::context::default_workarounds | + asio::ssl::context::no_sslv2 | asio::ssl::context::no_sslv3 | + asio::ssl::context::no_tlsv1 | asio::ssl::context::no_tlsv1_1); + loadWindowsRootCertificates(sslContext_); + sslContext_.set_verify_mode(asio::ssl::verify_peer); + sslContext_.set_verify_callback(&logAndAcceptCertificate); +#else + if (useTls_) { + std::cerr << "[network] wss:// requested but TLS support was not " + "compiled in (NETWORK_TLS)" << std::endl; + connectionFailed_.store(true); + return; + } +#endif + std::cout << "[network] Resolving " << scheme() << host_ << ':' << port_ << path_ << std::endl; resolve(); thread_ = std::thread([this]() { io_.run(); }); @@ -184,6 +308,20 @@ private: } private: + const char* scheme() const { return useTls_ ? "wss://" : "ws://"; } + + template + void withStream(F&& f) + { +#ifdef NETWORK_TLS + if (useTls_) { + f(secureSocket_); + return; + } +#endif + f(socket_); + } + void resolve() { resolver_.async_resolve( @@ -192,60 +330,97 @@ private: if (error) return fail("resolve", error); std::cout << "[network] WebSocket host resolved; connecting" << std::endl; - asio::async_connect( - socket_.next_layer(), results, - [this](beast::error_code connectError, - const tcp::endpoint&) { - if (connectError) return fail("connect", connectError); - std::cout << "[network] TCP connected; starting WebSocket handshake" - << std::endl; - socket_.set_option( - websocket::stream_base::timeout::suggested( - beast::role_type::client)); - socket_.async_handshake( - host_, path_, - [this](beast::error_code handshakeError) { - if (handshakeError) - return fail("handshake", handshakeError); - connected_.store(true); - std::cout - << "[network] Connected to ws://" - << host_ << ':' << port_ << path_ << std::endl; - read(); - }); - }); + withStream([&](auto& stream) { + asio::async_connect( + beast::get_lowest_layer(stream), results, + [this](beast::error_code connectError, + const tcp::endpoint&) { + if (connectError) return fail("connect", connectError); + std::cout << "[network] TCP connected" << std::endl; + startTlsOrWebsocketHandshake(); + }); + }); }); } + void startTlsOrWebsocketHandshake() + { +#ifdef NETWORK_TLS + if (useTls_) { + SSL* ssl = secureSocket_.next_layer().native_handle(); + // SNI, and hostname verification (failures are only logged). + SSL_set_tlsext_host_name(ssl, const_cast(host_.c_str())); + SSL_set1_host(ssl, host_.c_str()); + std::cout << "[network] Starting TLS handshake" << std::endl; + secureSocket_.next_layer().async_handshake( + asio::ssl::stream_base::client, + [this](beast::error_code tlsError) { + if (tlsError) return fail("TLS handshake", tlsError); + std::cout << "[network] TLS established" << std::endl; + startWebsocketHandshake(); + }); + return; + } +#endif + startWebsocketHandshake(); + } + + void startWebsocketHandshake() + { + const bool defaultPort = port_ == (useTls_ ? "443" : "80"); + const std::string hostHeader = defaultPort ? host_ : host_ + ':' + port_; + std::cout << "[network] Starting WebSocket handshake" << std::endl; + withStream([&](auto& stream) { + stream.set_option( + websocket::stream_base::timeout::suggested( + beast::role_type::client)); + stream.async_handshake( + hostHeader, path_, + [this](beast::error_code handshakeError) { + if (handshakeError) + return fail("handshake", handshakeError); + connected_.store(true); + std::cout + << "[network] Connected to " << scheme() + << host_ << ':' << port_ << path_ << std::endl; + read(); + }); + }); + } + void read() { - socket_.async_read( - buffer_, - [this](beast::error_code error, std::size_t) { - if (error) { - connected_.store(false); - if (error != websocket::error::closed) fail("read", error); - return; - } - std::string message = beast::buffers_to_string(buffer_.data()); - buffer_.consume(buffer_.size()); - { - std::lock_guard lock(messagesMutex_); - messages_.push_back(std::move(message)); - } - read(); - }); + withStream([this](auto& stream) { + stream.async_read( + buffer_, + [this](beast::error_code error, std::size_t) { + if (error) { + connected_.store(false); + if (error != websocket::error::closed) fail("read", error); + return; + } + std::string message = beast::buffers_to_string(buffer_.data()); + buffer_.consume(buffer_.size()); + { + std::lock_guard lock(messagesMutex_); + messages_.push_back(std::move(message)); + } + read(); + }); + }); } void write() { - socket_.async_write( - asio::buffer(outbound_.front()), - [this](beast::error_code error, std::size_t) { - if (error) return fail("write", error); - outbound_.pop_front(); - if (!outbound_.empty()) write(); - }); + withStream([this](auto& stream) { + stream.async_write( + asio::buffer(outbound_.front()), + [this](beast::error_code error, std::size_t) { + if (error) return fail("write", error); + outbound_.pop_front(); + if (!outbound_.empty()) write(); + }); + }); } void fail(const char* operation, beast::error_code error) @@ -257,13 +432,20 @@ private: } asio::io_context io_; +#ifdef NETWORK_TLS + asio::ssl::context sslContext_; +#endif tcp::resolver resolver_; +#ifdef NETWORK_TLS + websocket::stream> secureSocket_; +#endif websocket::stream socket_; beast::flat_buffer buffer_; std::thread thread_; std::string host_; std::string port_; std::string path_; + bool useTls_ = false; std::atomic_bool connected_{false}; std::atomic_bool connectionFailed_{false}; std::mutex messagesMutex_; @@ -273,9 +455,15 @@ private: }; WebsocketGameClient::WebsocketGameClient( - std::string host, std::string port, std::string path) + std::string host, std::string port, std::string path, bool useTls) : impl_(std::make_unique( - std::move(host), std::move(port), std::move(path))) + std::move(host), std::move(port), std::move(path), useTls)) +{ +} + +WebsocketGameClient::WebsocketGameClient(const WebsocketEndpoint& endpoint) + : WebsocketGameClient( + endpoint.host, endpoint.port, endpoint.path, endpoint.useTls) { } diff --git a/src/game_server/WebsocketGameClient.h b/src/game_server/WebsocketGameClient.h index beb0ecf..db99881 100644 --- a/src/game_server/WebsocketGameClient.h +++ b/src/game_server/WebsocketGameClient.h @@ -11,12 +11,26 @@ namespace FRG { class RemoteGameServerHost; +// Parsed "ws://host[:port][/path]" or "wss://host[:port][/path]". +struct WebsocketEndpoint { + std::string host = "127.0.0.1"; + std::string port = "8080"; + std::string path = "/"; + bool useTls = false; + + // Returns false (and leaves *this untouched) for an unsupported/malformed URL. + // Default ports: 80 for ws://, 443 for wss://. + bool parse(const std::string& url); +}; + class WebsocketGameClient final : public IGameServer { public: WebsocketGameClient( std::string host = "127.0.0.1", std::string port = "8080", - std::string path = "/"); + std::string path = "/", + bool useTls = false); + explicit WebsocketGameClient(const WebsocketEndpoint& endpoint); ~WebsocketGameClient(); WebsocketGameClient(const WebsocketGameClient&) = delete;