Skip to content

Migrate node join client from httpclient to libcurl#8040

Open
achamayou wants to merge 23 commits into
mainfrom
join-client-curl-migration
Open

Migrate node join client from httpclient to libcurl#8040
achamayou wants to merge 23 commits into
mainfrom
join-client-curl-migration

Conversation

@achamayou

Copy link
Copy Markdown
Member

Closes #7262.

Migrates the node join sequence - the last remaining user of the legacy enclave RPCSessions::create_client() HTTP client - to the curl multi singleton client already used for attestation endorsements, JWT/JWK refresh (#8005), and snapshot fetch. The now-unused httpclient infrastructure is then removed.

Approach (staged)

  1. curl wrapper: add POST-with-body support to ccf::curl::CurlRequest (+ curl_test coverage).
  2. Join migration: rewrite NodeState::initiate_join_unsafe to build an async curl POST to /node/join via CurlmLibuvContextSingleton, preserving every existing response branch (redirect, StartupSeqnoIsOld snapshot fetch, TRUSTED/PENDING, fatal errors).
  3. Test infra + e2e gates: update the join TLS-failure detection in the test infra and validate against the reconfiguration suite.
  4. Removal: delete make_http_request, RPCSessions::create_client/create_unencrypted_client, and the HTTPClientSession/HTTP2ClientSession/UnencryptedHTTPClientSession/ClientSession classes.
  5. Docs + changelog.

Security posture

The service certificate remains the sole trust anchor for the join connection: CURLOPT_CAINFO_BLOB installs it and CURLOPT_CAPATH=nullptr prevents any fallback to the host CA store, so the set of accepted CAs is identical to the legacy tls::CA path. Peer chain verification is preserved (VERIFYPEER=1), and the joining node still presents its self-signed node certificate for mutual TLS.

Behavioural change (hardening): TLS certificate hostname verification is now enforced on the join connection (VERIFYHOST=2); the legacy path used SNI only. Operators must ensure the target node's certificate SANs cover the address in join.target_rpc_address (derived from the interface published_address, or set via node_certificate.subject_alt_names). Documented in the CHANGELOG and start_network.rst.

Error semantics

The legacy join's fatal error callback was effectively dead code: transport failures (connection refused, unresolved host, timeouts) were silently retried by the periodic join timer, while only TLS handshake failures were fatal. This split is preserved - transient transport CURLcodes retry; TLS/certificate failures (including an untrusted service certificate) remain fatal and shut the node down.

Retained (not part of the httpclient)

The in-process NodeClient/HTTPNodeClient (used by retired-node cleanup) and the tls::Client primitive (server-side TLS + TLS unit tests) are kept.

Testing

  • Unit: curl_test (incl. new POST test), http_test, node_frontend_test, tls_test, channels_test, frontend_test.
  • e2e: reconfiguration.run_all - basic/snapshot/backup/redirect joins plus the test_add_node_invalid_service_cert security gate (wrong service cert -> CURLE_PEER_FAILED_VERIFICATION -> node stops).
  • lts_compatibility passed against previous LTS ccf-6.0.28 (cross-version join).
  • Format/lint gates and sphinx --fail-on-warning.

GitHub Copilot added 11 commits July 7, 2026 18:14
The CurlRequest HTTP_POST branch previously assumed the caller had pre-set
CURLOPT_POSTFIELDS and did not enable CURLOPT_POST, so passing a RequestBody
for a POST did not transmit the body. Enable CURLOPT_POST and declare the
body size via CURLOPT_POSTFIELDSIZE_LARGE so the existing RequestBody read
callback is used and a Content-Length is sent. Adds a curl_test case that
posts a body to the echo server and asserts the method and body round-trip.
Replace the RPCSessions::create_client join path with an async curl
request via CurlmLibuvContextSingleton, matching the JWT refresh and
snapshot fetch clients. The service certificate remains the sole trust
anchor (CURLOPT_CAINFO_BLOB + CURLOPT_CAPATH=nullptr), peer verification
is enforced (VERIFYPEER=1), and hostname verification is now enabled
(VERIFYHOST=2). The joining node presents its self-signed node
certificate for mutual TLS. All existing response-handling branches
(redirect, StartupSeqnoIsOld snapshot fetch, TRUSTED/PENDING, fatal
errors) are preserved.
The legacy httpclient join path only invoked its fatal error callback for
TLS handshake failures; transport-level failures (connection refused,
unresolved host, timeouts) silently closed the connection and were
retried by the periodic join timer. The initial curl migration treated
every non-OK CURLcode as fatal, which caused nodes to shut down on a
transient 'could not connect' during normal startup races.

Categorise transient transport errors (COULDNT_RESOLVE_*, COULDNT_CONNECT,
OPERATION_TIMEDOUT, GOT_NOTHING, RECV/SEND_ERROR, PARTIAL_FILE) as
retryable, and keep TLS/protocol failures (including peer verification
failures for an untrusted service certificate) fatal. Add connect and
total timeouts so stalled attempts are abandoned for the timer to retry.
The curl-based join client reports an untrusted service certificate as
"invalid service certificate" rather than the legacy TLS session's
"invalid cert on handshake". Update the ServiceCertificateInvalid
detection to match the new wording, retaining the old string for
mixed-version test compatibility.
With the node join migrated to the curl client, the enclave-side HTTP
client sessions are no longer used. Remove make_http_request (and its
AbstractNodeState declaration), RPCSessions::create_client and
create_unencrypted_client (and the now-dead client session id helper),
and the HTTPClientSession, HTTP2ClientSession and
UnencryptedHTTPClientSession classes along with the ClientSession base.

The in-process NodeClient/HTTPNodeClient (used by retired node cleanup)
and the tls::Client primitive (still exercised by the TLS unit tests and
underpinning server-side TLS) are retained. Completes the httpclient
removal tracked in #7262.
Add CHANGELOG entries for the join client migration to curl (including
the new TLS hostname-verification requirement on the join connection)
and the removal of the legacy httpclient infrastructure. Add an operator
note to the start-network docs explaining that the target node's
certificate SANs must cover join.target_rpc_address.
…t errors

Move the join client's transient-vs-fatal CURLcode decision into a
reusable, unit-tested ccf::curl::is_transient_transport_error() helper,
and add CURLE_WEIRD_SERVER_REPLY, CURLE_HTTP2 and CURLE_HTTP2_STREAM to
the retryable set. The legacy httpclient join silently retried these
transfer/parse failures via the periodic timer, whereas the initial curl
migration treated everything outside a small allow-list as fatal, so a
transient garbled reply or HTTP/2 framing error during a startup race
could shut a node down. CURLE_WRITE_ERROR (our own response size-cap
rejection) remains fatal by design. Adds a table-driven curl_test that
pins the full classification.
The curl response callback runs on the shared host libuv event loop
thread. The previous code did all join processing there under
NodeState::lock, including deserialising a potentially large snapshot and
issuing KV/ledger writes. That stalls every other user of the shared curl
loop (JWT refresh, quote endorsements, recovery decision) and risks a
deadlock: the blocking host ring-buffer writer spin-waits for space while
its only consumer runs on that same libuv thread.

Restructure the callback to mirror the JWT refresh client: on the libuv
thread it only captures the response (headers and body) and schedules a
ccf::tasks task; all NodeState::lock-holding work then runs on a task
thread, as it did before the curl migration (when the callback ran on the
enclave RPC thread). This also removes the latent self-deadlock where a
synchronous abort during shutdown could re-enter the callback while lock
was held. Aborted requests are dropped on the libuv thread without
scheduling a task.
Guard the TLS hostname-verification hardening the node join client relies
on. The e2e_curl harness now also serves HTTPS with a self-signed
certificate whose only SAN is a dNSName that does not cover the loopback
IP it is served on. A new curl_test then asserts, with that certificate
pinned as the CA, that CURLOPT_SSL_VERIFYHOST=2 rejects a request to the
IP (CURLE_PEER_FAILED_VERIFICATION), accepts one to the SAN name resolved
to the same address, and that VERIFYHOST=0 accepts the mismatch (control,
proving the hostname check is the sole discriminator). The test skips
cleanly when run outside the harness.
@achamayou achamayou added the run-long-test Run Long Test job label Jul 8, 2026
@achamayou achamayou requested a review from Copilot July 8, 2026 08:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR completes the migration of the node join client from the legacy in-enclave HTTP client (RPCSessions::create_client()) to the existing libcurl multi singleton, and removes the now-unused legacy HTTP client/session infrastructure. This aligns join with the already-migrated JWT/JWK refresh, endorsements, and snapshot fetch clients, while hardening join by enforcing TLS hostname verification.

Changes:

  • Extend the curl wrapper to support POST-with-body and add curl unit/e2e coverage (including VERIFYHOST behavior).
  • Rewrite NodeState::initiate_join_unsafe() to perform /node/join via CurlmLibuvContextSingleton, preserving prior join control-flow branches and error semantics.
  • Remove legacy enclave-side HTTP client sessions/APIs, update docs and changelog, and adjust test infra log matching for invalid service certificates.

Custom instructions used:

  • .github/copilot-instructions.md
  • .github/instructions/reviewing.instructions.md

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/infra/network.py Updates join failure log matching to recognize the new curl-based “invalid service certificate” wording while retaining legacy compatibility.
tests/e2e_curl.py Extends the curl e2e harness to stand up an HTTPS endpoint with a controlled SAN mismatch for VERIFYHOST coverage.
src/node/rpc/node_interface.h Removes the legacy make_http_request API from the node interface.
src/node/node_state.h Migrates join POST to async curl-multi with pinned service cert, mTLS client cert/key, and VERIFYHOST enforcement.
src/http/test/curl_test.cpp Adds unit coverage for POST-with-body and VERIFYHOST SAN mismatch behavior, plus error classification tests.
src/http/http2_session.h Removes the legacy HTTP2ClientSession implementation.
src/http/http_session.h Removes the legacy HTTPClientSession and UnencryptedHTTPClientSession implementations.
src/http/curl.h Adds retryability classification helper and first-class POST-with-body support in CurlRequest.
src/enclave/rpc_sessions.h Removes create_client/create_unencrypted_client and associated client-session ID plumbing.
src/enclave/client_session.h Deletes the legacy ClientSession base class.
doc/operations/start_network.rst Documents the new join-time TLS hostname verification requirement and SAN/operator implications.
doc/dev/join_curl_migration_plan.md Adds a detailed internal migration plan and rationale for the staged approach and validation gates.
CHANGELOG.md Records the join migration + hostname verification hardening and the removal of the legacy enclave HTTP client infra.

Comment thread src/node/node_state.h Outdated
achamayou and others added 4 commits July 8, 2026 14:22
The periodic join timer fires every config.join.retry_timeout (default 1s), but a join attempt can remain in flight for up to CONNECTTIMEOUT/TIMEOUT. Because the join client shares the CurlmLibuvContextSingleton with the JWT refresh, endorsements and snapshot-fetch clients, unguarded retries against a slow or unresponsive target could accumulate in-flight requests and starve those other users.

Track an atomic join_request_in_flight flag: initiate_join_unsafe() skips the retry if a request is already in flight, sets the flag before attaching the request, and the response callback clears it (on the libuv thread, so via an atomic store rather than NodeState::lock) for every completion path including synchronous abort during shutdown.
Follow-up to the single-in-flight join gate. If attach_request (or the singleton accessor) throws after the in-flight flag is set - realistically only under an OOM-class bad_alloc - the response callback never runs, so the flag would remain set and the periodic timer would gate out every future retry, stranding the node in pending. Wrap the handoff so the flag is reset on the exceptional path.

Also add two clarifying comments: that join liveness depends on the curl singleton invoking each request callback exactly once, and that start_join_timer's initial initiate_join_unsafe call relies on the caller holding NodeState::lock.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.

Comment thread src/node/node_state.h
Comment thread tests/infra/network.py Outdated
GitHub Copilot added 2 commits July 9, 2026 06:43
The join client flagged CURLE_PEER_FAILED_VERIFICATION (and CURLE_SSL_CACERT_BADFILE) as an "invalid service certificate", but with VERIFYHOST=2 that CURLcode is also returned for a hostname/SAN mismatch or any other peer certificate verification failure, so the wording could mislead operators. Replace the marker with the accurate "TLS certificate verification failed", rename the local bool, and broaden the code comment.

Update the test infra matcher and comment in network.py to the new marker (still matching the legacy "invalid cert on handshake" wording for mixed-version tests). The marker was introduced by this unreleased PR, so there is no released node emitting the old text.
Address review nits on the join certificate-failure path. The fatal branch fires for both CURLE_PEER_FAILED_VERIFICATION and CURLE_SSL_CACERT_BADFILE, but the previous "TLS certificate verification failed" marker and comments only described peer verification, not the case where the configured service certificate blob cannot be loaded (CURLE_SSL_CACERT_BADFILE).

Reword the marker to the more accurate "TLS certificate trust check failed" (updating the local bool and the test-infra matcher to match), expand both code comments to enumerate both underlying causes, and document the ServiceCertificateInvalid test exception, which now covers all join-time certificate trust failures rather than only a literally invalid certificate.
@achamayou achamayou marked this pull request as ready for review July 9, 2026 08:31
@achamayou achamayou requested a review from a team as a code owner July 9, 2026 08:31
Comment thread doc/dev/join_curl_migration_plan.md Outdated

Copy link
Copy Markdown
Member Author

This migration plan was drafted during development. Keeping it as a PR comment for reviewer context rather than committing it to the repository (it previously lived at doc/dev/join_curl_migration_plan.md).

Migrating the node join client from the legacy HTTP client to libcurl

Tracking issue: #7262 - "Migrate
httpclients over to libcurl". The node join sequence is the last remaining
user of RPCSessions::create_client() (the legacy in-enclave TLS HTTP client).
Migrating it lets us delete the remaining legacy HTTP client infrastructure.

Reference implementation: #8005,
which migrated JWT/JWK OpenID discovery and key refresh to the shared curl-multi
client. The endorsements client (#7102) and snapshot fetch already use the same
curl client, and the snapshot fetch runs inside the join flow, so there is a
directly comparable node-to-node precedent.

Goals

  • Replace the legacy HTTP client in NodeState::initiate_join_unsafe() with the
    async curl-multi client (ccf::curl::CurlmLibuvContextSingleton).
  • Preserve the join trust model exactly: the service certificate is the only
    trust anchor
    , and the joining node presents its self-signed node certificate
    as a client certificate (mutual TLS).
  • Strengthen TLS peer verification by enabling certificate hostname
    verification (CURLOPT_SSL_VERIFYHOST=2), which the legacy path did not do.
  • Preserve every existing join control-flow branch (redirects, snapshot re-fetch
    on StartupSeqnoIsOld, pending/trusted handling, fatal errors).
  • Remove the now-dead legacy HTTP client infrastructure.
  • Lean on existing test coverage as validation gates, and plug the gaps that the
    behavioural change introduces.

Non-goals

  • No change to the /node/join server endpoint or the join wire protocol.
  • No change to the in-process node client (NodeClient / HTTPNodeClient),
    which is a loopback handler dispatch, not a network/TLS client.
  • No change to the server-side TLS/HTTP session stack (nodes still serve RPC).

Current implementation (baseline)

NodeState::initiate_join_unsafe() in src/node/node_state.h:

auto network_ca = std::make_shared<::tls::CA>(std::string(
  config.join.service_cert.begin(), config.join.service_cert.end()));

auto join_client_cert = std::make_unique<::tls::Cert>(
  network_ca,
  self_signed_node_cert,
  node_sign_kp->private_key_pem(),
  target_host);

auto join_client = rpcsessions->create_client(
  std::move(join_client_cert),
  rpcsessions->get_app_protocol_main_interface());

join_client->connect(target_host, target_port, /* response cb */, /* error cb */);
join_client->send_request(POST /node/join with JSON body);

Relevant properties of the baseline:

  • Trust anchor: config.join.service_cert only. ::tls::CA builds an
    X509_STORE containing exactly that certificate; the system CA bundle is never
    consulted. partial_ok is false (full chain to the service cert required).
  • Peer verification: SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT with
    standard OpenSSL chain validation.
  • Hostname verification: none. ::tls::Cert uses peer_hostname only for
    SNI (SSL_set_tlsext_host_name); there is no X509_VERIFY_PARAM_set1_host
    call, so the target's certificate name is not checked.
  • Client authentication: the node presents self_signed_node_cert +
    node_sign_kp private key (at join time the node is not yet endorsed, so
    endorsed_node_cert is std::nullopt).
  • Concurrency: asynchronous. connect() returns immediately; the response
    callback runs later and processes the result under NodeState::lock guarded by
    sm.check(NodeStartupState::pending). The lock is not held during network
    I/O.
  • Retry: join_periodic_task re-invokes initiate_join_unsafe() on a timer.

Join response-handling branches to preserve

  1. Transport/handshake failure -> fatal: write AdminMessage::fatal_error_msg,
    node shuts down gracefully (not retried).
  2. 4xx with ODataError code StartupSeqnoIsOld and fetch_recent_snapshot ->
    schedule a FetchSnapshot task, return, and let the periodic timer retry.
  3. Other 4xx -> fatal shutdown.
  4. 307/308 with follow_redirect and a Location header -> update
    config.join.target_rpc_address and let the periodic timer retry.
  5. Other non-200 -> log failure, wait for retry.
  6. 200 OK, node_status == TRUSTED -> initialise identity / ledger secrets /
    consensus, become part of (public) network, cancel the join timer.
  7. 200 OK, node_status == PENDING -> wait for member votes (retry).

Target implementation

Model on JwtKeyAutoRefresh::send_curl_get() (src/node/jwt_key_auto_refresh.h)
for the TLS options and async dispatch, and on recovery_decision_protocol.cpp
for the client-certificate (mTLS) options.

curl easy-handle options for the join request:

Option Value Rationale
CURLOPT_CAINFO_BLOB config.join.service_cert Trust anchor = service cert only (matches baseline).
CURLOPT_CAPATH nullptr Do not fall back to the system CA bundle. Critical: keeps the accepted-CA set identical to the baseline.
CURLOPT_SSL_VERIFYPEER 1L Verify the peer chain (matches baseline).
CURLOPT_SSL_VERIFYHOST 2L Verify the peer certificate name (hardening; see below).
CURLOPT_PROTOCOLS_STR "https" Restrict to HTTPS (matches JWT refresh).
CURLOPT_SSLCERT_BLOB self-signed node cert Client cert for mTLS (matches baseline).
CURLOPT_SSLCERTTYPE "PEM"
CURLOPT_SSLKEY_BLOB node signing key Client key for mTLS (matches baseline).
CURLOPT_SSLKEYTYPE "PEM"
CURLOPT_CONNECTTIMEOUT / CURLOPT_TIMEOUT small fixed values Bound transport time (matches JWT refresh).
  • Method/body: POST https://{target_rpc_address}/node/join with the JSON
    JoinNetworkNodeToNode::In body and Content-Type: application/json.
  • Response body cap: a fixed, generous ccf::curl::ResponseBody maximum
    (large enough for any realistic join response - identity, ledger secrets - but
    far below a snapshot). No new operator-facing config option.
  • Redirects: do not set CURLOPT_FOLLOWLOCATION. Handle redirects
    manually exactly as today (inspect status + Location, update
    target_rpc_address), so the trust anchor is re-applied to the new target and
    the snapshot fetch picks up the redirected address.
  • Dispatch: build the request under lock, then
    CurlmLibuvContextSingleton::get_instance()->attach_request(...) and return.
    The single ResponseCallback re-acquires lock, re-checks
    sm.check(pending), and maps CURLcode + HTTP status to the seven branches
    above. Transport/TLS failures (CURLcode != CURLE_OK, e.g.
    CURLE_PEER_FAILED_VERIFICATION, CURLE_SSL_CACERT) map to the current fatal
    path and must log a single stable, greppable message so the test suite can
    detect a rejected service certificate.

POST-with-body support in the curl wrapper (prerequisite)

ccf::curl::CurlRequest currently wires GET, HEAD and PUT (upload) fully, but the
HTTP_POST branch assumes the caller pre-set CURLOPT_POSTFIELDS and does not
enable CURLOPT_POST. Add first-class POST-with-body support (set CURLOPT_POST
and CURLOPT_POSTFIELDSIZE, feeding the existing RequestBody read callback, or
an equivalent COPYPOSTFIELDS helper) so the join can send a JSON body cleanly,
and cover it in src/http/test/curl_test.cpp.

Security analysis: which root CAs are accepted

Property Baseline (legacy client) Migrated (curl) Verdict
Accepted trust anchors service cert only (::tls::CA store) CAINFO_BLOB = service cert, CAPATH = nullptr Identical
System CA fallback never disabled via CAPATH=nullptr Identical
Chain building full chain, partial_ok=false curl default (no partial chain) Identical (service identity is a self-signed root; snapshot fetch already proves this against the same peer)
Peer chain verification SSL_VERIFY_PEER SSL_VERIFYPEER=1 Identical
Certificate name check none (SNI only) SSL_VERIFYHOST=2 Strengthened
Client auth (mTLS) self-signed node cert SSLCERT_BLOB self-signed node cert Identical

Hostname verification hardening. Enabling VERIFYHOST=2 means a join fails if
the host in join.target_rpc_address is not present in the target node's
certificate SANs. CCF derives node-cert SANs from
config.node_certificate.subject_alt_names, or, by default, from each RPC
interface's published_address (get_subject_alternative_names()), with IPs
emitted as iPAddress SANs and names as dNSName. Redirect targets are built
from those same published addresses. The snapshot fetch inside the join flow
already uses curl with the default VERIFYHOST=2 against the same
target_rpc_address and same service_cert, so standard deployments and the
test suite already satisfy this constraint. The residual risk is a deployment that
connects to an address deliberately absent from the target SANs; this is a
behavioural change and must be documented in CHANGELOG.md.

Test strategy and validation gates

Existing coverage to lean on

  • Security gate: tests/reconfiguration.py::test_add_node_invalid_service_cert
    joins with the wrong service certificate and expects
    infra.network.ServiceCertificateInvalid. That exception is raised in
    tests/infra/network.py::run_join_node() by matching the log string
    "invalid cert on handshake" (emitted by src/enclave/tls_session.h). The curl
    path emits a different message, so this detection string must be updated to
    match the new stable TLS-failure log. This is the primary gap.
  • Happy paths: test_add_node, test_add_node_from_snapshot,
    test_add_node_from_backup, test_add_as_many_pending_nodes.
  • Redirects: test_join_straddling_primary_replacement, and
    start_network.py --redirection_kind node-by-role.
  • Fake/duplicate joins: test_issue_fake_join, node_frontend_test.cpp.
  • Attestation error mapping: code_update.py SNP join tests exercise the
    MeasurementNotFound / HostDataNotFound / UVMEndorsementsNotAuthorised
    detection in run_join_node(); these must still work with curl error handling.
  • Recovery / public-network join: recovery.py.
  • curl mechanics: src/http/test/curl_test.cpp.

Gaps to plug

  1. Update the ServiceCertificateInvalid detection in tests/infra/network.py
    to the new curl TLS-failure log line.
  2. Add curl_test.cpp coverage for POST-with-body and mTLS client certificates.
  3. Add/confirm coverage that VERIFYHOST=2 succeeds for both DNS-name and IP
    published addresses, and after a redirect.
  4. (Recommended) Add a negative test that a join to an address not in the
    target SANs is now rejected, to regression-protect the hardening.

Removal scope (after the join no longer uses the legacy client)

Remove (confirmed to have no other users once the join and the dead
make_http_request are gone):

  • NodeState::make_http_request() (src/node/node_state.h) and the
    AbstractNodeState::make_http_request declaration
    (src/node/rpc/node_interface.h). Already dead (no callers).
  • RPCSessions::create_client() and RPCSessions::create_unencrypted_client()
    (src/enclave/rpc_sessions.h). The latter is already dead.
  • ClientSession (src/enclave/client_session.h), HTTPClientSession
    (src/http/http_session.h), HTTP2ClientSession (src/http/http2_session.h),
    UnencryptedHTTPClientSession (src/http/http_session.h), plus their includes.

Keep:

  • NodeClient / HTTPNodeClient (src/node/node_client.h,
    src/node/http_node_client.h): in-process handler dispatch
    (RpcHandler::process), not a network/TLS client. Used by
    retired_nodes_cleanup.h and raft.h.
  • ::tls::Client (src/tls/client.h): a TLS-layer primitive still exercised by
    the TLS unit test (src/tls/test/main.cpp); the server side still relies on the
    TLS layer. (Optional stretch: remove it too and trim the test.)
  • All server-side TLS/HTTP session classes.

Staged delivery (each stage is independently reviewable and gated)

  • Stage 0 - Branch + this plan. Branch join-client-curl-migration.
  • Stage 1 - curl wrapper POST-with-body. Extend ccf::curl::CurlRequest +
    curl_test.cpp coverage (POST body, mTLS). Gate: curl_test.
  • Stage 2 - curl-based join. Rewrite the client half of
    initiate_join_unsafe(); remove the legacy tls::CA/tls::Cert/create_client
    join code. Preserve all seven branches. Gate: build + node_frontend_test.
  • Stage 3 - Test infra + e2e gates. Update the ServiceCertificateInvalid
    detection string; run the join e2e gates; add the hostname-verification
    coverage. Gate: e2e join suite green.
  • Stage 4 - Remove dead httpclient infra. Delete the removal set above; grep
    for zero references; build all targets and run affected unit tests. Gate: full
    build + unit tests.
  • Stage 5 - Docs, changelog, compatibility. CHANGELOG.md (Changed: curl
    join migration + VERIFYHOST hardening migration note + infra removal), bump
    python/pyproject.toml if a new version is cut, review
    doc/operations/start_network.rst wording, run ci-checks.sh and formatting/
    linting, and run lts_compatibility with LONG_TESTS=1 (join touches
    cross-version TLS and protocol paths).

Comment thread src/node/node_state.h

@cjen1-msft cjen1-msft left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me.
One comment regarding an alternative way to sequence the retries which might simplify things (it did for fetch):
You can rely on the exactly once response from the curl backend to sequence the retries as you have perfect info of why the response failed/timed-out/succeeded.

achamayou and others added 6 commits July 9, 2026 09:58
- Remove doc/dev/join_curl_migration_plan.md; the plan is preserved as a comment on PR #8040 rather than shipped in the tree.
- Add an explanatory comment for CURLOPT_POST=1L in the curl wrapper, linking to the libcurl documentation.
- Split the CHANGELOG entry so the new join-time target RPC address SAN check (CURLOPT_SSL_VERIFYHOST) is highlighted as its own item.
7.0.7 has been released, so this PR's entries belong in a new dev version. Open [7.0.8] with the #8040 Changed and Removed entries and bump python/pyproject.toml to 7.0.8 to match.
- Set CURLOPT_INFILESIZE only for PUT uploads; POST already declares its body size via CURLOPT_POSTFIELDSIZE_LARGE, so the shared attach_to_curl no longer sets it redundantly.
- Map CURLE_WRITE_ERROR on the join to a clear fatal message naming the response size cap, instead of libcurl's generic write error.
Since the join client was migrated to libcurl, a joining node verifies
(CURLOPT_SSL_VERIFYHOST=2) that the target node's certificate covers the
address it dialled. Add run_tls_san_join_mismatch, a dedicated-network test
where the target's file-serving interface binds an address deliberately
absent from the node certificate SANs, so a joining node dialling it is
rejected with ServiceCertificateInvalid. This exercises the hostname/SAN
check specifically, distinct from the untrusted-service-certificate path
already covered by test_add_node_invalid_service_cert.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-long-test Run Long Test job

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Migrate httpclients over to libcurl

3 participants