diff --git a/.gitignore b/.gitignore index ed4a43a..f8f6793 100644 --- a/.gitignore +++ b/.gitignore @@ -57,19 +57,7 @@ Makefile.cmake /cmake-build-release/ # Roblox payload -/rbx_bin/libroblox.so -/rbx_bin/libroblox.so.* -/rbx_bin/client_settings_android.json -/rbx_bin/assets/ -/rbx_bin/android/ -/rbx_bin/sober_apk/ -/rbx_bin/versions/ -/rbx_bin/.payload-update.*/ -/rbx_bin/*.apk -/rbx_bin/*.so -/rbx_bin/*.so.* - -!/rbx_bin/.gitkeep +/rbx_bin/ # Runtime state /runtime/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 058d573..6dacda6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -268,6 +268,7 @@ add_library(mocktail_runtime STATIC src/runtime/environment.cc src/runtime/external_launch_broker.cc src/runtime/frame_rate_policy.cc + src/runtime/fleasion.cc src/runtime/game_mode.cc src/runtime/game_session_coordinator.cc src/runtime/graphics_launch_policy.cc @@ -329,6 +330,7 @@ if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND endif() target_link_libraries(mocktail_runtime PUBLIC Threads::Threads) target_link_libraries(mocktail_runtime PRIVATE + OpenSSL::Crypto CURL::libcurl nlohmann_json::nlohmann_json PkgConfig::GIO diff --git a/config/mocktail.example.yaml b/config/mocktail.example.yaml index 986bb65..9cfd2e6 100644 --- a/config/mocktail.example.yaml +++ b/config/mocktail.example.yaml @@ -75,6 +75,16 @@ audio: input_device: default integrations: + fleasion: + # Boolean (default: false): trust Fleasion's CA without editing Roblox files. + enabled: false + # String: match Fleasion's routing mode: env or hosts. + proxy_mode: env + # Integer: Fleasion's local HTTP proxy port in env mode. + proxy_port: 58443 + # Optional absolute public CA path. Default: $XDG_CONFIG_HOME/Fleasion/proxy_ca/ca.crt + # or ~/.config/Fleasion/proxy_ca/ca.crt. Never select ca.key. + # ca_certificate: /home/user/.config/Fleasion/proxy_ca/ca.crt discord_rpc: # Boolean (default: false): publish Mocktail activity to Discord Desktop. # This never signs in to Discord and never reads an account token. diff --git a/include/runtime/fleasion.h b/include/runtime/fleasion.h new file mode 100644 index 0000000..678d7da --- /dev/null +++ b/include/runtime/fleasion.h @@ -0,0 +1,28 @@ +#ifndef MOCKTAIL_RUNTIME_FLEASION_H_ +#define MOCKTAIL_RUNTIME_FLEASION_H_ + +#include +#include + +#include "runtime/runtime_config.h" +#include "runtime/runtime_paths.h" + +namespace mocktail::runtime { + +struct FleasionPreparation { + std::filesystem::path certificate; + std::filesystem::path base_bundle; + std::filesystem::path bundle; + std::string error; + explicit operator bool() const { return error.empty(); } +}; + +// Generates a private, replaceable trust bundle outside the managed payload. +// Reads certificates only; never reads Fleasion's private key or changes it. +FleasionPreparation PrepareFleasion(const RuntimeConfig& config, + const Environment& environment, + const RuntimePaths& paths); + +} // namespace mocktail::runtime + +#endif diff --git a/include/runtime/runtime_config.h b/include/runtime/runtime_config.h index 6c4ddbd..b01de6d 100644 --- a/include/runtime/runtime_config.h +++ b/include/runtime/runtime_config.h @@ -119,6 +119,13 @@ class RuntimeConfig { } bool ca_bundle_valid() const { return ca_bundle_valid_; } bool use_system_proxy() const { return use_system_proxy_; } + bool fleasion_enabled() const { return fleasion_enabled_; } + bool fleasion_valid() const { return fleasion_valid_; } + const std::string& fleasion_proxy_mode() const { return fleasion_proxy_mode_; } + int fleasion_proxy_port() const { return fleasion_proxy_port_; } + const std::optional& fleasion_ca_certificate() const { + return fleasion_ca_certificate_; + } const DiscordRpcConfig& discord_rpc() const { return discord_rpc_; } bool discord_rpc_valid() const { return discord_rpc_valid_; } @@ -152,6 +159,11 @@ class RuntimeConfig { std::string audio_input_device_ = "default"; bool audio_input_device_valid_ = true; bool use_system_proxy_ = false; + bool fleasion_enabled_ = false; + bool fleasion_valid_ = true; + std::string fleasion_proxy_mode_ = "env"; + int fleasion_proxy_port_ = 58443; + std::optional fleasion_ca_certificate_; std::optional network_proxy_; std::optional ca_bundle_; bool ca_bundle_valid_ = true; diff --git a/rbx_bin/.gitkeep b/rbx_bin/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/libc_shim/libc_shim.cc b/src/libc_shim/libc_shim.cc index ffca00f..eca2018 100644 --- a/src/libc_shim/libc_shim.cc +++ b/src/libc_shim/libc_shim.cc @@ -8,16 +8,19 @@ #include #include #include +#include #include #include #include #include +#include namespace libc_shim { namespace { std::unordered_map g_path_mappings; +std::vector g_ca_bundle_aliases; bool g_installed = false; std::atomic g_guest_allocator{nullptr}; @@ -27,9 +30,15 @@ constexpr std::array kDefaultCaBundles = { "/etc/ssl/cert.pem", "/etc/ssl/certs/ca-certificates.crt", }; -constexpr std::array kAndroidCaBundlePaths = { +constexpr std::array kAndroidCaBundlePaths = { "/data/user/0/com.roblox.client/files/exe/cacert.pem", "/data/data/com.roblox.client/files/exe/cacert.pem", + "/data/user/0/com.roblox.client/files/exe/ssl/cacert.pem", + "/data/data/com.roblox.client/files/exe/ssl/cacert.pem", + "ssl/cacert.pem", + "content/ssl/cacert.pem", + "rbx_bin/assets/ssl/cacert.pem", + "rbx_bin/assets/content/ssl/cacert.pem", }; bool IsEnabled(const char* name) { @@ -192,17 +201,22 @@ HostCaBundleResolution ResolveHostCaBundle() { } HostCaBundleResolution ConfigureHostCaBundlePathMappings() { - for (const char* android_path : kAndroidCaBundlePaths) { - g_path_mappings.erase(android_path); + for (const auto& alias : g_ca_bundle_aliases) g_path_mappings.erase(alias); + g_ca_bundle_aliases.assign(kAndroidCaBundlePaths.begin(), kAndroidCaBundlePaths.end()); + if (const char* content = GetEnvNonEmpty("MOCKTAIL_ASSET_PATH")) { + auto root = std::filesystem::path(content).lexically_normal(); + if (root.filename().empty()) root = root.parent_path(); + g_ca_bundle_aliases.push_back((root / "ssl/cacert.pem").string()); + if (root.filename() == "content") + g_ca_bundle_aliases.push_back((root.parent_path() / "ssl/cacert.pem").string()); } HostCaBundleResolution resolution = ResolveHostCaBundle(); if (!resolution.ok()) { return resolution; } - for (const char* android_path : kAndroidCaBundlePaths) { - RegisterPathMapping(android_path, resolution.host_path); - } + for (const auto& alias : g_ca_bundle_aliases) + RegisterPathMapping(alias, resolution.host_path); return resolution; } @@ -308,6 +322,7 @@ void RegisterPathMapping(const std::string& android_prefix, void ClearPathMappings() { g_path_mappings.clear(); + g_ca_bundle_aliases.clear(); } } // namespace libc_shim diff --git a/src/main.cc b/src/main.cc index 4b6247e..80d13dd 100644 --- a/src/main.cc +++ b/src/main.cc @@ -29,6 +29,7 @@ #include "runtime/environment.h" #include "runtime/external_launch_broker.h" #include "runtime/failure_dialog.h" +#include "runtime/fleasion.h" #include "runtime/game_mode.h" #include "runtime/graphics_launch_policy.h" #include "runtime/memory_limit.h" @@ -469,6 +470,38 @@ int main(int argc, char* argv[]) { mocktail::runtime::LogProcessDiagnostics( mocktail::runtime::ProcessDiagnosticStage::kStartup); } + if (runtime_config.config.fleasion_enabled()) { + const auto fleasion = mocktail::runtime::PrepareFleasion( + runtime_config.config, environment, paths); + if (!fleasion || + setenv("MOCKTAIL_CA_BUNDLE", fleasion.bundle.c_str(), 1) != 0 || + setenv("MOCKTAIL_FLEASION_BASE_CA_BUNDLE", fleasion.base_bundle.c_str(), 1) != 0 || + setenv("MOCKTAIL_FLEASION_GENERATED_BUNDLE", fleasion.bundle.c_str(), 1) != 0 || + setenv("MOCKTAIL_FLEASION_CA_CERTIFICATE", fleasion.certificate.c_str(), 1) != 0) { + std::cerr << "[FATAL] Cannot prepare Fleasion: " + << (!fleasion ? fleasion.error : "cannot export certificate paths") << '\n'; + return EXIT_FAILURE; + } + if (const auto& proxy = runtime_config.config.network_proxy(); proxy && + (setenv("MOCKTAIL_HTTP_PROXY_HOST", proxy->host.c_str(), 1) != 0 || + setenv("MOCKTAIL_HTTP_PROXY_PORT", std::to_string(proxy->port).c_str(), 1) != 0 || + setenv("MOCKTAIL_HTTP_PROXY_SCHEME", proxy->scheme.c_str(), 1) != 0)) { + std::cerr << "[FATAL] Cannot export Fleasion proxy\n"; + return EXIT_FAILURE; + } + runtime_config = mocktail::runtime::LoadRuntimeConfig(environment, paths.config_file()); + if (!runtime_config) { + std::cerr << "[FATAL] Cannot apply Fleasion: " << runtime_config.error << '\n'; + return EXIT_FAILURE; + } + std::cout << " [fleasion] mode=" << runtime_config.config.fleasion_proxy_mode() + << " certificate=" << fleasion.certificate + << " trust_bundle=" << fleasion.bundle << '\n'; + if (runtime_config.config.network_proxy()) { + std::cout << " [fleasion] proxy=" << mocktail::runtime::BuildNetworkProxyUrl( + *runtime_config.config.network_proxy()) << "; start Fleasion before Roblox\n"; + } + } if (config_bootstrap.created()) { std::cout << " [runtime] created first-run configuration: " << paths.config_file() << '\n'; diff --git a/src/runtime/discord_rpc.cc b/src/runtime/discord_rpc.cc index 72534a3..ba4812d 100644 --- a/src/runtime/discord_rpc.cc +++ b/src/runtime/discord_rpc.cc @@ -422,8 +422,12 @@ std::optional GetRobloxJson(std::string url) { if (proxy.has_value()) { proxy_url = BuildNetworkProxyUrl(*proxy); curl_easy_setopt(curl, CURLOPT_PROXY, proxy_url.c_str()); + curl_easy_setopt(curl, CURLOPT_NOPROXY, ""); } } + if (const char* ca_bundle = std::getenv("MOCKTAIL_CA_BUNDLE")) { + curl_easy_setopt(curl, CURLOPT_CAINFO, ca_bundle); + } const CURLcode status = curl_easy_perform(curl); long http_status = 0; if (status == CURLE_OK) { diff --git a/src/runtime/fleasion.cc b/src/runtime/fleasion.cc new file mode 100644 index 0000000..ff007f0 --- /dev/null +++ b/src/runtime/fleasion.cc @@ -0,0 +1,151 @@ +#include "runtime/fleasion.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace mocktail::runtime { +namespace { + +using Bio = std::unique_ptr; +using Certificate = std::unique_ptr; + +bool ReadFile(const std::filesystem::path& path, std::string* contents) { + std::error_code error; + if (!path.is_absolute() || !std::filesystem::is_regular_file(path, error)) + return false; + const auto size = std::filesystem::file_size(path, error); + if (error || size == 0 || size > 4 * 1024 * 1024) return false; + std::ifstream input(path, std::ios::binary); + contents->resize(static_cast(size)); + return static_cast(input.read(contents->data(), contents->size())); +} + +std::filesystem::path DefaultCertificate(const Environment& environment, + const RuntimePaths& paths) { + std::filesystem::path root = environment.GetOr("XDG_CONFIG_HOME", ""); + if (!root.is_absolute()) root = paths.home() / ".config"; + return root / "Fleasion/proxy_ca/ca.crt"; +} + +bool WriteBundle(const std::filesystem::path& destination, + const char* contents, std::size_t size) { + std::error_code error; + std::filesystem::create_directories(destination.parent_path(), error); + if (error || std::filesystem::is_symlink(destination.parent_path(), error)) + return false; + std::string temporary = destination.string() + ".XXXXXX"; + const int descriptor = mkstemp(temporary.data()); + if (descriptor < 0) return false; + std::size_t offset = 0; + while (offset < size) { + const ssize_t count = write(descriptor, contents + offset, size - offset); + if (count < 0 && errno == EINTR) continue; + if (count <= 0) break; + offset += static_cast(count); + } + bool ok = offset == size && fsync(descriptor) == 0; + if (close(descriptor) != 0) ok = false; + if (ok) ok = rename(temporary.c_str(), destination.c_str()) == 0; + if (!ok) unlink(temporary.c_str()); + return ok; +} + +} // namespace + +FleasionPreparation PrepareFleasion(const RuntimeConfig& config, + const Environment& environment, + const RuntimePaths& paths) { + FleasionPreparation result; + if (!config.fleasion_enabled()) return result; + if (!config.fleasion_valid()) { + result.error = "invalid Fleasion configuration"; + return result; + } + result.certificate = config.fleasion_ca_certificate().value_or( + DefaultCertificate(environment, paths)); + result.bundle = paths.cache_root() / "fleasion/cacert.pem"; + result.base_bundle = config.ca_bundle().value_or(std::filesystem::path{}); + // Re-exec and updater canaries inherit the generated bundle. Rebuild from + // the original roots so a rotated Fleasion CA does not accumulate forever. + if (result.base_bundle == result.bundle || + result.base_bundle == environment.GetOr("MOCKTAIL_FLEASION_GENERATED_BUNDLE", "")) { + result.base_bundle = environment.GetOr("MOCKTAIL_FLEASION_BASE_CA_BUNDLE", ""); + } + if (result.base_bundle.empty()) { + for (const char* candidate : {"/etc/ssl/cert.pem", "/etc/ssl/certs/ca-certificates.crt"}) { + std::error_code error; + if (std::filesystem::is_regular_file(candidate, error)) { + result.base_bundle = candidate; + break; + } + } + } + std::error_code equivalent_error; + const bool certificate_is_output = std::filesystem::equivalent( + result.certificate, result.bundle, equivalent_error); + equivalent_error.clear(); + const bool base_is_output = std::filesystem::equivalent( + result.base_bundle, result.bundle, equivalent_error); + if (result.certificate == result.bundle || result.base_bundle == result.bundle || + certificate_is_output || base_is_output) { + result.error = "Fleasion source certificates must be outside its generated bundle"; + return result; + } + std::string ca_pem; + if (!ReadFile(result.certificate, &ca_pem)) { + result.error = "cannot read Fleasion CA certificate at " + result.certificate.string() + + "; start Fleasion first or set integrations.fleasion.ca_certificate"; + return result; + } + Bio ca_input(BIO_new_mem_buf(ca_pem.data(), static_cast(ca_pem.size())), BIO_free); + Certificate ca(ca_input ? PEM_read_bio_X509(ca_input.get(), nullptr, nullptr, nullptr) + : nullptr, X509_free); + if (!ca || X509_check_ca(ca.get()) <= 0 || + X509_cmp_current_time(X509_get0_notBefore(ca.get())) >= 0 || + X509_cmp_current_time(X509_get0_notAfter(ca.get())) <= 0) { + ERR_clear_error(); + result.error = "Fleasion CA must be a currently valid PEM CA certificate"; + return result; + } + std::string roots; + if (!ReadFile(result.base_bundle, &roots)) { + result.error = "cannot read base CA bundle for Fleasion: " + result.base_bundle.string(); + return result; + } + Bio input(BIO_new_mem_buf(roots.data(), static_cast(roots.size())), BIO_free); + Bio output(BIO_new(BIO_s_mem()), BIO_free); + if (!input || !output) { + result.error = "cannot allocate Fleasion certificate bundle"; + return result; + } + int count = 0; + for (;;) { + Certificate root(PEM_read_bio_X509(input.get(), nullptr, nullptr, nullptr), X509_free); + if (!root) break; + ++count; + if (X509_cmp(root.get(), ca.get()) != 0 && + PEM_write_bio_X509(output.get(), root.get()) != 1) { + result.error = "cannot encode base CA certificate"; + return result; + } + } + ERR_clear_error(); + if (count == 0 || PEM_write_bio_X509(output.get(), ca.get()) != 1) { + result.error = "base bundle must contain PEM certificates"; + return result; + } + char* bytes = nullptr; + const long size = BIO_get_mem_data(output.get(), &bytes); + if (size <= 0 || !WriteBundle(result.bundle, bytes, static_cast(size))) + result.error = "cannot write generated Fleasion CA bundle: " + result.bundle.string(); + return result; +} + +} // namespace mocktail::runtime diff --git a/src/runtime/runtime_config.cc b/src/runtime/runtime_config.cc index f3e9d9a..f3ea995 100644 --- a/src/runtime/runtime_config.cc +++ b/src/runtime/runtime_config.cc @@ -287,6 +287,35 @@ RuntimeConfig RuntimeConfig::FromEnvironment(const Environment& environment) { config.use_system_proxy_ = LegacyEnabled(environment, "MOCKTAIL_USE_SYSTEM_PROXY"); config.network_proxy_ = ReadNetworkProxy(environment); + config.fleasion_enabled_ = ReadBoolean( + environment, "MOCKTAIL_FLEASION_ENABLED", false, &config.fleasion_valid_); + config.fleasion_proxy_mode_ = environment.GetOr("MOCKTAIL_FLEASION_PROXY_MODE", "env"); + const auto fleasion_proxy = ParseNetworkProxyConfig( + "127.0.0.1", environment.GetOr("MOCKTAIL_FLEASION_PROXY_PORT", "58443")); + if (!fleasion_proxy || (config.fleasion_proxy_mode_ != "env" && + config.fleasion_proxy_mode_ != "hosts")) { + config.fleasion_valid_ = false; + } else { + config.fleasion_proxy_port_ = fleasion_proxy->port; + } + if (const auto certificate = environment.Get("MOCKTAIL_FLEASION_CA_CERTIFICATE")) { + config.fleasion_ca_certificate_ = *certificate; + if (certificate->empty() || !config.fleasion_ca_certificate_->is_absolute()) + config.fleasion_valid_ = false; + } + if (config.fleasion_enabled_) { + if (config.use_system_proxy_) config.fleasion_valid_ = false; + if (config.fleasion_proxy_mode_ == "env" && fleasion_proxy) { + if (config.network_proxy_ && + (config.network_proxy_->host != fleasion_proxy->host || + config.network_proxy_->port != fleasion_proxy->port || + config.network_proxy_->scheme != "http")) + config.fleasion_valid_ = false; + config.network_proxy_ = fleasion_proxy; + } else if (config.network_proxy_) { + config.fleasion_valid_ = false; + } + } if (const std::optional ca_bundle = environment.Get("MOCKTAIL_CA_BUNDLE"); ca_bundle.has_value()) { diff --git a/src/runtime/runtime_config_bootstrap.cc b/src/runtime/runtime_config_bootstrap.cc index f0d5386..7cfc4aa 100644 --- a/src/runtime/runtime_config_bootstrap.cc +++ b/src/runtime/runtime_config_bootstrap.cc @@ -93,6 +93,16 @@ audio: input_device: default integrations: + fleasion: + # Boolean (default: false): trust Fleasion's CA without editing Roblox files. + enabled: false + # String: match Fleasion's routing mode: env or hosts. + proxy_mode: env + # Integer: Fleasion's local HTTP proxy port in env mode. + proxy_port: 58443 + # Optional absolute public CA path. Default: $XDG_CONFIG_HOME/Fleasion/proxy_ca/ca.crt + # or ~/.config/Fleasion/proxy_ca/ca.crt. Never select ca.key. + # ca_certificate: /home/user/.config/Fleasion/proxy_ca/ca.crt discord_rpc: # Boolean (default: false): publish Mocktail activity to Discord Desktop. # This never signs in to Discord and never reads an account token. diff --git a/src/runtime/runtime_config_file.cc b/src/runtime/runtime_config_file.cc index faee55b..dd30c4e 100644 --- a/src/runtime/runtime_config_file.cc +++ b/src/runtime/runtime_config_file.cc @@ -188,6 +188,10 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment, "network.proxy_host", "network.proxy_port", "network.ca_bundle", + "integrations.fleasion.enabled", + "integrations.fleasion.proxy_mode", + "integrations.fleasion.proxy_port", + "integrations.fleasion.ca_certificate", "integrations.discord_rpc.enabled", "integrations.discord_rpc.show_place_name", "integrations.discord_rpc.show_elapsed_time", @@ -483,6 +487,7 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment, std::pair( "integrations.discord_rpc.enabled", "MOCKTAIL_DISCORD_RPC_ENABLED"), + {"integrations.fleasion.enabled", "MOCKTAIL_FLEASION_ENABLED"}, {"integrations.discord_rpc.show_place_name", "MOCKTAIL_DISCORD_RPC_SHOW_PLACE_NAME"}, {"integrations.discord_rpc.show_elapsed_time", @@ -503,6 +508,15 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment, } (*environment)[std::string(variable)] = parsed ? "1" : "0"; } + for (const auto& [key, variable] : { + std::pair( + "integrations.fleasion.proxy_mode", "MOCKTAIL_FLEASION_PROXY_MODE"), + {"integrations.fleasion.proxy_port", "MOCKTAIL_FLEASION_PROXY_PORT"}, + {"integrations.fleasion.ca_certificate", "MOCKTAIL_FLEASION_CA_CERTIFICATE"}, + }) { + if (const auto configured = value(key)) + (*environment)[std::string(variable)] = *configured; + } if (const auto application_id = value("integrations.discord_rpc.application_id"); application_id.has_value()) { @@ -751,6 +765,10 @@ RuntimeConfigLoadResult LoadRuntimeConfig( result.error = "GameMode policy is invalid"; } else if (!result.config.performance().physics_worker_mode_valid) { result.error = "physics worker policy is invalid"; + } else if (!result.config.fleasion_valid()) { + result.error = "Fleasion configuration is invalid: use proxy_mode env or hosts, " + "a port from 1 to 65535, an absolute CA certificate path, and " + "no conflicting system/fixed proxy"; } else if (!result.config.ca_bundle_valid()) { result.error = "CA bundle path is invalid"; } else if (!result.config.discord_rpc_valid()) { @@ -761,6 +779,10 @@ RuntimeConfigLoadResult LoadRuntimeConfig( bool ExportRuntimeConfigEnvironment(const RuntimeConfig& config, std::string* error) { + if (!config.fleasion_valid()) { + if (error != nullptr) *error = "cannot export invalid Fleasion configuration"; + return false; + } if (!config.device_profile_valid()) { if (error != nullptr) { *error = "cannot export an invalid device profile"; @@ -910,6 +932,20 @@ bool ExportRuntimeConfigEnvironment(const RuntimeConfig& config, if (!base_exported) { return false; } + if (!SetEnvironmentValue("MOCKTAIL_FLEASION_ENABLED", + config.fleasion_enabled() ? "1" : "0", error) || + !SetEnvironmentValue("MOCKTAIL_FLEASION_PROXY_MODE", + config.fleasion_proxy_mode(), error) || + !SetEnvironmentValue("MOCKTAIL_FLEASION_PROXY_PORT", + std::to_string(config.fleasion_proxy_port()), error)) + return false; + if (config.fleasion_ca_certificate()) { + if (!SetEnvironmentValue("MOCKTAIL_FLEASION_CA_CERTIFICATE", + config.fleasion_ca_certificate()->string(), error)) + return false; + } else if (!UnsetEnvironmentValue("MOCKTAIL_FLEASION_CA_CERTIFICATE", error)) { + return false; + } if (config.ca_bundle().has_value()) { if (!SetEnvironmentValue("MOCKTAIL_CA_BUNDLE", config.ca_bundle()->string(), error)) { diff --git a/src/services/http_client.cc b/src/services/http_client.cc index 6088ace..713965a 100644 --- a/src/services/http_client.cc +++ b/src/services/http_client.cc @@ -118,6 +118,9 @@ HttpResponse PerformRequest(const HttpRequest& request, curl_easy_setopt(handle, CURLOPT_WRITEDATA, &writer); curl_easy_setopt(handle, CURLOPT_HEADERFUNCTION, WriteHeader); curl_easy_setopt(handle, CURLOPT_HEADERDATA, &response.headers); + if (const char* ca_bundle = std::getenv("MOCKTAIL_CA_BUNDLE")) { + curl_easy_setopt(handle, CURLOPT_CAINFO, ca_bundle); + } std::string proxy_url; const char* proxy_host = std::getenv("MOCKTAIL_HTTP_PROXY_HOST"); const char* proxy_port = std::getenv("MOCKTAIL_HTTP_PROXY_PORT"); @@ -130,6 +133,9 @@ HttpResponse PerformRequest(const HttpRequest& request, if (proxy.has_value()) { proxy_url = runtime::BuildNetworkProxyUrl(*proxy); curl_easy_setopt(handle, CURLOPT_PROXY, proxy_url.c_str()); + // A configured application proxy must not be bypassed by an inherited + // NO_PROXY entry, including '*' from a desktop launcher. + curl_easy_setopt(handle, CURLOPT_NOPROXY, ""); } } if (headers != nullptr) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6420d3b..49b7a4e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,6 +16,18 @@ target_link_libraries(runtime_config_test PRIVATE GTest::gtest_main ) +add_executable(fleasion_probe fleasion_probe.cc) +target_link_libraries(fleasion_probe PRIVATE Mocktail::Services libc_shim) +mocktail_apply_compile_options(fleasion_probe) +find_program(MOCKTAIL_TEST_PYTHON python3) +find_program(MOCKTAIL_TEST_OPENSSL openssl) +if(MOCKTAIL_TEST_PYTHON AND MOCKTAIL_TEST_OPENSSL) + add_test(NAME FleasionIntegrationTest.ProxyAndTrust + COMMAND ${MOCKTAIL_TEST_PYTHON} ${CMAKE_CURRENT_SOURCE_DIR}/fleasion_integration_test.py + $ ${MOCKTAIL_TEST_OPENSSL}) + set_tests_properties(FleasionIntegrationTest.ProxyAndTrust PROPERTIES TIMEOUT 45) +endif() + add_executable(runtime_config_file_test runtime_config_file_test.cc) target_link_libraries(runtime_config_file_test PRIVATE Mocktail::Runtime diff --git a/tests/fleasion_integration_test.py b/tests/fleasion_integration_test.py new file mode 100644 index 0000000..e235d0b --- /dev/null +++ b/tests/fleasion_integration_test.py @@ -0,0 +1,141 @@ +"""Exercise real CA composition, native path mapping, and HTTPS CONNECT routing.""" +import os +from pathlib import Path +import socket +import ssl +import subprocess +import sys +import tempfile +import threading + + +probe, openssl = sys.argv[1:] + + +def command(*args): + subprocess.run(args, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + + +with tempfile.TemporaryDirectory(prefix="mocktail-fleasion-test-") as directory: + root = Path(directory) + for name in ("public", "fleasion", "rotated"): + command(openssl, "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "2", + "-subj", f"/CN={name}", "-addext", "basicConstraints=critical,CA:TRUE", + "-keyout", str(root / f"{name}.key"), "-out", str(root / f"{name}.crt")) + command(openssl, "req", "-newkey", "rsa:2048", "-nodes", "-subj", + "/CN=assetdelivery.roblox.com", "-keyout", str(root / "leaf.key"), + "-out", str(root / "leaf.csr")) + (root / "leaf.ext").write_text("subjectAltName=DNS:assetdelivery.roblox.com\nbasicConstraints=CA:FALSE\n") + command(openssl, "x509", "-req", "-in", str(root / "leaf.csr"), "-CA", + str(root / "fleasion.crt"), "-CAkey", str(root / "fleasion.key"), + "-CAcreateserial", "-days", "1", "-extfile", str(root / "leaf.ext"), + "-out", str(root / "leaf.crt")) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(root / "leaf.crt", root / "leaf.key") + intercepted = [] + failures = [] + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + listener.listen() + listener.settimeout(5) + port = listener.getsockname()[1] + + def serve(): + try: + # Correct CA succeeds; the unrelated CA must fail TLS verification. + for attempt in range(2): + connection, _ = listener.accept() + with connection: + connection.settimeout(5) + connect = b"" + while not connect.endswith(b"\r\n\r\n"): + data = connection.recv(4096) + if not data: + raise AssertionError("proxy connection closed before CONNECT") + connect += data + assert connect.startswith(b"CONNECT assetdelivery.roblox.com:443 "), connect + connection.sendall(b"HTTP/1.1 200 Connection established\r\n\r\n") + try: + with context.wrap_socket(connection, server_side=True) as tls: + request = tls.recv(8192) + assert request.startswith(b"GET /replacement "), request + intercepted.append(request) + body = b"fleasion-replaced-asset" + tls.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: " + + str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body) + except (ssl.SSLError, ConnectionError): + if attempt == 0: + raise + # The wrong CA may close with either a TLS alert or + # a TCP reset before the server finishes its handshake. + except BaseException as error: + failures.append(error) + + thread = threading.Thread(target=serve, daemon=True) + thread.start() + env = {key: value for key, value in os.environ.items() + if not key.startswith(("MOCKTAIL_", "ROBLOX_"))} + for key in ("http_proxy", "https_proxy", "all_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"): + env.pop(key, None) + env.update(HOME=str(root), XDG_CONFIG_HOME=str(root / "config"), + MOCKTAIL_CACHE_ROOT=str(root / "cache"), NO_PROXY="*", no_proxy="*") + certificate = root / "config/Fleasion/proxy_ca/ca.crt" + certificate.parent.mkdir(parents=True) + original = (root / "fleasion.crt").read_bytes() + certificate.write_bytes(original) + config = root / "config.yaml" + + def configure(extra="", enabled="true", mode="env"): + config.write_text(f"version: 1\nnetwork:\n ca_bundle: {root / 'public.crt'}\n" + f"integrations:\n fleasion:\n enabled: {enabled}\n" + f" proxy_mode: {mode}\n proxy_port: {port}\n{extra}") + + def run(url="prepare", success=True, environment=None): + result = subprocess.run([probe, str(config), url], env=environment or env, + capture_output=True, text=True, timeout=10) + assert (result.returncode == 0) == success, result.stdout + result.stderr + return result + + configure() + response = run("https://assetdelivery.roblox.com/replacement") + assert "fleasion-replaced-asset" in response.stdout + assert certificate.read_bytes() == original + generated = root / "cache/fleasion/cacert.pem" + assert generated.stat().st_mode & 0o777 == 0o600 + command(openssl, "verify", "-CAfile", str(generated), str(root / "public.crt")) + first_bundle = generated.read_bytes() + run() # Repeated startup must not duplicate trust roots. + assert generated.read_bytes() == first_bundle + certificate.write_bytes((root / "rotated.crt").read_bytes()) + response = run("https://assetdelivery.roblox.com/replacement", success=False) + assert response.returncode == 10, response.stderr + thread.join(timeout=7) + listener.close() + assert not thread.is_alive() and not failures, failures + assert len(intercepted) == 1 + assert original not in generated.read_bytes() + command(openssl, "verify", "-CAfile", str(generated), str(root / "rotated.crt")) + # Re-exec must compose from the original public roots, not old proxy CAs. + inherited = dict(env, MOCKTAIL_CA_BUNDLE=str(generated), + MOCKTAIL_FLEASION_GENERATED_BUNDLE=str(generated), + MOCKTAIL_FLEASION_BASE_CA_BUNDLE=str(root / "public.crt")) + certificate.write_bytes(original) + run(environment=inherited) + assert generated.read_bytes() == first_bundle + for bad in (b"not a certificate", (root / "leaf.crt").read_bytes()): + certificate.write_bytes(bad) + run(success=False) + assert generated.read_bytes() == first_bundle + certificate.unlink() + run(success=False) + configure(enabled="false") + run() # Disabled integration does not require Fleasion. + certificate.write_bytes(original) + configure(mode="hosts") + run() + configure(mode="invalid") + run(success=False) + configure(extra=" ca_certificate: relative.crt\n") + run(success=False) + print("Fleasion integration: proxy replacement, TLS rejection, CA rotation, " + "native aliases, and configuration checks passed") diff --git a/tests/fleasion_probe.cc b/tests/fleasion_probe.cc new file mode 100644 index 0000000..021dc54 --- /dev/null +++ b/tests/fleasion_probe.cc @@ -0,0 +1,50 @@ +#include "runtime/fleasion.h" +#include "runtime/runtime_config_file.h" +#include "services/http_client.h" +#include "libc_shim/libc_shim.h" + +#include +#include + +int main(int argc, char** argv) { + if (argc != 3) return 2; + const mocktail::runtime::ProcessEnvironment environment; + const auto paths = mocktail::runtime::RuntimePaths::FromEnvironment(environment); + const auto loaded = mocktail::runtime::LoadRuntimeConfig(environment, argv[1]); + if (!loaded) { + std::cerr << loaded.error << '\n'; + return 3; + } + const auto prepared = mocktail::runtime::PrepareFleasion(loaded.config, environment, paths); + if (!prepared) { + std::cerr << prepared.error << '\n'; + return 4; + } + std::string error; + if (!mocktail::runtime::ExportRuntimeConfigEnvironment(loaded.config, &error)) return 5; + if (!prepared.bundle.empty()) { + if (setenv("MOCKTAIL_CA_BUNDLE", prepared.bundle.c_str(), 1) != 0) return 6; + const auto mapped = libc_shim::ConfigureHostCaBundlePathMappings(); + if (!mapped.ok()) return 7; + FILE* certificate = mocktail_fopen("ssl/cacert.pem", "r"); + if (certificate == nullptr) return 8; + const int first = fgetc(certificate); + fclose(certificate); + if (first != '-') return 9; + } + if (std::string(argv[2]) == "prepare") { + std::cout << prepared.bundle.string() << '\n'; + return 0; + } + mocktail::services::CurlHttpClient client; + mocktail::services::HttpRequest request; + request.url = argv[2]; + request.timeout_ms = 3000; + const auto response = client.Get(request); + if (!response.transport_ok) { + std::cerr << response.error << '\n'; + return 10; + } + std::cout << response.status_code << '\n' << response.body; + return response.status_code == 200 ? 0 : 11; +} diff --git a/tests/libc_shim_test.cc b/tests/libc_shim_test.cc index 8094f73..4c25191 100644 --- a/tests/libc_shim_test.cc +++ b/tests/libc_shim_test.cc @@ -141,6 +141,7 @@ TEST_F(LibcShimTest, ValidCaBundleOverrideMapsAndroidAliases) { static_cast(sizeof(kTestCertificate) - 1)); ASSERT_EQ(close(fd), 0); ASSERT_EQ(setenv("MOCKTAIL_CA_BUNDLE", temp_template, 1), 0); + ASSERT_EQ(setenv("MOCKTAIL_ASSET_PATH", "/payload/assets/content/", 1), 0); RegisterPathMapping("/data/user/0/com.roblox.client", "/host/sober"); const HostCaBundleResolution resolution = @@ -157,6 +158,14 @@ TEST_F(LibcShimTest, ValidCaBundleOverrideMapsAndroidAliases) { temp_template); EXPECT_EQ(TranslatePath("/data/user/0/com.roblox.client/files/other"), "/host/sober/files/other"); + for (const char* alias : { + "ssl/cacert.pem", "content/ssl/cacert.pem", + "rbx_bin/assets/ssl/cacert.pem", "rbx_bin/assets/content/ssl/cacert.pem", + "/payload/assets/ssl/cacert.pem", "/payload/assets/content/ssl/cacert.pem", + "/data/user/0/com.roblox.client/files/exe/ssl/cacert.pem", + "/data/data/com.roblox.client/files/exe/ssl/cacert.pem"}) { + EXPECT_EQ(TranslatePath(alias), temp_template); + } FILE* mapped_file = mocktail_fopen( "/data/user/0/com.roblox.client/files/exe/cacert.pem", "r"); ASSERT_NE(mapped_file, nullptr); diff --git a/tests/runtime_config_file_test.cc b/tests/runtime_config_file_test.cc index cb4b555..549aa5d 100644 --- a/tests/runtime_config_file_test.cc +++ b/tests/runtime_config_file_test.cc @@ -1040,6 +1040,54 @@ TEST(RuntimeConfigFileTest, RejectsMalformedYaml) { EXPECT_NE(loaded.error.find("invalid YAML"), std::string::npos); } +TEST(RuntimeConfigFileTest, FleasionRoutesThroughConfiguredLoopbackPort) { + TemporaryDirectory temporary; + const auto file = temporary.Write( + "integrations:\n fleasion:\n enabled: true\n proxy_mode: env\n" + " proxy_port: 59443\n ca_certificate: /tmp/fleasion-ca.crt\n"); + auto loaded = LoadRuntimeConfig(MapEnvironment(), file); + ASSERT_TRUE(loaded) << loaded.error; + EXPECT_TRUE(loaded.config.fleasion_enabled()); + ASSERT_TRUE(loaded.config.network_proxy()); + EXPECT_EQ(loaded.config.network_proxy()->host, "127.0.0.1"); + EXPECT_EQ(loaded.config.network_proxy()->port, 59443); + EXPECT_EQ(*loaded.config.fleasion_ca_certificate(), "/tmp/fleasion-ca.crt"); + loaded = LoadRuntimeConfig(MapEnvironment({{"MOCKTAIL_FLEASION_ENABLED", "0"}}), file); + ASSERT_TRUE(loaded) << loaded.error; + EXPECT_FALSE(loaded.config.fleasion_enabled()); + EXPECT_FALSE(loaded.config.network_proxy()); +} + +TEST(RuntimeConfigFileTest, FleasionRejectsInvalidConfigurationAndProxyConflicts) { + TemporaryDirectory temporary; + for (const char* setting : {"proxy_port: 0", "proxy_port: 65536", "proxy_port: 123x", + "proxy_mode: invalid", "ca_certificate: relative.pem"}) { + const auto file = temporary.Write(std::string( + "integrations:\n fleasion:\n enabled: true\n ") + setting + "\n"); + EXPECT_FALSE(LoadRuntimeConfig(MapEnvironment(), file)) << setting; + } + const auto file = temporary.Write("integrations:\n fleasion:\n enabled: true\n"); + EXPECT_FALSE(LoadRuntimeConfig(MapEnvironment({{"MOCKTAIL_USE_SYSTEM_PROXY", "1"}}), file)); + EXPECT_FALSE(LoadRuntimeConfig(MapEnvironment({{"MOCKTAIL_HTTP_PROXY_HOST", "other"}, + {"MOCKTAIL_HTTP_PROXY_PORT", "3128"}}), file)); +} + +TEST(RuntimeConfigFileTest, FleasionEnvironmentRoundTripsAndHostsModeDoesNotSetProxy) { + TemporaryDirectory temporary; + const auto file = temporary.Write("integrations:\n fleasion:\n enabled: true\n" + " proxy_mode: hosts\n"); + const auto loaded = LoadRuntimeConfig(MapEnvironment(), file); + ASSERT_TRUE(loaded) << loaded.error; + EXPECT_FALSE(loaded.config.network_proxy()); + std::string error; + ASSERT_TRUE(ExportRuntimeConfigEnvironment(loaded.config, &error)) << error; + const auto resolved = RuntimeConfig::FromEnvironment(ProcessEnvironment()); + EXPECT_TRUE(resolved.fleasion_enabled()); + EXPECT_TRUE(resolved.fleasion_valid()); + EXPECT_EQ(resolved.fleasion_proxy_mode(), "hosts"); + ASSERT_TRUE(ExportRuntimeConfigEnvironment(RuntimeConfig::FromEnvironment(MapEnvironment()), &error)); +} + } // namespace } // namespace runtime } // namespace mocktail diff --git a/tests/runtime_config_test.cc b/tests/runtime_config_test.cc index 357f224..d349f96 100644 --- a/tests/runtime_config_test.cc +++ b/tests/runtime_config_test.cc @@ -71,6 +71,8 @@ TEST(RuntimeConfigTest, UsesSupportedDefaults) { EXPECT_TRUE(config.audio_input_device_valid()); EXPECT_TRUE(config.microphone_enabled()); EXPECT_FALSE(config.use_system_proxy()); + EXPECT_FALSE(config.fleasion_enabled()); + EXPECT_TRUE(config.fleasion_valid()); EXPECT_FALSE(config.network_proxy().has_value()); EXPECT_FALSE(config.discord_rpc().enabled); EXPECT_TRUE(config.discord_rpc().show_place_name);