Compare commits

...

3 Commits

Author SHA1 Message Date
Alex Kremer
159f79d085 ci: Pin scylladb docker to fix integration tests (#3140) 2026-07-08 13:12:40 +01:00
Alex Kremer
5cd1e1115b fix: Shared SSL context (#3138) 2026-07-07 20:46:33 +01:00
Sergey Kuznetsov
bcc0345c35 chore: Use released libxrpl 3.2.0 (#3105) 2026-06-16 17:31:10 +01:00
14 changed files with 206 additions and 65 deletions

View File

@@ -114,7 +114,7 @@ jobs:
services:
scylladb:
image: ${{ inputs.container != '' && 'scylladb/scylla' || '' }}
image: ${{ inputs.container != '' && 'scylladb/scylla:2026.1' || '' }}
options: >-
--health-cmd "cqlsh -e 'describe cluster'"
--health-interval 10s
@@ -138,15 +138,15 @@ jobs:
run: |
docker rm --force scylladb || true
docker run \
--detach \
--name scylladb \
--health-cmd "cqlsh -e 'describe cluster'" \
--health-interval 10s \
--health-timeout 5s \
--health-retries 5 \
--publish 9042:9042 \
--memory 16G \
scylladb/scylla
--detach \
--name scylladb \
--health-cmd "cqlsh -e 'describe cluster'" \
--health-interval 10s \
--health-timeout 5s \
--health-retries 5 \
--publish 9042:9042 \
--memory 16G \
scylladb/scylla:2026.1
until [ "$(docker inspect -f '{{.State.Health.Status}}' scylladb)" == "healthy" ]; do
sleep 5

View File

@@ -3,7 +3,7 @@
"requires": [
"zlib/1.3.2#1cb806da49011867778ffb6ac7190fcb%1778091116.056",
"xxhash/0.8.3#681d36a0a6111fc56e5e45ea182c19cc%1765850149.987",
"xrpl/3.2.0-rc3+e5cf1a0#694aadb2bdc6226effdb3b1920463e37%1780518730.129",
"xrpl/3.2.0#c3c124909c6461012758a4fab7c90cd5%1781569913.756",
"sqlite3/3.53.0#324ada52333108388a9a6108bfa96734%1778091117.311",
"spdlog/1.17.0#bcbaaf7147bda6ad24ffbd1ac3d7142c%1768312128.781",
"soci/4.0.3#fe32b9ad5eb47e79ab9e45a68f363945%1774450067.231",

View File

@@ -16,7 +16,7 @@ class ClioConan(ConanFile):
"fmt/12.1.0",
"libbacktrace/cci.20210118",
"spdlog/1.17.0",
"xrpl/3.2.0-rc3+e5cf1a0",
"xrpl/3.2.0",
]
default_options = {

View File

@@ -13,7 +13,7 @@ If you see the error log message `Could not connect to Cassandra: No hosts avail
If you would like to run a local ScyllaDB, you can call:
```sh
docker run --rm -p 9042:9042 --name clio-scylla -d scylladb/scylla
docker run --rm -p 9042:9042 --name clio-scylla -d scylladb/scylla:2026.1
```
## Check the server status of Clio

View File

@@ -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<uint16_t>("io_threads");
LOG(util::LogService::info()) << "Number of io threads = " << threads;

View File

@@ -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

View File

@@ -136,18 +136,16 @@ std::expected<std::string, RequestError>
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<int>(::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<std::string, RequestError>

View File

@@ -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 <expected>
#include <string>
namespace util::requests {
std::expected<void, std::string>
initClientSslContext()
{
return impl::initClientSslContext();
}
} // namespace util::requests

View File

@@ -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 <expected>
#include <string>
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<void, std::string>
initClientSslContext();
} // namespace util::requests

View File

@@ -95,18 +95,16 @@ std::expected<WsConnectionPtr, RequestError>
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<int>(::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<WsConnectionPtr, RequestError>

View File

@@ -32,6 +32,7 @@
#include <array>
#include <cstddef>
#include <cstdlib>
#include <expected>
#include <filesystem>
#include <fstream>
@@ -62,38 +63,82 @@ constexpr std::array kCERT_FILE_PATHS{
"/system/etc/security/cacerts", // Android
};
std::expected<std::string, RequestError>
std::optional<std::string>
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<std::string>
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<ssl::context, RequestError>&
cachedClientSslContext()
{
static std::expected<ssl::context, RequestError> kCONTEXT = makeClientSslContext(getRootCertificate());
return kCONTEXT;
}
} // namespace
std::expected<boost::asio::ssl::context, RequestError>
makeClientSslContext()
std::expected<ssl::context, RequestError>
makeClientSslContext(std::optional<std::string> 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<void, std::string>
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<std::string>
sslErrorToString(boost::beast::error_code const& error)
{

View File

@@ -31,7 +31,13 @@
namespace util::requests::impl {
std::expected<boost::asio::ssl::context, RequestError>
makeClientSslContext();
makeClientSslContext(std::optional<std::string> const& rootCertificate);
std::expected<void, std::string>
initClientSslContext();
boost::asio::ssl::context&
getClientSslContext();
std::optional<std::string>
sslErrorToString(boost::beast::error_code const& error);

View File

@@ -19,7 +19,6 @@
#pragma once
#include "util/requests/Types.hpp"
#include "util/requests/impl/SslContext.hpp"
#include <boost/asio/associated_executor.hpp>
@@ -32,9 +31,6 @@
#include <boost/beast/websocket.hpp>
#include <boost/beast/websocket/stream.hpp>
#include <expected>
#include <utility>
namespace util::requests::impl {
template <typename StreamType>
@@ -52,28 +48,19 @@ using TcpStreamData = PlainStreamData<boost::beast::tcp_stream>;
using WsStreamData = PlainStreamData<boost::beast::websocket::stream<boost::beast::tcp_stream>>;
template <typename StreamType>
class SslStreamData {
boost::asio::ssl::context sslContext_;
public:
struct SslStreamData {
static constexpr bool kSSL_ENABLED = true;
static std::expected<SslStreamData, RequestError>
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)
{
}
};

View File

@@ -19,12 +19,37 @@
#include "util/requests/impl/SslContext.hpp"
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <optional>
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"));
}