Compare commits

..

5 Commits

Author SHA1 Message Date
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
Sergey Kuznetsov
14645e1494 fix: Proxy support (#3103)
Port of two changes onto release/2.7.1:
- #3006 (d3381a1d): resolve proxy ip before processing any request
- #3043 (d7bcf6e7): re-resolve client ip when a proxy reuses a TCP
connection for different clients (resolveClientIp now returns
std::optional; extractClientIp made public; isProxyConnection_ tracked)
2026-06-09 16:25:02 +01:00
Sergey Kuznetsov
ce44aec245 ci: Use docker images with glibc version in conan (#3100) 2026-06-08 15:16:50 +01:00
Sergey Kuznetsov
94da8459dd ci: Use glibc version in conan profile (#3099) 2026-06-08 14:01:33 +01:00
34 changed files with 472 additions and 111 deletions

View File

@@ -4,7 +4,7 @@ import json
LINUX_OS = ["heavy", "heavy-arm64"]
LINUX_CONTAINERS = [
'{ "image": "ghcr.io/xrplf/clio-ci:6bb4953f1643b999781609ca79d5ec467289c996" }'
'{ "image": "ghcr.io/xrplf/clio-ci:94da8459ddc30e2f0d88a98cba63a57b2cda3031" }'
]
LINUX_COMPILERS = ["gcc", "clang"]

View File

@@ -49,7 +49,7 @@ jobs:
build_type: [Release, Debug]
container:
[
'{ "image": "ghcr.io/xrplf/clio-ci:6bb4953f1643b999781609ca79d5ec467289c996" }',
'{ "image": "ghcr.io/xrplf/clio-ci:94da8459ddc30e2f0d88a98cba63a57b2cda3031" }',
]
static: [true]
@@ -79,7 +79,7 @@ jobs:
uses: ./.github/workflows/reusable-build.yml
with:
runs_on: heavy
container: '{ "image": "ghcr.io/xrplf/clio-ci:6bb4953f1643b999781609ca79d5ec467289c996" }'
container: '{ "image": "ghcr.io/xrplf/clio-ci:94da8459ddc30e2f0d88a98cba63a57b2cda3031" }'
conan_profile: gcc
build_type: Debug
download_ccache: true
@@ -97,7 +97,7 @@ jobs:
needs: build-and-test
runs-on: heavy
container:
image: ghcr.io/xrplf/clio-ci:6bb4953f1643b999781609ca79d5ec467289c996
image: ghcr.io/xrplf/clio-ci:94da8459ddc30e2f0d88a98cba63a57b2cda3031
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1

View File

@@ -21,7 +21,7 @@ jobs:
name: Build Clio / `libXRPL ${{ github.event.client_payload.version }}`
runs-on: heavy
container:
image: ghcr.io/xrplf/clio-ci:6bb4953f1643b999781609ca79d5ec467289c996
image: ghcr.io/xrplf/clio-ci:94da8459ddc30e2f0d88a98cba63a57b2cda3031
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
@@ -69,7 +69,7 @@ jobs:
needs: build
runs-on: heavy
container:
image: ghcr.io/xrplf/clio-ci:6bb4953f1643b999781609ca79d5ec467289c996
image: ghcr.io/xrplf/clio-ci:94da8459ddc30e2f0d88a98cba63a57b2cda3031
steps:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0

View File

@@ -31,7 +31,7 @@ jobs:
if: github.event_name != 'push' || contains(github.event.head_commit.message, 'clang-tidy auto fixes')
runs-on: heavy
container:
image: ghcr.io/xrplf/clio-ci:6bb4953f1643b999781609ca79d5ec467289c996
image: ghcr.io/xrplf/clio-ci:94da8459ddc30e2f0d88a98cba63a57b2cda3031
permissions:
contents: write

View File

@@ -18,7 +18,7 @@ jobs:
build:
runs-on: ubuntu-latest
container:
image: ghcr.io/xrplf/clio-ci:6bb4953f1643b999781609ca79d5ec467289c996
image: ghcr.io/xrplf/clio-ci:94da8459ddc30e2f0d88a98cba63a57b2cda3031
steps:
- name: Checkout

View File

@@ -55,17 +55,17 @@ jobs:
conan_profile: gcc
build_type: Release
static: true
container: '{ "image": "ghcr.io/xrplf/clio-ci:6bb4953f1643b999781609ca79d5ec467289c996" }'
container: '{ "image": "ghcr.io/xrplf/clio-ci:94da8459ddc30e2f0d88a98cba63a57b2cda3031" }'
- os: heavy
conan_profile: gcc
build_type: Debug
static: true
container: '{ "image": "ghcr.io/xrplf/clio-ci:6bb4953f1643b999781609ca79d5ec467289c996" }'
container: '{ "image": "ghcr.io/xrplf/clio-ci:94da8459ddc30e2f0d88a98cba63a57b2cda3031" }'
- os: heavy
conan_profile: gcc.ubsan
build_type: Release
static: false
container: '{ "image": "ghcr.io/xrplf/clio-ci:6bb4953f1643b999781609ca79d5ec467289c996" }'
container: '{ "image": "ghcr.io/xrplf/clio-ci:94da8459ddc30e2f0d88a98cba63a57b2cda3031" }'
uses: ./.github/workflows/reusable-build-test.yml
with:
@@ -88,7 +88,7 @@ jobs:
uses: ./.github/workflows/reusable-build.yml
with:
runs_on: heavy
container: '{ "image": "ghcr.io/xrplf/clio-ci:6bb4953f1643b999781609ca79d5ec467289c996" }'
container: '{ "image": "ghcr.io/xrplf/clio-ci:94da8459ddc30e2f0d88a98cba63a57b2cda3031" }'
conan_profile: gcc
build_type: Release
download_ccache: false
@@ -111,7 +111,7 @@ jobs:
include:
- os: heavy
conan_profile: clang
container: '{ "image": "ghcr.io/xrplf/clio-ci:6bb4953f1643b999781609ca79d5ec467289c996" }'
container: '{ "image": "ghcr.io/xrplf/clio-ci:94da8459ddc30e2f0d88a98cba63a57b2cda3031" }'
static: true
- os: macos15
conan_profile: apple-clang

View File

@@ -11,4 +11,4 @@ jobs:
uses: XRPLF/actions/.github/workflows/pre-commit.yml@282890f46d6921249d5659dd38babcb0bd8aef48
with:
runs_on: heavy
container: '{ "image": "ghcr.io/xrplf/clio-pre-commit:6bb4953f1643b999781609ca79d5ec467289c996" }'
container: '{ "image": "ghcr.io/xrplf/clio-pre-commit:94da8459ddc30e2f0d88a98cba63a57b2cda3031" }'

View File

@@ -29,7 +29,7 @@ jobs:
conan_profile: gcc
build_type: Release
static: true
container: '{ "image": "ghcr.io/xrplf/clio-ci:6bb4953f1643b999781609ca79d5ec467289c996" }'
container: '{ "image": "ghcr.io/xrplf/clio-ci:94da8459ddc30e2f0d88a98cba63a57b2cda3031" }'
uses: ./.github/workflows/reusable-build-test.yml
with:
@@ -51,7 +51,7 @@ jobs:
uses: ./.github/workflows/reusable-build.yml
with:
runs_on: heavy
container: '{ "image": "ghcr.io/xrplf/clio-ci:6bb4953f1643b999781609ca79d5ec467289c996" }'
container: '{ "image": "ghcr.io/xrplf/clio-ci:94da8459ddc30e2f0d88a98cba63a57b2cda3031" }'
conan_profile: gcc
build_type: Release
download_ccache: false

View File

@@ -46,7 +46,7 @@ jobs:
release:
runs-on: heavy
container:
image: ghcr.io/xrplf/clio-ci:6bb4953f1643b999781609ca79d5ec467289c996
image: ghcr.io/xrplf/clio-ci:94da8459ddc30e2f0d88a98cba63a57b2cda3031
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}

View File

@@ -44,7 +44,7 @@ jobs:
uses: ./.github/workflows/reusable-build-test.yml
with:
runs_on: heavy
container: '{ "image": "ghcr.io/xrplf/clio-ci:6bb4953f1643b999781609ca79d5ec467289c996" }'
container: '{ "image": "ghcr.io/xrplf/clio-ci:94da8459ddc30e2f0d88a98cba63a57b2cda3031" }'
download_ccache: false
upload_ccache: false
conan_profile: ${{ matrix.compiler }}${{ matrix.sanitizer_ext }}

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

@@ -3,7 +3,7 @@
This image contains an environment to build [Clio](https://github.com/XRPLF/clio), check code and documentation.
It is used in [Clio Github Actions](https://github.com/XRPLF/clio/actions) but can also be used to compile Clio locally.
The image is based on Ubuntu 20.04 and contains:
The image is based on Ubuntu 22.04 and contains:
- ccache 4.12.2
- Clang 19

View File

@@ -10,3 +10,5 @@ os=Linux
[conf]
tools.build:compiler_executables={"c": "/usr/bin/clang-19", "cpp": "/usr/bin/clang++-19"}
grpc/1.50.1:tools.build:cxxflags+=["-Wno-missing-template-arg-list-after-template-kw"]
user.package:libc_version=2.32
tools.info.package_id:confs+=["user.package:libc_version"]

View File

@@ -9,3 +9,5 @@ os=Linux
[conf]
tools.build:compiler_executables={"c": "/usr/bin/gcc-15", "cpp": "/usr/bin/g++-15"}
user.package:libc_version=2.32
tools.info.package_id:confs+=["user.package:libc_version"]

View File

@@ -1,6 +1,6 @@
services:
clio_develop:
image: ghcr.io/xrplf/clio-ci:6bb4953f1643b999781609ca79d5ec467289c996
image: ghcr.io/xrplf/clio-ci:94da8459ddc30e2f0d88a98cba63a57b2cda3031
volumes:
- clio_develop_conan_data:/root/.conan2/p
- clio_develop_ccache:/root/.ccache

View File

@@ -175,7 +175,7 @@ Open the `index.html` file in your browser to see the documentation pages.
It is also possible to build Clio using [Docker](https://www.docker.com/) if you don't want to install all the dependencies on your machine.
```sh
docker run -it ghcr.io/xrplf/clio-ci:6bb4953f1643b999781609ca79d5ec467289c996
docker run -it ghcr.io/xrplf/clio-ci:94da8459ddc30e2f0d88a98cba63a57b2cda3031
git clone https://github.com/XRPLF/clio
cd 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

@@ -26,6 +26,7 @@
#include <boost/beast/http/field.hpp>
#include <algorithm>
#include <optional>
#include <string>
#include <string_view>
@@ -63,20 +64,20 @@ ProxyIpResolver::fromConfig(util::config::ClioConfigDefinition const& config)
return ProxyIpResolver{std::move(ips), std::move(tokens)};
}
std::string
std::optional<std::string>
ProxyIpResolver::resolveClientIp(std::string const& connectionIp, HttpHeaders const& headers) const
{
if (proxyIps_.contains(connectionIp)) {
return extractClientIp(headers).value_or(connectionIp);
return extractClientIp(headers);
}
if (auto it = headers.find(kPROXY_TOKEN_HEADER); it != headers.end()) {
auto const tokenHash = util::sha256sum(it->value());
if (proxyTokens_.contains(tokenHash)) {
return extractClientIp(headers).value_or(connectionIp);
return extractClientIp(headers);
}
}
return connectionIp;
return std::nullopt;
}
std::optional<std::string>
@@ -92,14 +93,17 @@ ProxyIpResolver::extractClientIp(HttpHeaders const& headers)
auto const headerValue = util::toLower(it->value());
static constexpr std::string_view kFOR_PREFIX = "for=";
auto const startPos = headerValue.find(kFOR_PREFIX);
auto const startPos = headerValue.rfind(kFOR_PREFIX);
if (startPos == std::string::npos) {
return std::nullopt;
}
auto value = it->value().substr(startPos + kFOR_PREFIX.size());
static constexpr char kDELIMITER = ';';
auto const endPos = value.find(kDELIMITER);
static constexpr char kSECTION_DELIMITER = ';';
static constexpr char kCHAIN_DELIMITER = ',';
auto const sectionEnd = value.find(kSECTION_DELIMITER);
auto const chainEnd = value.find(kCHAIN_DELIMITER);
auto const endPos = std::min(sectionEnd, chainEnd);
auto const ip = value.substr(0, endPos);
static constexpr auto kMIN_IP_LENGTH = 7; // minimum 3 dots + 4 digits

View File

@@ -75,16 +75,15 @@ public:
*
* If the connection IP is in the trusted proxy list, or if a valid proxy token is provided in the headers,
* this method will attempt to extract the client's IP from the `Forwarded` header.
* Otherwise, it returns the connection IP.
* Otherwise, returns std::nullopt.
*
* @param connectionIp The IP address of the direct connection.
* @param headers The HTTP request headers.
* @return The resolved client IP address as a string.
* @return The resolved client IP address if the connection is from a trusted proxy, otherwise std::nullopt.
*/
std::string
std::optional<std::string>
resolveClientIp(std::string const& connectionIp, HttpHeaders const& headers) const;
private:
/**
* @brief Extracts the client IP from the `Forwarded` HTTP header.
*

View File

@@ -43,8 +43,10 @@
#include <boost/beast/http/error.hpp>
#include <boost/beast/http/field.hpp>
#include <boost/beast/http/message.hpp>
#include <boost/beast/http/message_fwd.hpp>
#include <boost/beast/http/status.hpp>
#include <boost/beast/http/string_body.hpp>
#include <boost/beast/http/string_body_fwd.hpp>
#include <boost/beast/http/verb.hpp>
#include <boost/beast/ssl.hpp>
#include <boost/core/ignore_unused.hpp>
@@ -139,6 +141,7 @@ class HttpBase : public ConnectionBase {
SendLambda sender_;
std::shared_ptr<AdminVerificationStrategy> adminVerification_;
std::shared_ptr<ProxyIpResolver> proxyIpResolver_;
bool isProxyConnection_ = false;
protected:
boost::beast::flat_buffer buffer_;
@@ -239,6 +242,23 @@ public:
if (ec)
return httpFail(ec, "read");
auto const updateClientIp = [&](std::string newIp) {
if (newIp == clientIp_)
return;
LOG(log_.info()) << tag() << "Detected a forwarded request from proxy. Resolved client ip: " << newIp;
dosGuard_.get().decrement(clientIp_);
clientIp_ = std::move(newIp);
dosGuard_.get().increment(clientIp_);
};
if (isProxyConnection_) {
if (auto resolvedIp = ProxyIpResolver::extractClientIp(req_); resolvedIp.has_value())
updateClientIp(std::move(*resolvedIp));
} else if (auto resolvedIp = proxyIpResolver_->resolveClientIp(clientIp_, req_); resolvedIp.has_value()) {
updateClientIp(std::move(*resolvedIp));
isProxyConnection_ = true;
}
if (req_.method() == http::verb::get and req_.target() == "/health")
return sender_(httpResponse(http::status::ok, "text/html", kHEALTH_CHECK_HTML));
@@ -249,14 +269,6 @@ public:
return sender_(httpResponse(http::status::service_unavailable, "text/html", kCACHE_CHECK_NOT_LOADED_HTML));
}
if (auto resolvedIp = proxyIpResolver_->resolveClientIp(clientIp_, req_); resolvedIp != clientIp_) {
LOG(log_.info()) << tag() << "Detected a forwarded request from proxy. Proxy ip: " << clientIp_
<< ". Resolved client ip: " << resolvedIp;
dosGuard_.get().decrement(clientIp_);
clientIp_ = std::move(resolvedIp);
dosGuard_.get().increment(clientIp_);
}
// Update isAdmin property of the connection
ConnectionBase::isAdmin_ = adminVerification_->isAdmin(req_, clientIp_);

View File

@@ -45,6 +45,7 @@ class ConnectionMetadata : public util::Taggable {
protected:
std::string ip_; // client ip
std::optional<bool> isAdmin_;
bool isProxyConnection_ = false;
public:
/**
@@ -82,6 +83,26 @@ public:
ip_ = std::move(newIp);
}
/**
* @brief Mark this connection as coming through a trusted proxy.
*/
void
markAsProxyConnection()
{
isProxyConnection_ = true;
}
/**
* @brief Whether this connection was identified as coming through a trusted proxy.
*
* @return true if the connection is a proxy connection.
*/
[[nodiscard]] bool
isProxyConnection() const
{
return isProxyConnection_;
}
/**
* @brief Get whether the client is an admin.
*

View File

@@ -395,12 +395,22 @@ ConnectionHandler::handleRequest(
void
ConnectionHandler::resolveClientIp(Connection& connection, Request const& request) const
{
if (auto resolvedClientIp = proxyIpResolver_.resolveClientIp(connection.ip(), request.httpHeaders());
resolvedClientIp != connection.ip()) {
LOG(log_.info()) << connection.tag() << "Detected a forwarded request from proxy. Proxy ip: " << connection.ip()
<< ". Resolved client ip: " << resolvedClientIp;
onIpChangeHook_(connection.ip(), resolvedClientIp);
connection.setIp(std::move(resolvedClientIp));
auto const updateIp = [&](std::string newIp) {
if (newIp == connection.ip())
return;
LOG(log_.info()) << connection.tag()
<< "Detected a forwarded request from proxy. Resolved client ip: " << newIp;
onIpChangeHook_(connection.ip(), newIp);
connection.setIp(std::move(newIp));
};
if (connection.isProxyConnection()) {
if (auto resolvedIp = ProxyIpResolver::extractClientIp(request.httpHeaders()); resolvedIp.has_value())
updateIp(std::move(*resolvedIp));
} else if (auto resolvedIp = proxyIpResolver_.resolveClientIp(connection.ip(), request.httpHeaders());
resolvedIp.has_value()) {
updateIp(std::move(*resolvedIp));
connection.markAsProxyConnection();
}
}

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"));
}

View File

@@ -29,6 +29,7 @@
#include <fmt/format.h>
#include <gtest/gtest.h>
#include <optional>
#include <string>
#include <unordered_set>
#include <utility>
@@ -44,7 +45,7 @@ struct ProxyIpResolverTestParams {
std::unordered_set<std::string> proxyTokens;
std::vector<std::pair<std::string, std::string>> headers;
std::string connectionIp;
std::string expectedIp;
std::optional<std::string> expectedIp;
};
class ProxyIpResolverTest : public ::testing::TestWithParam<ProxyIpResolverTestParams> {};
@@ -79,11 +80,11 @@ TEST_F(ProxyIpResolverTest, FromConfig)
auto const proxyIpResolver = ProxyIpResolver::fromConfig(config);
ProxyIpResolver::HttpHeaders headers;
EXPECT_EQ(proxyIpResolver.resolveClientIp(clientIp, headers), clientIp);
EXPECT_EQ(proxyIpResolver.resolveClientIp(proxyIp, headers), proxyIp);
EXPECT_EQ(proxyIpResolver.resolveClientIp(clientIp, headers), std::nullopt);
EXPECT_EQ(proxyIpResolver.resolveClientIp(proxyIp, headers), std::nullopt);
headers.set(boost::beast::http::field::forwarded, fmt::format("for={}", clientIp));
EXPECT_EQ(proxyIpResolver.resolveClientIp(clientIp, headers), clientIp);
EXPECT_EQ(proxyIpResolver.resolveClientIp(clientIp, headers), std::nullopt);
EXPECT_EQ(proxyIpResolver.resolveClientIp(proxyIp, headers), clientIp);
headers.set(ProxyIpResolver::kPROXY_TOKEN_HEADER, proxyToken);
@@ -113,7 +114,7 @@ INSTANTIATE_TEST_SUITE_P(
.proxyTokens = {},
.headers = {},
.connectionIp = "1.2.3.4",
.expectedIp = "1.2.3.4"
.expectedIp = std::nullopt
},
ProxyIpResolverTestParams{
.testName = "TrustedProxyIpWithForwardedHeader",
@@ -129,7 +130,7 @@ INSTANTIATE_TEST_SUITE_P(
.proxyTokens = {},
.headers = {},
.connectionIp = "5.6.7.8",
.expectedIp = "5.6.7.8"
.expectedIp = std::nullopt
},
ProxyIpResolverTestParams{
.testName = "UntrustedProxyIpWithForwardedHeader",
@@ -137,7 +138,7 @@ INSTANTIATE_TEST_SUITE_P(
.proxyTokens = {},
.headers = {{std::string(http::to_string(http::field::forwarded)), "for=1.2.3.4"}},
.connectionIp = "5.6.7.8",
.expectedIp = "5.6.7.8"
.expectedIp = std::nullopt
},
ProxyIpResolverTestParams{
.testName = "TrustedProxyTokenWithForwardedHeader",
@@ -155,7 +156,7 @@ INSTANTIATE_TEST_SUITE_P(
.proxyTokens = {"test_token"},
.headers = {{std::string(ProxyIpResolver::kPROXY_TOKEN_HEADER), "test_token"}},
.connectionIp = "5.6.7.8",
.expectedIp = "5.6.7.8"
.expectedIp = std::nullopt
},
ProxyIpResolverTestParams{
.testName = "UntrustedProxyTokenWithForwardedHeader",
@@ -165,7 +166,7 @@ INSTANTIATE_TEST_SUITE_P(
{{std::string(ProxyIpResolver::kPROXY_TOKEN_HEADER), "test_token"},
{std::string(http::to_string(http::field::forwarded)), "for=1.2.3.4"}},
.connectionIp = "5.6.7.8",
.expectedIp = "5.6.7.8"
.expectedIp = std::nullopt
},
ProxyIpResolverTestParams{
.testName = "ForwardedHeaderWithAdditionalFields",
@@ -191,7 +192,7 @@ INSTANTIATE_TEST_SUITE_P(
.proxyTokens = {},
.headers = {{std::string(http::to_string(http::field::forwarded)), "by=1.2.3.4"}},
.connectionIp = "5.6.7.8",
.expectedIp = "5.6.7.8"
.expectedIp = std::nullopt
},
ProxyIpResolverTestParams{
.testName = "ForwardedHeaderWithIpInQuotes",
@@ -207,7 +208,25 @@ INSTANTIATE_TEST_SUITE_P(
.proxyTokens = {},
.headers = {{std::string(http::to_string(http::field::forwarded)), "for=\";some_other_text"}},
.connectionIp = "5.6.7.8",
.expectedIp = "5.6.7.8"
.expectedIp = std::nullopt
},
ProxyIpResolverTestParams{
.testName = "ForwardedHeaderWithMultipleForValues",
.proxyIps = {"5.6.7.8"},
.proxyTokens = {},
.headers = {{std::string(http::to_string(http::field::forwarded)), "for=1.2.3.4, for=9.10.11.12"}},
.connectionIp = "5.6.7.8",
.expectedIp = "9.10.11.12"
},
ProxyIpResolverTestParams{
.testName = "ForwardedHeaderWithMultipleForValuesAndSectionDelimiters",
.proxyIps = {"5.6.7.8"},
.proxyTokens = {},
.headers =
{{std::string(http::to_string(http::field::forwarded)),
"for=1.2.3.4; proto=http, for=9.10.11.12; proto=https"}},
.connectionIp = "5.6.7.8",
.expectedIp = "9.10.11.12"
}
),
tests::util::kNAME_GENERATOR

View File

@@ -523,6 +523,88 @@ TEST_F(ConnectionHandlerSequentialProcessingTest, OnIpChangeHookCalledWhenSentFr
});
}
TEST_F(ConnectionHandlerSequentialProcessingTest, ProxyConnection_SameClientReuses_HookCalledOnce)
{
std::string const target = "/some/target";
testing::StrictMock<testing::MockFunction<
Response(Request const&, ConnectionMetadata const&, web::SubscriptionContextPtr, boost::asio::yield_context)>>
getHandlerMock;
connectionHandler.onGet(target, getHandlerMock.AsStdFunction());
StrictMockHttpConnectionPtr mockProxyConnection =
std::make_unique<StrictMockHttpConnection>(proxyIp, boost::beast::flat_buffer{}, tagDecoratorFactory);
auto request = http::request<http::string_body>{http::verb::get, target, 11, ""};
request.set(http::field::forwarded, fmt::format("for={}", clientIp));
EXPECT_CALL(*mockProxyConnection, wasUpgraded).WillOnce(Return(false));
EXPECT_CALL(*mockProxyConnection, receive)
.WillOnce(Return(makeRequest(request)))
.WillOnce(Return(makeRequest(request)))
.WillOnce(Return(makeError(http::error::end_of_stream)));
EXPECT_CALL(onIpChangeMock, Call(proxyIp, clientIp));
EXPECT_CALL(getHandlerMock, Call).Times(2).WillRepeatedly([](Request const& req, auto&&, auto&&, auto&&) {
return Response(http::status::ok, "ok", req);
});
EXPECT_CALL(*mockProxyConnection, send).Times(2).WillRepeatedly(Return(std::expected<void, web::ng::Error>{}));
EXPECT_CALL(onDisconnectMock, Call).WillOnce([this, ptr = mockProxyConnection.get()](Connection const& c) {
EXPECT_EQ(&c, ptr);
EXPECT_EQ(c.ip(), clientIp);
});
runSpawn([this, c = std::move(mockProxyConnection)](boost::asio::yield_context yield) mutable {
connectionHandler.processConnection(std::move(c), yield);
});
}
TEST_F(ConnectionHandlerSequentialProcessingTest, ProxyConnection_DifferentClientReuses_HookCalledForEachIpChange)
{
std::string const target = "/some/target";
std::string const anotherClientIp = "9.10.11.12";
testing::StrictMock<testing::MockFunction<
Response(Request const&, ConnectionMetadata const&, web::SubscriptionContextPtr, boost::asio::yield_context)>>
getHandlerMock;
connectionHandler.onGet(target, getHandlerMock.AsStdFunction());
StrictMockHttpConnectionPtr mockProxyConnection =
std::make_unique<StrictMockHttpConnection>(proxyIp, boost::beast::flat_buffer{}, tagDecoratorFactory);
auto request1 = http::request<http::string_body>{http::verb::get, target, 11, ""};
request1.set(http::field::forwarded, fmt::format("for={}", clientIp));
auto request2 = http::request<http::string_body>{http::verb::get, target, 11, ""};
request2.set(http::field::forwarded, fmt::format("for={}", anotherClientIp));
EXPECT_CALL(*mockProxyConnection, wasUpgraded).WillOnce(Return(false));
EXPECT_CALL(*mockProxyConnection, receive)
.WillOnce(Return(makeRequest(request1)))
.WillOnce(Return(makeRequest(request2)))
.WillOnce(Return(makeError(http::error::end_of_stream)));
EXPECT_CALL(onIpChangeMock, Call(proxyIp, clientIp));
EXPECT_CALL(onIpChangeMock, Call(clientIp, anotherClientIp));
EXPECT_CALL(getHandlerMock, Call).Times(2).WillRepeatedly([](Request const& req, auto&&, auto&&, auto&&) {
return Response(http::status::ok, "ok", req);
});
EXPECT_CALL(*mockProxyConnection, send).Times(2).WillRepeatedly(Return(std::expected<void, web::ng::Error>{}));
EXPECT_CALL(onDisconnectMock, Call)
.WillOnce([anotherClientIp, ptr = mockProxyConnection.get()](Connection const& c) {
EXPECT_EQ(&c, ptr);
EXPECT_EQ(c.ip(), anotherClientIp);
});
runSpawn([this, c = std::move(mockProxyConnection)](boost::asio::yield_context yield) mutable {
connectionHandler.processConnection(std::move(c), yield);
});
}
TEST_F(ConnectionHandlerSequentialProcessingTest, Stop)
{
testing::StrictMock<testing::MockFunction<
@@ -728,6 +810,75 @@ TEST_F(ConnectionHandlerParallelProcessingTest, OnIpChangeHookCalledWhenSentFrom
});
}
TEST_F(ConnectionHandlerParallelProcessingTest, ProxyConnection_SameClientReuses_HookCalledOnce)
{
connectionHandler.onWs([](Request const& req, auto&&, auto&&, auto&&) {
return Response(http::status::ok, "ok", req);
});
StrictMockWsConnectionPtr mockProxyConnection =
std::make_unique<StrictMockWsConnection>(proxyIp, boost::beast::flat_buffer{}, tagDecoratorFactory);
headers.set(http::field::forwarded, fmt::format("for={}", clientIp));
EXPECT_CALL(*mockProxyConnection, wasUpgraded).WillOnce(Return(true));
EXPECT_CALL(*mockProxyConnection, receive)
.WillOnce(Return(makeRequest("msg", headers)))
.WillOnce(Return(makeRequest("msg", headers)))
.WillOnce(Return(makeError(websocket::error::closed)));
EXPECT_CALL(onIpChangeMock, Call(proxyIp, clientIp));
EXPECT_CALL(*mockProxyConnection, send).Times(2).WillRepeatedly(Return(std::expected<void, web::ng::Error>{}));
EXPECT_CALL(onDisconnectMock, Call).WillOnce([this, ptr = mockProxyConnection.get()](Connection const& c) {
EXPECT_EQ(&c, ptr);
EXPECT_EQ(c.ip(), clientIp);
});
runSpawn([this, c = std::move(mockProxyConnection)](boost::asio::yield_context yield) mutable {
connectionHandler.processConnection(std::move(c), yield);
});
}
TEST_F(ConnectionHandlerParallelProcessingTest, ProxyConnection_DifferentClientReuses_HookCalledForEachIpChange)
{
std::string const anotherClientIp = "9.10.11.12";
connectionHandler.onWs([](Request const& req, auto&&, auto&&, auto&&) {
return Response(http::status::ok, "ok", req);
});
StrictMockWsConnectionPtr mockProxyConnection =
std::make_unique<StrictMockWsConnection>(proxyIp, boost::beast::flat_buffer{}, tagDecoratorFactory);
Request::HttpHeaders headers1;
headers1.set(http::field::forwarded, fmt::format("for={}", clientIp));
Request::HttpHeaders headers2;
headers2.set(http::field::forwarded, fmt::format("for={}", anotherClientIp));
EXPECT_CALL(*mockProxyConnection, wasUpgraded).WillOnce(Return(true));
EXPECT_CALL(*mockProxyConnection, receive)
.WillOnce(Return(makeRequest("msg", headers1)))
.WillOnce(Return(makeRequest("msg", headers2)))
.WillOnce(Return(makeError(websocket::error::closed)));
EXPECT_CALL(onIpChangeMock, Call(proxyIp, clientIp));
EXPECT_CALL(onIpChangeMock, Call(clientIp, anotherClientIp));
EXPECT_CALL(*mockProxyConnection, send).Times(2).WillRepeatedly(Return(std::expected<void, web::ng::Error>{}));
EXPECT_CALL(onDisconnectMock, Call)
.WillOnce([anotherClientIp, ptr = mockProxyConnection.get()](Connection const& c) {
EXPECT_EQ(&c, ptr);
EXPECT_EQ(c.ip(), anotherClientIp);
});
runSpawn([this, c = std::move(mockProxyConnection)](boost::asio::yield_context yield) mutable {
connectionHandler.processConnection(std::move(c), yield);
});
}
TEST_F(ConnectionHandlerParallelProcessingTest, Receive_Handle_Send_Loop)
{
testing::StrictMock<testing::MockFunction<