diff --git a/src/app/ClioApplication.cpp b/src/app/ClioApplication.cpp index 2cd4803ee..603b90629 100644 --- a/src/app/ClioApplication.cpp +++ b/src/app/ClioApplication.cpp @@ -40,6 +40,7 @@ #include "util/build/Build.hpp" #include "util/config/ConfigDefinition.hpp" #include "util/log/Logger.hpp" +#include "util/requests/SslContext.hpp" #include "web/AdminVerificationStrategy.hpp" #include "web/RPCServerHandler.hpp" #include "web/Server.hpp" @@ -97,6 +98,11 @@ ClioApplication::ClioApplication(util::config::ClioConfigDefinition const& confi int ClioApplication::run(bool const useNgWebServer) { + if (auto const sslContext = util::requests::initClientSslContext(); not sslContext.has_value()) { + LOG(util::LogService::fatal()) << "Failed to create client SSL context: " << sslContext.error(); + return EXIT_FAILURE; + } + auto const threads = config_.get("io_threads"); LOG(util::LogService::info()) << "Number of io threads = " << threads; diff --git a/src/util/CMakeLists.txt b/src/util/CMakeLists.txt index 323fff23e..1c5f462cf 100644 --- a/src/util/CMakeLists.txt +++ b/src/util/CMakeLists.txt @@ -20,6 +20,7 @@ target_sources( Retry.cpp Repeat.cpp requests/RequestBuilder.cpp + requests/SslContext.cpp requests/Types.cpp requests/WsConnection.cpp requests/impl/SslContext.cpp diff --git a/src/util/requests/RequestBuilder.cpp b/src/util/requests/RequestBuilder.cpp index a8b8faf0a..24ce41af7 100644 --- a/src/util/requests/RequestBuilder.cpp +++ b/src/util/requests/RequestBuilder.cpp @@ -136,18 +136,16 @@ std::expected RequestBuilder::doSslRequest(asio::yield_context yield, beast::http::verb method) { auto streamData = impl::SslTcpStreamData::create(yield); - if (not streamData.has_value()) - return std::unexpected{std::move(streamData).error()}; #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wold-style-cast" - if (!SSL_set_tlsext_host_name(streamData->stream.native_handle(), host_.c_str())) { + if (!SSL_set_tlsext_host_name(streamData.stream.native_handle(), host_.c_str())) { #pragma GCC diagnostic pop beast::error_code errorCode; errorCode.assign(static_cast(::ERR_get_error()), asio::error::get_ssl_category()); return std::unexpected{RequestError{"SSL setup failed", errorCode}}; } - return doRequestImpl(std::move(streamData).value(), yield, method); + return doRequestImpl(std::move(streamData), yield, method); } std::expected diff --git a/src/util/requests/SslContext.cpp b/src/util/requests/SslContext.cpp new file mode 100644 index 000000000..348d360b6 --- /dev/null +++ b/src/util/requests/SslContext.cpp @@ -0,0 +1,35 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2026, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "util/requests/SslContext.hpp" + +#include "util/requests/impl/SslContext.hpp" + +#include +#include + +namespace util::requests { + +std::expected +initClientSslContext() +{ + return impl::initClientSslContext(); +} + +} // namespace util::requests diff --git a/src/util/requests/SslContext.hpp b/src/util/requests/SslContext.hpp new file mode 100644 index 000000000..32e1eb9eb --- /dev/null +++ b/src/util/requests/SslContext.hpp @@ -0,0 +1,40 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2026, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include +#include + +namespace util::requests { + +/** + * @brief Create and cache the shared client SSL context. + * + * Intended to be called once during application startup so that an unrecoverable SSL + * misconfiguration (e.g. a missing system root certificate bundle) is reported immediately instead + * of causing every outgoing request to fail later. The context (including the potentially expensive + * parse of the root certificate bundle) is created once and reused for the lifetime of the process. + * + * @return An error message if the context could not be created + */ +std::expected +initClientSslContext(); + +} // namespace util::requests diff --git a/src/util/requests/WsConnection.cpp b/src/util/requests/WsConnection.cpp index 29735c7c5..c2630cc90 100644 --- a/src/util/requests/WsConnection.cpp +++ b/src/util/requests/WsConnection.cpp @@ -95,18 +95,16 @@ std::expected WsConnectionBuilder::sslConnect(asio::yield_context yield) const { auto streamData = impl::SslWsStreamData::create(yield); - if (not streamData.has_value()) - return std::unexpected{std::move(streamData).error()}; #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wold-style-cast" - if (!SSL_set_tlsext_host_name(streamData->stream.next_layer().native_handle(), host_.c_str())) { + if (!SSL_set_tlsext_host_name(streamData.stream.next_layer().native_handle(), host_.c_str())) { #pragma GCC diagnostic pop beast::error_code errorCode; errorCode.assign(static_cast(::ERR_get_error()), beast::net::error::get_ssl_category()); return std::unexpected{RequestError{"SSL setup failed", errorCode}}; } - return connectImpl(std::move(streamData).value(), yield); + return connectImpl(std::move(streamData), yield); } std::expected diff --git a/src/util/requests/impl/SslContext.cpp b/src/util/requests/impl/SslContext.cpp index be7472025..dce505f12 100644 --- a/src/util/requests/impl/SslContext.cpp +++ b/src/util/requests/impl/SslContext.cpp @@ -32,6 +32,7 @@ #include #include +#include #include #include #include @@ -62,38 +63,82 @@ constexpr std::array kCERT_FILE_PATHS{ "/system/etc/security/cacerts", // Android }; -std::expected +std::optional +readCertificateFile(std::filesystem::path const& path) +{ + if (not std::filesystem::exists(path)) + return std::nullopt; + + std::ifstream const fileStream{path, std::ios::in}; + if (not fileStream.is_open()) + return std::nullopt; + + std::stringstream buffer; + buffer << fileStream.rdbuf(); + + return std::move(buffer).str(); +} + +std::optional getRootCertificate() { - for (auto const& path : kCERT_FILE_PATHS) { - if (std::filesystem::exists(path)) { - std::ifstream const fileStream{path, std::ios::in}; - if (not fileStream.is_open()) { - continue; - } - std::stringstream buffer; - buffer << fileStream.rdbuf(); - return std::move(buffer).str(); - } + // Honor the OpenSSL-standard SSL_CERT_FILE environment variable first. Some + // environments (e.g. the Nix-based CI/runtime image) point it at their CA + // bundle instead of installing certificates at the well-known system paths. + if (char const* const certFile = std::getenv("SSL_CERT_FILE"); certFile != nullptr) { + if (auto contents = readCertificateFile(certFile); contents.has_value()) + return contents; } - return std::unexpected{RequestError{"SSL setup failed: could not find root certificate"}}; + + for (auto const& path : kCERT_FILE_PATHS) { + if (auto contents = readCertificateFile(path); contents.has_value()) + return contents; + } + + return std::nullopt; +} + +std::expected& +cachedClientSslContext() +{ + static std::expected kCONTEXT = makeClientSslContext(getRootCertificate()); + return kCONTEXT; } } // namespace -std::expected -makeClientSslContext() +std::expected +makeClientSslContext(std::optional const& rootCertificate) { + if (not rootCertificate.has_value()) + return std::unexpected{RequestError{"SSL setup failed: could not find root certificate"}}; + ssl::context context{ssl::context::tls_client}; context.set_verify_mode(ssl::verify_peer); - auto const rootCertificate = getRootCertificate(); - if (not rootCertificate.has_value()) { - return std::unexpected{rootCertificate.error()}; - } - context.add_certificate_authority(asio::buffer(rootCertificate->data(), rootCertificate->size())); + context.add_certificate_authority( // + asio::buffer(rootCertificate->data(), rootCertificate->size()) + ); + return context; } +std::expected +initClientSslContext() +{ + auto const& context = cachedClientSslContext(); + if (not context.has_value()) + return std::unexpected{context.error().message()}; + + return {}; +} + +ssl::context& +getClientSslContext() +{ + // initClientSslContext() called during startup + return cachedClientSslContext().value(); +} + std::optional sslErrorToString(boost::beast::error_code const& error) { diff --git a/src/util/requests/impl/SslContext.hpp b/src/util/requests/impl/SslContext.hpp index d4dac6ac1..fa7a78705 100644 --- a/src/util/requests/impl/SslContext.hpp +++ b/src/util/requests/impl/SslContext.hpp @@ -31,7 +31,13 @@ namespace util::requests::impl { std::expected -makeClientSslContext(); +makeClientSslContext(std::optional const& rootCertificate); + +std::expected +initClientSslContext(); + +boost::asio::ssl::context& +getClientSslContext(); std::optional sslErrorToString(boost::beast::error_code const& error); diff --git a/src/util/requests/impl/StreamData.hpp b/src/util/requests/impl/StreamData.hpp index e3d040c83..49e0eed17 100644 --- a/src/util/requests/impl/StreamData.hpp +++ b/src/util/requests/impl/StreamData.hpp @@ -19,7 +19,6 @@ #pragma once -#include "util/requests/Types.hpp" #include "util/requests/impl/SslContext.hpp" #include @@ -32,9 +31,6 @@ #include #include -#include -#include - namespace util::requests::impl { template @@ -52,28 +48,19 @@ using TcpStreamData = PlainStreamData; using WsStreamData = PlainStreamData>; template -class SslStreamData { - boost::asio::ssl::context sslContext_; - -public: +struct SslStreamData { static constexpr bool kSSL_ENABLED = true; - - static std::expected - create(boost::asio::yield_context yield) - { - auto sslContext = makeClientSslContext(); - if (not sslContext.has_value()) { - return std::unexpected{std::move(sslContext.error())}; - } - return SslStreamData{std::move(sslContext).value(), yield}; - } - StreamType stream; -private: - SslStreamData(boost::asio::ssl::context sslContext, boost::asio::yield_context yield) - : sslContext_(std::move(sslContext)), stream(boost::asio::get_associated_executor(yield), sslContext_) + static SslStreamData + create(boost::asio::yield_context yield) + { + return SslStreamData{getClientSslContext(), yield}; + } +private: + SslStreamData(boost::asio::ssl::context& sslContext, boost::asio::yield_context yield) + : stream(boost::asio::get_associated_executor(yield), sslContext) { } }; diff --git a/tests/unit/util/requests/SslContextTests.cpp b/tests/unit/util/requests/SslContextTests.cpp index 6f7cc177f..02d3db7bc 100644 --- a/tests/unit/util/requests/SslContextTests.cpp +++ b/tests/unit/util/requests/SslContextTests.cpp @@ -19,12 +19,37 @@ #include "util/requests/impl/SslContext.hpp" +#include #include +#include + using namespace util::requests::impl; TEST(SslContext, Create) { - auto ctx = makeClientSslContext(); - EXPECT_TRUE(ctx); + auto& ctx = getClientSslContext(); + EXPECT_NE(ctx.native_handle(), nullptr); +} + +TEST(SslContext, IsCached) +{ + auto& first = getClientSslContext(); + auto& second = getClientSslContext(); + + // Same shared instance is returned on every call + EXPECT_EQ(&first, &second); +} + +TEST(SslContext, InitSucceedsWithSystemCertificates) +{ + EXPECT_TRUE(initClientSslContext().has_value()); +} + +TEST(SslContext, MakeWithoutRootCertificateReturnsError) +{ + auto const ctx = makeClientSslContext(std::nullopt); + + ASSERT_FALSE(ctx.has_value()); + EXPECT_THAT(ctx.error().message(), testing::HasSubstr("could not find root certificate")); }