Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c9f4864
Document node join curl migration plan
Jul 7, 2026
26b6d0c
Add POST-with-body support to curl wrapper
Jul 7, 2026
c6422d3
Migrate node join request from httpclient to curl client
Jul 7, 2026
61d22eb
Retry transient join transport errors instead of failing fatally
Jul 7, 2026
0aa42e7
Detect curl service-certificate rejection in join test infra
Jul 7, 2026
059ff75
Remove unused legacy httpclient infrastructure
Jul 7, 2026
0ec4c10
Document node join curl migration and hostname verification
Jul 7, 2026
87753c7
Reference PR #8040 in join curl migration changelog entries
Jul 7, 2026
66667fd
Extract and test join transient-error classifier; retry more transpor…
Jul 7, 2026
8afa7cc
Process join response on a task thread, not the host libuv loop
Jul 7, 2026
242d31c
Add curl test for TLS hostname verification (VERIFYHOST)
Jul 8, 2026
03be222
Merge branch 'main' into join-client-curl-migration
achamayou Jul 8, 2026
a399037
Gate node join retries to a single in-flight request
Jul 8, 2026
d6a5e4b
Harden join in-flight gate: reset flag if attach_request throws
Jul 8, 2026
2a2af9b
Merge branch 'main' into join-client-curl-migration
achamayou Jul 8, 2026
e04b34d
Use accurate TLS verification marker in join failure message
Jul 9, 2026
e154062
Clarify join TLS failure marker to cover CA-load failures
Jul 9, 2026
de9765f
Drop migration plan doc; document CURLOPT_POST; highlight SAN check
achamayou Jul 9, 2026
e145f80
Move #8040 changelog entries to new 7.0.8 section
achamayou Jul 9, 2026
11f0581
Clarify curl body-size options and oversized join response error
achamayou Jul 9, 2026
b47de7e
Merge branch 'main' into join-client-curl-migration
achamayou Jul 9, 2026
4a416ba
Add e2e test for join target certificate SAN (VERIFYHOST) check
achamayou Jul 9, 2026
8a4b6c7
Merge branch 'main' into join-client-curl-migration
achamayou Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

### Changed

- The node join protocol client now uses the curl multi singleton client (introduced in #7102) instead of the legacy enclave `RPCSessions::create_client()` HTTP client, matching the JWT refresh and snapshot-fetch clients. The service certificate remains the sole trust anchor for the join connection (the host certificate store is never consulted). TLS certificate hostname verification is now enforced on the join connection: operators must ensure the target node's certificate subject alternative names cover the address configured in `join.target_rpc_address` (#8040).
- JWT/JWK auto-refresh outbound HTTP fetches (OpenID metadata and JWKS) now use the curl multi singleton client introduced in #7102, replacing the previous `RPCSessions::create_client()` path. Connection and TLS failures are now counted in refresh failure metrics via `send_refresh_jwt_keys_error()`, improving observability of network-level refresh errors (#7989).
- JWT/JWK auto-refresh now supports configuring the maximum response body size for fetched OpenID metadata and JWKS via the `jwt.key_refresh_max_response_size` node startup config setting (#7989).
- Fatal task worker stack traces now use libbacktrace for improved function and source-location resolution. Building CCF now requires the libbacktrace development package, and the RPM development package depends on `libbacktrace-static` (#7721).

### Removed

- The unused enclave-side HTTP client infrastructure (`RPCSessions::create_client`, `HTTPClientSession`, `HTTP2ClientSession`, `UnencryptedHTTPClientSession`, and the `ClientSession` base) has been removed following the migration of the node join client to curl, completing the legacy HTTP client removal tracked in #7262 (#8040).

### Fixed

- JS endpoint string values (e.g. response bodies, KV keys/values) containing embedded NUL bytes are no longer silently truncated at the first NUL when converted to their C++ representation (#8033).
Expand Down
241 changes: 241 additions & 0 deletions doc/dev/join_curl_migration_plan.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions doc/operations/start_network.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ To add a new node to an existing opening network, other nodes should be started

The joining node takes the certificate of the existing network to join via ``service_certificate_file`` configuration entry and initiates an enclave-to-enclave TLS connection to an existing node of the network as specified by ``join.target_rpc_address`` configuration entry.

.. note:: The joining node verifies the target node's TLS certificate against the ``service_certificate_file`` (which is the only trust anchor used for this connection) and checks that it matches the ``join.target_rpc_address`` host. Operators must therefore ensure that the target node's certificate subject alternative names - derived from its RPC interface ``published_address`` values, or set explicitly via ``node_certificate.subject_alt_names`` - include the address used in ``join.target_rpc_address``.

The join configuration option should be set in the :ref:`operations/configuration:``command.join``` section of the JSON configuration.

A new node can only join an existing CCF network if its hardware attestation is valid [#remote_attestation]_. and runs an enclave application that is :ref:`trusted by the consortium <governance/common_member_operations:Updating Code Version>`.
Expand Down
53 changes: 0 additions & 53 deletions src/enclave/client_session.h

This file was deleted.

78 changes: 0 additions & 78 deletions src/enclave/rpc_sessions.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
#include "node/session_metrics.h"
#include "rpc_handler.h"
#include "tls/cert.h"
#include "tls/client.h"
#include "tls/context.h"
#include "tls/plaintext_server.h"
#include "tls/server.h"
Expand Down Expand Up @@ -72,10 +71,6 @@ namespace ccf
sessions;
size_t sessions_peak = 0;

// Negative sessions are reserved for those originating from
// the enclave via create_client().
std::atomic<ccf::tls::ConnID> next_client_session_id = -1;

template <typename Base>
class NoMoreSessionsImpl : public Base
{
Expand All @@ -102,35 +97,6 @@ namespace ccf
}
};

ccf::tls::ConnID get_next_client_id()
{
auto id = next_client_session_id--;
const auto initial = id;

if (next_client_session_id > 0)
{
next_client_session_id = -1;
}

while (sessions.find(id) != sessions.end())
{
id--;

if (id > 0)
{
id = -1;
}

if (id == initial)
{
throw std::runtime_error(
"Exhausted all IDs for enclave client sessions");
}
}

return id;
}

ListenInterface& get_interface_from_interface_id(
const ccf::ListenInterfaceID& id)
{
Expand Down Expand Up @@ -557,50 +523,6 @@ namespace ccf
}
}

std::shared_ptr<ClientSession> create_client(
const std::shared_ptr<::tls::Cert>& cert,
const std::string& app_protocol = "HTTP1")
{
std::lock_guard<ccf::pal::Mutex> guard(lock);
auto ctx = std::make_unique<::tls::Client>(cert);
auto id = get_next_client_id();

LOG_DEBUG_FMT("Creating a new client session inside the enclave: {}", id);

// There are no limits on outbound client sessions (we do not check any
// session caps here). We expect this type of session to be rare and
// want it to succeed even when we are busy.
if (app_protocol == "HTTP2")
{
auto session = std::make_shared<::http::HTTP2ClientSession>(
id, writer_factory, std::move(ctx));
sessions.insert(std::make_pair(id, std::make_pair("", session)));
sessions_peak = std::max(sessions_peak, sessions.size());
return session;
}
if (app_protocol == "HTTP1")
{
auto session = std::make_shared<::http::HTTPClientSession>(
id, writer_factory, std::move(ctx));
sessions.insert(std::make_pair(id, std::make_pair("", session)));
sessions_peak = std::max(sessions_peak, sessions.size());
return session;
}

throw std::runtime_error("unsupported client application protocol");
}

std::shared_ptr<ClientSession> create_unencrypted_client()
{
std::lock_guard<ccf::pal::Mutex> guard(lock);
auto id = get_next_client_id();
auto session = std::make_shared<::http::UnencryptedHTTPClientSession>(
id, writer_factory);
sessions.insert(std::make_pair(id, std::make_pair("", session)));
sessions_peak = std::max(sessions_peak, sessions.size());
return session;
}

void register_message_handlers(
messaging::Dispatcher<ringbuffer::Message>& disp)
{
Expand Down
48 changes: 44 additions & 4 deletions src/http/curl.h
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,26 @@

namespace ccf::curl
{
// Returns true for libcurl transfer failures at the transport/protocol layer
// that are generally safe to retry: the peer may not be ready yet, a
// connection was dropped, or a transient HTTP/2 framing error occurred.
// Callers that run a retry loop (e.g. the node join client) use this to
// distinguish retryable transport failures from fatal TLS/certificate or
// application errors.
//
// This deliberately excludes CURLE_WRITE_ERROR: that indicates our own write
// callback rejected the response (e.g. it exceeded the caller's size cap),
// which is an anomalous response the caller should treat as fatal rather
// than retry indefinitely.
inline bool is_transient_transport_error(CURLcode code)
{
return code == CURLE_COULDNT_RESOLVE_PROXY ||
code == CURLE_COULDNT_RESOLVE_HOST || code == CURLE_COULDNT_CONNECT ||
code == CURLE_OPERATION_TIMEDOUT || code == CURLE_GOT_NOTHING ||
code == CURLE_RECV_ERROR || code == CURLE_SEND_ERROR ||
code == CURLE_PARTIAL_FILE || code == CURLE_WEIRD_SERVER_REPLY ||
code == CURLE_HTTP2 || code == CURLE_HTTP2_STREAM;
}

class UniqueCURL
{
Expand Down Expand Up @@ -238,6 +258,11 @@ namespace ccf::curl
return bytes_to_copy;
}

[[nodiscard]] size_t size() const
{
return unsent.size();
}

void attach_to_curl(CURL* curl)
{
if (curl == nullptr)
Expand Down Expand Up @@ -500,10 +525,25 @@ namespace ccf::curl
}
break;
case HTTP_POST:
// libcurl sets the post verb when CURLOPT_POSTFIELDS is set, so we
// skip doing so here, and we assume that the user has already set
// these fields
break;
{
CHECK_CURL_EASY_SETOPT(curl_handle, CURLOPT_POST, 1L);
if (request_body == nullptr)
{
// If no request body is provided, curl will try reading from
// stdin, which causes a blockage
request_body =
std::make_unique<RequestBody>(std::vector<uint8_t>());
}
// With CURLOPT_POST set and no CURLOPT_POSTFIELDS, libcurl obtains
// the request body from the read callback attached below. Declare
// the size so a Content-Length is sent rather than switching to
// chunked transfer encoding.
CHECK_CURL_EASY_SETOPT(
curl_handle,
CURLOPT_POSTFIELDSIZE_LARGE,
static_cast<curl_off_t>(request_body->size()));
}
break;
default:
throw std::logic_error(
fmt::format("Unsupported HTTP method: {}", method.c_str()));
Expand Down
69 changes: 0 additions & 69 deletions src/http/http2_session.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
#pragma once

#include "ds/internal_logger.h"
#include "enclave/client_session.h"
#include "enclave/rpc_map.h"
#include "error_reporter.h"
#include "http/http2_types.h"
Expand Down Expand Up @@ -434,72 +433,4 @@ namespace http
->set_on_stream_close_callback(cb);
}
};

class HTTP2ClientSession : public HTTP2Session,
public ccf::ClientSession,
public ::http::ResponseProcessor
{
private:
http2::ClientParser client_parser;

public:
HTTP2ClientSession(
int64_t session_id_,
ringbuffer::AbstractWriterFactory& writer_factory,
std::unique_ptr<ccf::tls::Context> ctx) :
HTTP2Session(session_id_, writer_factory, std::move(ctx)),
ccf::ClientSession(session_id_, writer_factory),
client_parser(*this)
{
client_parser.set_outgoing_data_handler(
[this](std::span<const uint8_t> data) {
send_data(std::vector<uint8_t>(data.begin(), data.end()));
});
}

bool parse(std::span<const uint8_t> data) override
{
// Catch response parsing errors and log them
try
{
client_parser.execute(data.data(), data.size());

return true;
}
catch (const std::exception& e)
{
LOG_FAIL_FMT("Error parsing HTTP2 response on session {}", session_id);
LOG_DEBUG_FMT("Error parsing HTTP2 response: {}", e.what());
LOG_DEBUG_FMT(
"Error occurred while parsing fragment {} byte fragment:\n{}",
data.size(),
std::string_view(
reinterpret_cast<char const*>(data.data()), data.size()));

close_session();
}
return false;
}

void send_request(http::Request&& request) override
{
client_parser.send_structured_request(
request.get_method(),
request.get_path(),
request.get_headers(),
{request.get_content_data(),
request.get_content_data() + request.get_content_length()});
}

void handle_response(
ccf::http_status status,
ccf::http::HeaderMap&& headers,
std::vector<uint8_t>&& body) override
{
handle_data_cb(status, std::move(headers), std::move(body));

LOG_TRACE_FMT("Closing connection, message handled");
close_session();
}
};
}
Loading