From aaab091a85f1326cbef3417ca9e31680be584ea7 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Tue, 23 Jun 2026 13:43:46 +0000 Subject: [PATCH 01/59] Add LoopExecutor --- CMakeLists.txt | 5 ++ src/host/loop_executor.h | 82 ++++++++++++++++++++++ src/host/test/loop_executor.cpp | 118 ++++++++++++++++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 src/host/loop_executor.h create mode 100644 src/host/test/loop_executor.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8e5283c7d927..98cb3af6bea0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -720,6 +720,11 @@ if(BUILD_TESTS) ) target_link_libraries(rpc_connections_test PRIVATE uv) + add_unit_test( + loop_executor_test + ${CMAKE_CURRENT_SOURCE_DIR}/src/host/test/loop_executor.cpp + ) + add_unit_test( raft_test ${CMAKE_CURRENT_SOURCE_DIR}/src/consensus/aft/test/main.cpp diff --git a/src/host/loop_executor.h b/src/host/loop_executor.h new file mode 100644 index 000000000000..798923ab695c --- /dev/null +++ b/src/host/loop_executor.h @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +#include "ccf/pal/locking.h" + +#include +#include +#include +#include +#include + +namespace asynchost +{ + // A thread-safe queue of work items to be executed on the libuv loop thread. + // + // Any thread may enqueue work via enqueue(); the work is run later, on the + // loop thread, when flush() is called (typically driven by a libuv Timer via + // on_timer()). This mirrors the existing host pattern of draining the + // enclave->host ringbuffer on a periodic Timer, and provides a safe way to + // marshal operations that must run on the loop thread (e.g. libuv socket + // operations, which are not thread-safe) when they are requested from other + // threads (e.g. the enclave worker threads). + class LoopExecutorImpl + { + public: + using Work = std::function; + + private: + ccf::pal::Mutex lock; + std::vector pending; + + public: + // May be called from any thread. + void enqueue(Work work) + { + std::lock_guard guard(lock); + pending.emplace_back(std::move(work)); + } + + // Must be called on the loop thread. Runs all work that was queued at the + // point of the call, in the order it was enqueued. Work enqueued while + // flushing (including by the work items themselves) is left for a + // subsequent flush, so this never loops indefinitely. + void flush() + { + std::vector to_run; + { + std::lock_guard guard(lock); + std::swap(to_run, pending); + } + + for (auto& work : to_run) + { + work(); + } + } + + // Called by the driving Timer on the loop thread. + void on_timer() + { + flush(); + } + }; + + // Timer behaviour that drains a LoopExecutorImpl on the loop thread. Drive + // this with an asynchost::Timer at a small interval, e.g. + // proxy_ptr> t(1ms, executor); + struct LoopExecutorDrainer + { + std::shared_ptr executor; + + explicit LoopExecutorDrainer(std::shared_ptr executor_) : + executor(std::move(executor_)) + {} + + void on_timer() + { + executor->flush(); + } + }; +} diff --git a/src/host/test/loop_executor.cpp b/src/host/test/loop_executor.cpp new file mode 100644 index 000000000000..a890e7adf9ca --- /dev/null +++ b/src/host/test/loop_executor.cpp @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. + +#include "host/loop_executor.h" + +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include +#include +#include +#include + +using namespace asynchost; + +TEST_CASE("LoopExecutor runs queued work on flush") +{ + LoopExecutorImpl executor; + + int counter = 0; + executor.enqueue([&]() { counter += 1; }); + executor.enqueue([&]() { counter += 10; }); + + // Nothing runs until flush() + REQUIRE(counter == 0); + + executor.flush(); + REQUIRE(counter == 11); + + // A second flush with no pending work is a no-op + executor.flush(); + REQUIRE(counter == 11); +} + +TEST_CASE("LoopExecutor preserves enqueue order") +{ + LoopExecutorImpl executor; + + std::vector order; + constexpr int n = 100; + for (int i = 0; i < n; ++i) + { + executor.enqueue([&order, i]() { order.push_back(i); }); + } + + executor.flush(); + + REQUIRE(order.size() == n); + for (int i = 0; i < n; ++i) + { + REQUIRE(order[i] == i); + } +} + +TEST_CASE("LoopExecutor defers work enqueued during flush") +{ + LoopExecutorImpl executor; + + int outer = 0; + int inner = 0; + executor.enqueue([&]() { + outer += 1; + // Work enqueued while flushing must not run during this same flush. + executor.enqueue([&]() { inner += 1; }); + }); + + executor.flush(); + REQUIRE(outer == 1); + REQUIRE(inner == 0); + + // The re-entrantly enqueued work runs on the next flush. + executor.flush(); + REQUIRE(outer == 1); + REQUIRE(inner == 1); +} + +TEST_CASE("LoopExecutor is safe under concurrent producers") +{ + LoopExecutorImpl executor; + + std::atomic executed{0}; + std::atomic draining{true}; + + constexpr int num_producers = 8; + constexpr int per_producer = 10000; + + // A single "loop thread" continuously draining the executor. + std::thread loop_thread([&]() { + while (draining.load()) + { + executor.flush(); + } + // Final drain to catch anything enqueued just before we stopped. + executor.flush(); + }); + + std::vector producers; + producers.reserve(num_producers); + for (int p = 0; p < num_producers; ++p) + { + producers.emplace_back([&]() { + for (int i = 0; i < per_producer; ++i) + { + executor.enqueue([&executed]() { executed.fetch_add(1); }); + } + }); + } + + for (auto& t : producers) + { + t.join(); + } + + // Stop draining once all work has been enqueued; the loop thread will do a + // final flush before exiting. + draining.store(false); + loop_thread.join(); + + REQUIRE(executed.load() == num_producers * per_producer); +} From 113d71ce6399aeed81b3db8e5ce2760408efc9a4 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Wed, 24 Jun 2026 13:50:30 +0000 Subject: [PATCH 02/59] checkpoint: OpenSSL-native transport subsystem (green) before RPC cutover --- CMakeLists.txt | 5 + include/ccf/node/session.h | 7 +- src/enclave/abstract_rpc_sessions.h | 57 ++ src/enclave/no_more_sessions.h | 45 ++ src/enclave/rpc_sessions.h | 27 +- src/enclave/session.h | 7 +- src/enclave/session_writer.h | 57 ++ src/host/rpc_connection_manager.h | 758 +++++++++++++++++++++++++ src/host/rpc_socket_set.h | 263 +++++++++ src/host/test/openssl_server_test.cpp | 312 ++++++++++ src/host/tls/openssl_server.h | 708 +++++++++++++++++++++++ src/host/tls/openssl_session_manager.h | 140 +++++ src/node/node_state.h | 4 +- src/quic/quic_session.h | 12 +- 14 files changed, 2376 insertions(+), 26 deletions(-) create mode 100644 src/enclave/abstract_rpc_sessions.h create mode 100644 src/enclave/no_more_sessions.h create mode 100644 src/enclave/session_writer.h create mode 100644 src/host/rpc_connection_manager.h create mode 100644 src/host/rpc_socket_set.h create mode 100644 src/host/test/openssl_server_test.cpp create mode 100644 src/host/tls/openssl_server.h create mode 100644 src/host/tls/openssl_session_manager.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 98cb3af6bea0..fb533d3a662f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -725,6 +725,11 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/src/host/test/loop_executor.cpp ) + add_unit_test( + openssl_server_test + ${CMAKE_CURRENT_SOURCE_DIR}/src/host/test/openssl_server_test.cpp + ) + add_unit_test( raft_test ${CMAKE_CURRENT_SOURCE_DIR}/src/consensus/aft/test/main.cpp diff --git a/include/ccf/node/session.h b/include/ccf/node/session.h index da58f763a5ec..75d7ab1b05de 100644 --- a/include/ccf/node/session.h +++ b/include/ccf/node/session.h @@ -4,6 +4,7 @@ #include #include +#include namespace ccf { @@ -12,7 +13,11 @@ namespace ccf public: virtual ~Session() = default; - virtual void handle_incoming_data(std::span data) = 0; + // Inbound bytes for this session. `addr` is the source address of the + // datagram for connectionless (UDP) transports, and is unused (default) for + // stream (TCP) transports. + virtual void handle_incoming_data( + std::span data, sockaddr addr = {}) = 0; virtual void send_data(std::vector&& data) = 0; virtual void close_session() = 0; }; diff --git a/src/enclave/abstract_rpc_sessions.h b/src/enclave/abstract_rpc_sessions.h new file mode 100644 index 000000000000..4babaf4519c2 --- /dev/null +++ b/src/enclave/abstract_rpc_sessions.h @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +#include "ccf/crypto/pem.h" +#include "ccf/service/node_info_network.h" +#include "enclave/client_session.h" +#include "forwarder_types.h" +#include "node/session_metrics.h" + +#include +#include + +namespace tls +{ + class Cert; +} + +namespace ccf +{ + class CustomProtocolSubsystem; + class CommitCallbackSubsystem; + + // The slice of RPC session management that the node (NodeState, frontends, + // Enclave, jwt refresh) depends on, independent of how connections are + // actually serviced. Both the legacy RPCSessions (ringbuffer/host-split) and + // the new host-side RPCConnectionManager implement this, so node-side code can + // hold a reference without depending on the concrete networking backend. + class AbstractRPCSessions : public AbstractRPCResponder + { + public: + ~AbstractRPCSessions() override = default; + + // Outbound client sessions (join, JWT refresh, redirects). + virtual std::shared_ptr create_client( + const std::shared_ptr<::tls::Cert>& cert, + const std::string& app_protocol = "HTTP1") = 0; + + virtual ccf::ApplicationProtocol get_app_protocol_main_interface() + const = 0; + + virtual ccf::SessionMetrics get_session_metrics() = 0; + + virtual void set_node_cert( + const ccf::crypto::Pem& cert, const ccf::crypto::Pem& pk) = 0; + virtual void set_network_cert( + const ccf::crypto::Pem& cert, const ccf::crypto::Pem& pk) = 0; + + virtual void update_listening_interface_options( + const ccf::NodeInfoNetwork& node_info) = 0; + + virtual void set_custom_protocol_subsystem( + std::shared_ptr cpss) = 0; + virtual void set_commit_callbacks_subsystem( + std::shared_ptr fcss) = 0; + }; +} diff --git a/src/enclave/no_more_sessions.h b/src/enclave/no_more_sessions.h new file mode 100644 index 000000000000..1b70845c89b4 --- /dev/null +++ b/src/enclave/no_more_sessions.h @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +#include "ccf/odata_error.h" +#include "enclave/tls_session.h" + +namespace ccf +{ + // Session wrapper used when an interface is over its soft session limit. + // + // It completes the TLS handshake far enough to send a single 503 response + // explaining that the service is busy, then closes the connection. It is + // templated on the concrete server session type (HTTPServerSession / + // HTTP2ServerSession) so it reuses that session's TLS plumbing and response + // machinery. + // + // Previously nested inside RPCSessions; pulled out so both the legacy + // RPCSessions and the new RPCConnectionManager can share it. + template + class NoMoreSessionsImpl : public Base + { + public: + template + NoMoreSessionsImpl(Ts&&... ts) : Base(std::forward(ts)...) + {} + + void handle_incoming_data_thread(std::vector&& data) override + { + Base::tls_io->recv_buffered(data.data(), data.size()); + + if (Base::tls_io->get_status() == ccf::SessionStatus::ready) + { + // Send response describing soft session limit + Base::send_odata_error_response(ccf::ErrorDetails{ + HTTP_STATUS_SERVICE_UNAVAILABLE, + ccf::errors::SessionCapExhausted, + "Service is currently busy and unable to serve new connections"}); + + // Close connection + Base::tls_io->close(); + } + } + }; +} diff --git a/src/enclave/rpc_sessions.h b/src/enclave/rpc_sessions.h index ec919cda8041..2c47aacf8210 100644 --- a/src/enclave/rpc_sessions.h +++ b/src/enclave/rpc_sessions.h @@ -6,6 +6,7 @@ #include "ccf/service/node_info_network.h" #include "ds/internal_logger.h" #include "ds/serialized.h" +#include "enclave/abstract_rpc_sessions.h" #include "enclave/session.h" #include "forwarder_types.h" #include "http/http2_session.h" @@ -38,7 +39,7 @@ namespace ccf static const ccf::Endorsement endorsement_default = {ccf::Authority::SERVICE}; class RPCSessions : public std::enable_shared_from_this, - public AbstractRPCResponder, + public AbstractRPCSessions, public ::http::ErrorReporter { private: @@ -162,13 +163,13 @@ namespace ccf } void set_custom_protocol_subsystem( - std::shared_ptr cpss) + std::shared_ptr cpss) override { custom_protocol_subsystem = cpss; } void set_commit_callbacks_subsystem( - std::shared_ptr fcss) + std::shared_ptr fcss) override { commit_callbacks_subsystem = fcss; } @@ -194,7 +195,7 @@ namespace ccf } void update_listening_interface_options( - const ccf::NodeInfoNetwork& node_info) + const ccf::NodeInfoNetwork& node_info) override { std::lock_guard guard(lock); @@ -226,7 +227,7 @@ namespace ccf } } - ccf::SessionMetrics get_session_metrics() + ccf::SessionMetrics get_session_metrics() override { ccf::SessionMetrics sm; std::lock_guard guard(lock); @@ -247,7 +248,7 @@ namespace ccf return sm; } - ccf::ApplicationProtocol get_app_protocol_main_interface() const + ccf::ApplicationProtocol get_app_protocol_main_interface() const override { // Note: this is a temporary function to conveniently find out which // protocol to use when creating client endpoints (e.g. for join @@ -262,13 +263,13 @@ namespace ccf } void set_node_cert( - const ccf::crypto::Pem& cert_, const ccf::crypto::Pem& pk) + const ccf::crypto::Pem& cert_, const ccf::crypto::Pem& pk) override { set_cert(ccf::Authority::NODE, cert_, pk); } void set_network_cert( - const ccf::crypto::Pem& cert_, const ccf::crypto::Pem& pk) + const ccf::crypto::Pem& cert_, const ccf::crypto::Pem& pk) override { set_cert(ccf::Authority::SERVICE, cert_, pk); } @@ -535,7 +536,8 @@ namespace ccf DISPATCHER_SET_MESSAGE_HANDLER( disp, ::tcp::tcp_inbound, [this](const uint8_t* data, size_t size) { - auto id = serialized::peek(data, size); + auto [id, body] = + ringbuffer::read_message<::tcp::tcp_inbound>(data, size); auto session = find_session(id); if (session == nullptr) @@ -545,7 +547,7 @@ namespace ccf return; } - session->handle_incoming_data({data, size}); + session->handle_incoming_data(body); }); DISPATCHER_SET_MESSAGE_HANDLER( @@ -625,7 +627,10 @@ namespace ccf session = search->second.second; } - session->handle_incoming_data({data, size}); + auto [_, addr_family, addr_data, body] = + ringbuffer::read_message(data, size); + session->handle_incoming_data( + body, udp::sockaddr_decode(addr_family, addr_data)); }); } }; diff --git a/src/enclave/session.h b/src/enclave/session.h index bf8a031eccb1..7636eb1c635c 100644 --- a/src/enclave/session.h +++ b/src/enclave/session.h @@ -86,12 +86,11 @@ namespace ccf // Implement Session::handle_incoming_data by dispatching a thread message // that eventually invokes the virtual handle_incoming_data_thread() - void handle_incoming_data(std::span data) override + void handle_incoming_data( + std::span data, sockaddr /*addr*/) override { - auto [_, body] = ringbuffer::read_message<::tcp::tcp_inbound>(data); - task_scheduler->add_action( - std::make_shared(body, shared_from_this())); + std::make_shared(data, shared_from_this())); } virtual void handle_incoming_data_thread(std::vector&& data) = 0; diff --git a/src/enclave/session_writer.h b/src/enclave/session_writer.h new file mode 100644 index 000000000000..add34aa41414 --- /dev/null +++ b/src/enclave/session_writer.h @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +#include "tcp/msg_types.h" + +#include +#include +#include +#include + +namespace ccf +{ + // Abstract output sink injected into Sessions. + // + // This replaces the per-session ringbuffer `to_host` writer that used to + // carry outbound bytes (tcp_outbound) and lifecycle signals (tcp_closed / + // tcp_stop) from the enclave back to the host. With the host/enclave split + // removed, sessions instead hold a reference to a SessionWriter implemented + // by the host-side RPCConnectionManager. + // + // IMPORTANT: Sessions invoke these methods from worker threads (see + // ccf::ThreadedSession / OrderedTasks). Implementations MUST be thread-safe + // and must marshal any libuv socket operations onto the loop thread (e.g. via + // asynchost::LoopExecutorImpl), since libuv handles are not thread-safe. + class SessionWriter + { + public: + virtual ~SessionWriter() = default; + + // Queue bytes (already encrypted by the session's TLS layer, or plaintext + // for unencrypted sessions) to be written to the socket associated with + // `id`. For datagram protocols, `addr` identifies the destination peer; it + // is ignored for stream (TCP) connections. The bytes are copied, so the + // caller's buffer can be reused immediately. + // + // Fire-and-forget: there is currently no backpressure signal. The old + // ringbuffer "buffer full" was not real network backpressure, so it is not + // reproduced here. + // + // FUTURE: to surface genuine TCP-layer backpressure (so that e.g. + // TLSSession::handle_send can return TLS_WRITING and let OpenSSL retry), an + // implementation should report when a connection's pending-write queue + // exceeds a watermark. The manager can track per-connection queued bytes + // (incremented on enqueue here, decremented once the uv write completes) + // and have this return a writable/would-block status. + virtual void write_outbound( + ::tcp::ConnID id, std::span data, sockaddr addr = {}) = 0; + + // Tear down the connection: stop the underlying socket and drop the + // session. This single call replaces the old two-phase tcp_stop + + // tcp_closed handshake, which existed only to reconcile the separate host + // and enclave bookkeeping across the ringbuffer. With a single owner there + // is no second party to notify, so one close is sufficient. + virtual void close_socket(::tcp::ConnID id) = 0; + }; +} diff --git a/src/host/rpc_connection_manager.h b/src/host/rpc_connection_manager.h new file mode 100644 index 000000000000..a9ece021ec2d --- /dev/null +++ b/src/host/rpc_connection_manager.h @@ -0,0 +1,758 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +// NOTE: This is a fresh, standalone rewrite that merges the responsibilities +// previously split across: +// - src/enclave/rpc_sessions.h (RPCSessions: ccf::Session lifecycle, +// listening-interface limits, certs) +// - src/host/rpc_connections.h (RPCConnectionsImpl: libuv sockets, ConnID +// allocation, socket <-> ringbuffer bridge) +// +// The host/enclave ringbuffer split was technical debt from SGX. With that gone +// we own both the libuv sockets and the ccf::Session objects in one place, and +// data flows directly: +// inbound: socket on_read --------------------> session.handle_incoming_data +// outbound: session -> SessionWriter sink -> LoopExecutor -> socket.write +// +// This file is intentionally NOT yet added to the build. It is meant to be +// reviewed standalone and then plugged in (potentially behind a compile-time +// switch) once complete. It targets the *post-swap* session interfaces: +// * Sessions are constructed with a `ccf::SessionWriter&` instead of a +// `ringbuffer::AbstractWriterFactory&`. +// * Session::handle_incoming_data receives raw socket bytes (no serialised +// tcp_inbound / udp_inbound framing) plus the source address (used by +// datagram transports, ignored by stream transports). +// Those session-side changes are deliberately left for the wiring step. +// +// Socket I/O is factored per transport into RPCSocketSet/ (see +// rpc_socket_set.h); this manager owns one of each and holds all the shared +// session/interface/cert state. That composition is purely an in-process +// organisation detail - there is no ringbuffer or arms-length boundary. + +#include "ccf/pal/locking.h" +#include "ccf/service/node_info_network.h" +#include "ds/internal_logger.h" +#include "enclave/no_more_sessions.h" +#include "enclave/session.h" +#include "enclave/session_writer.h" +#include "forwarder_types.h" +#include "host/loop_executor.h" +#include "host/rpc_socket_set.h" +#include "http/http2_session.h" +#include "http/http_session.h" +#include "node/rpc/custom_protocol_subsystem.h" +#include "node/session_metrics.h" +#include "quic/quic_session.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" + +#include +#include +#include +#include + +namespace ccf +{ + using QUICSessionImpl = quic::QUICEchoSession; + + static constexpr size_t cm_max_open_sessions_soft_default = 1000; + static constexpr size_t cm_max_open_sessions_hard_default = 1010; + static const ccf::Endorsement cm_endorsement_default = { + ccf::Authority::SERVICE}; + + // Single owner of libuv sockets and ccf::Session objects for RPC traffic. + // + // Threading model: + // * The `sockets` map is only ever touched on the libuv loop thread + // (listen/connect/accept/on_read/write/close). It therefore needs no + // lock. + // * The `sessions`/`listening_interfaces`/`certs` maps are touched both on + // the loop thread (accept/close) and on session worker threads + // (reply_async/find_session). They are guarded by `lock`. + // * Sessions run their work on OrderedTasks worker threads and call back + // into write_outbound()/close_socket() from those threads. Those methods + // only enqueue onto the LoopExecutor, which is thread-safe, and the real + // socket operation runs later on the loop thread. + class RPCConnectionManager + : public std::enable_shared_from_this, + public ccf::SessionWriter, + public ccf::SocketSetHost, + public ccf::AbstractRPCResponder, + public ::http::ErrorReporter + { + public: + using ConnID = ::tcp::ConnID; + + private: + struct ListenInterface + { + size_t open_sessions = 0; + size_t peak_sessions = 0; + size_t max_open_sessions_soft = 0; + size_t max_open_sessions_hard = 0; + ccf::Endorsement endorsement{}; + http::ParserConfiguration http_configuration; + ccf::SessionMetrics::Errors errors{}; + ccf::ApplicationProtocol app_protocol; + }; + + std::shared_ptr rpc_map; + std::shared_ptr loop_executor; + + std::shared_ptr custom_protocol_subsystem = + nullptr; + std::shared_ptr commit_callbacks_subsystem = + nullptr; + + // Loop-thread-only socket ownership, split by transport. + RPCSocketSet tcp_sockets; + RPCSocketSet udp_sockets; + + ccf::pal::Mutex lock; + std::map listening_interfaces; + std::unordered_map> certs; + std::unordered_map< + ConnID, + std::pair>> + sessions; + size_t sessions_peak = 0; + + // Positive IDs: sockets accepted/listened on the host side. + std::atomic next_server_id = 1; + // Negative IDs: outbound client sessions created locally (was create_client + // inside the enclave). Kept in a separate range to preserve the historical + // convention relied upon elsewhere (e.g. forwarding). + std::atomic next_client_id = -1; + + // ----- session construction --------------------------------------------- + + std::shared_ptr make_server_session( + const std::string& app_protocol, + ConnID id, + const ListenInterfaceID& listen_interface_id, + std::unique_ptr&& ctx, + const http::ParserConfiguration& parser_configuration) + { + // NOTE: post-swap, these session constructors take `*this` (a + // ccf::SessionWriter&) where they previously took a + // ringbuffer::AbstractWriterFactory&. + if (app_protocol == "HTTP2") + { + return std::make_shared<::http::HTTP2ServerSession>( + rpc_map, + id, + listen_interface_id, + *this, + std::move(ctx), + parser_configuration, + shared_from_this()); + } + if (app_protocol == "HTTP1") + { + return std::make_shared<::http::HTTPServerSession>( + rpc_map, + id, + listen_interface_id, + *this, + std::move(ctx), + parser_configuration, + shared_from_this(), + commit_callbacks_subsystem); + } + if (custom_protocol_subsystem) + { + return custom_protocol_subsystem->create_session( + app_protocol, id, std::move(ctx)); + } + + throw std::runtime_error(fmt::format( + "unknown protocol '{}' and custom protocol subsystem missing", + app_protocol)); + } + + std::shared_ptr make_capped_session( + const ListenInterface& li, + ConnID id, + const ListenInterfaceID& listen_interface_id) + { + // NOTE: post-swap, these session constructors take `*this` (a + // ccf::SessionWriter&) where they previously took a + // ringbuffer::AbstractWriterFactory&. + auto ctx = std::make_unique<::tls::Server>(certs[listen_interface_id]); + if (li.app_protocol == "HTTP2") + { + return std::make_shared>( + rpc_map, + id, + listen_interface_id, + *this, + std::move(ctx), + li.http_configuration, + shared_from_this()); + } + return std::make_shared>( + rpc_map, + id, + listen_interface_id, + *this, + std::move(ctx), + li.http_configuration, + shared_from_this(), + commit_callbacks_subsystem); + } + + ListenInterface& get_interface_from_interface_id( + const ListenInterfaceID& id) + { + auto it = listening_interfaces.find(id); + if (it != listening_interfaces.end()) + { + return it->second; + } + throw std::logic_error( + fmt::format("No RPC interface for interface ID {}", id)); + } + + ConnID get_next_client_id() + { + std::lock_guard guard(lock); + auto id = next_client_id--; + const auto initial = id; + + if (next_client_id > 0) + { + next_client_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 client sessions"); + } + } + return id; + } + + // ----- outbound (loop thread, invoked via LoopExecutor) ----------------- + + void write_on_loop(ConnID id, std::vector data, sockaddr addr) + { + if ( + tcp_sockets.write(id, data, addr) || udp_sockets.write(id, data, addr)) + { + return; + } + LOG_DEBUG_FMT( + "Dropping {} outbound bytes for unknown socket {}", data.size(), id); + } + + void close_on_loop(ConnID id) + { + tcp_sockets.stop(id); + tcp_sockets.close(id); + udp_sockets.stop(id); + udp_sockets.close(id); + remove_session(id); + } + + public: + RPCConnectionManager( + std::shared_ptr rpc_map_, + std::shared_ptr loop_executor_) : + rpc_map(std::move(rpc_map_)), + loop_executor(std::move(loop_executor_)), + tcp_sockets(*this), + udp_sockets(*this) + {} + + void set_custom_protocol_subsystem( + std::shared_ptr cpss) + { + custom_protocol_subsystem = std::move(cpss); + } + + void set_commit_callbacks_subsystem( + std::shared_ptr fcss) + { + commit_callbacks_subsystem = std::move(fcss); + } + + // ----- SocketSetHost (loop thread) -------------------------------------- + + ConnID get_next_server_id() override + { + return next_server_id++; + } + + void on_socket_start( + ConnID id, const ListenInterfaceID& interface_id, bool udp) override + { + accept(id, interface_id, udp); + } + + void on_socket_inbound( + ConnID id, const uint8_t* data, size_t len, sockaddr addr) override + { + auto session = find_session_for_inbound(id); + if (session == nullptr) + { + LOG_DEBUG_FMT("Ignoring inbound for unknown session {}", id); + return; + } + // Post-swap: handle_incoming_data takes raw bytes (no tcp_inbound / + // udp_inbound frame) plus the source address. `addr` is meaningful for + // datagram transports and ignored by stream sessions. + session->handle_incoming_data({data, len}, addr); + } + + void on_socket_gone(ConnID id) override + { + remove_session(id); + // Defer the socket erase so we are not destroying the behaviour that is + // currently executing this callback. + loop_executor->enqueue([self = shared_from_this(), id]() { + self->tcp_sockets.close(id); + self->udp_sockets.close(id); + }); + } + + // ----- SessionWriter (called from session worker threads) --------------- + + void write_outbound( + ConnID id, std::span data, sockaddr addr = {}) override + { + std::vector copy(data.begin(), data.end()); + loop_executor->enqueue( + [self = shared_from_this(), + id, + copy = std::move(copy), + addr]() mutable { + self->write_on_loop(id, std::move(copy), addr); + }); + } + + void close_socket(ConnID id) override + { + loop_executor->enqueue( + [self = shared_from_this(), id]() { self->close_on_loop(id); }); + } + + // ----- AbstractRPCResponder --------------------------------------------- + + bool reply_async( + ConnID id, bool terminate_after_send, std::vector&& data) + override + { + auto session = find_session(id); + if (session == nullptr) + { + LOG_DEBUG_FMT("Refusing to reply to unknown session {}", id); + return false; + } + + LOG_DEBUG_FMT("Replying to session {}", id); + session->send_data(std::move(data)); + + if (terminate_after_send) + { + session->close_session(); + } + return true; + } + + // ----- ErrorReporter ---------------------------------------------------- + + void report_parsing_error(const ListenInterfaceID& id) override + { + std::lock_guard guard(lock); + get_interface_from_interface_id(id).errors.parsing++; + } + + void report_request_payload_too_large_error( + const ListenInterfaceID& id) override + { + std::lock_guard guard(lock); + get_interface_from_interface_id(id).errors.request_payload_too_large++; + } + + void report_request_header_too_large_error( + const ListenInterfaceID& id) override + { + std::lock_guard guard(lock); + get_interface_from_interface_id(id).errors.request_header_too_large++; + } + + // ----- interface configuration / certs ---------------------------------- + + void update_listening_interface_options( + const ccf::NodeInfoNetwork& node_info) + { + std::lock_guard guard(lock); + + for (const auto& [name, interface] : node_info.rpc_interfaces) + { + auto& li = listening_interfaces[name]; + + li.max_open_sessions_soft = interface.max_open_sessions_soft.value_or( + cm_max_open_sessions_soft_default); + li.max_open_sessions_hard = interface.max_open_sessions_hard.value_or( + cm_max_open_sessions_hard_default); + li.endorsement = interface.endorsement.value_or(cm_endorsement_default); + li.http_configuration = + interface.http_configuration.value_or(http::ParserConfiguration{}); + li.app_protocol = interface.app_protocol.value_or("HTTP1"); + + LOG_INFO_FMT( + "Setting max open sessions on interface \"{}\" ({}) to [{}, {}] and " + "endorsement authority to {}", + name, + interface.bind_address, + li.max_open_sessions_soft, + li.max_open_sessions_hard, + li.endorsement.authority); + } + } + + void set_node_cert(const ccf::crypto::Pem& cert_, const ccf::crypto::Pem& pk) + { + set_cert(ccf::Authority::NODE, cert_, pk); + } + + void set_network_cert( + const ccf::crypto::Pem& cert_, const ccf::crypto::Pem& pk) + { + set_cert(ccf::Authority::SERVICE, cert_, pk); + } + + void set_cert( + ccf::Authority authority, + const ccf::crypto::Pem& cert_, + const ccf::crypto::Pem& pk) + { + // Caller authentication is done by each frontend by looking up the + // caller's certificate in the relevant store table; verification is not + // required here. + auto cert = std::make_shared<::tls::Cert>( + nullptr, cert_, pk, std::nullopt, /*auth_required ==*/false); + + std::lock_guard guard(lock); + for (auto& [listen_interface_id, interface] : listening_interfaces) + { + if (interface.endorsement.authority == authority) + { + certs.insert_or_assign(listen_interface_id, cert); + } + } + } + + ccf::SessionMetrics get_session_metrics() + { + ccf::SessionMetrics sm; + std::lock_guard guard(lock); + + sm.active = sessions.size(); + sm.peak = sessions_peak; + for (const auto& [name, interface] : listening_interfaces) + { + sm.interfaces[name] = { + interface.open_sessions, + interface.peak_sessions, + interface.max_open_sessions_soft, + interface.max_open_sessions_hard, + interface.errors}; + } + return sm; + } + + ccf::ApplicationProtocol get_app_protocol_main_interface() const + { + if (listening_interfaces.empty()) + { + throw std::logic_error("No listening interface for this node"); + } + return listening_interfaces.begin()->second.app_protocol; + } + + // ----- listen / connect (loop thread) ----------------------------------- + + bool listen( + const std::string& host, + const std::string& port, + const ListenInterfaceID& name, + bool udp = false) + { + const auto id = next_server_id++; + if (udp) + { + return udp_sockets.listen(id, host, port, name); + } + return tcp_sockets.listen(id, host, port, name); + } + + // ----- session lifecycle ------------------------------------------------ + + std::shared_ptr find_session(ConnID id) + { + std::lock_guard guard(lock); + auto search = sessions.find(id); + if (search == sessions.end()) + { + return nullptr; + } + return search->second.second; + } + + // Create a session for a newly started connection, applying per-interface + // session caps. Runs on the loop thread (from on_socket_start). + void accept( + ConnID id, const ListenInterfaceID& listen_interface_id, bool udp) + { + std::lock_guard guard(lock); + + if (sessions.find(id) != sessions.end()) + { + throw std::logic_error( + fmt::format("Duplicate conn ID received: {}", id)); + } + + auto it = listening_interfaces.find(listen_interface_id); + if (it == listening_interfaces.end()) + { + throw std::logic_error(fmt::format( + "Can't accept RPC session {} from unknown interface {}", + id, + listen_interface_id)); + } + auto& li = it->second; + + if (udp) + { + accept_udp(id, listen_interface_id, li); + return; + } + + const bool needs_cert = li.endorsement.authority != Authority::UNSECURED; + if (needs_cert && certs.find(listen_interface_id) == certs.end()) + { + LOG_DEBUG_FMT( + "Refusing TLS session {} - interface {} has no certificate yet", + id, + listen_interface_id); + close_socket(id); + return; + } + + if (li.open_sessions >= li.max_open_sessions_hard) + { + LOG_INFO_FMT( + "Refusing session {} - {} sessions on interface {}, hard limit {}", + id, + li.open_sessions, + listen_interface_id, + li.max_open_sessions_hard); + close_socket(id); + return; + } + + std::shared_ptr session; + if (li.open_sessions >= li.max_open_sessions_soft) + { + LOG_INFO_FMT( + "Soft-refusing session {} (503) - {} sessions on interface {}, soft " + "limit {}", + id, + li.open_sessions, + listen_interface_id, + li.max_open_sessions_soft); + session = make_capped_session(li, id, listen_interface_id); + } + else + { + LOG_DEBUG_FMT( + "Accepting session {} on interface \"{}\"", id, listen_interface_id); + + std::unique_ptr ctx; + if (li.endorsement.authority == Authority::UNSECURED) + { + ctx = std::make_unique(); + } + else + { + ctx = std::make_unique<::tls::Server>( + certs[listen_interface_id], li.app_protocol == "HTTP2"); + } + + session = make_server_session( + li.app_protocol, + id, + listen_interface_id, + std::move(ctx), + li.http_configuration); + } + + sessions.emplace( + id, std::make_pair(listen_interface_id, std::move(session))); + li.open_sessions++; + li.peak_sessions = std::max(li.peak_sessions, li.open_sessions); + sessions_peak = std::max(sessions_peak, sessions.size()); + } + + void remove_session(ConnID id) + { + std::lock_guard guard(lock); + LOG_DEBUG_FMT("Closing session {}", id); + const auto search = sessions.find(id); + if (search != sessions.end()) + { + auto it = listening_interfaces.find(search->second.first); + if (it != listening_interfaces.end()) + { + it->second.open_sessions--; + } + sessions.erase(search); + } + } + + std::shared_ptr create_client( + const std::shared_ptr<::tls::Cert>& cert, + const std::string& app_protocol = "HTTP1") + { + auto id = get_next_client_id(); + auto ctx = std::make_unique<::tls::Client>(cert); + + LOG_DEBUG_FMT("Creating client session {}", id); + + std::shared_ptr session; + if (app_protocol == "HTTP2") + { + session = std::make_shared<::http::HTTP2ClientSession>( + id, *this, std::move(ctx)); + } + else if (app_protocol == "HTTP1") + { + session = std::make_shared<::http::HTTPClientSession>( + id, *this, std::move(ctx)); + } + else + { + throw std::runtime_error("unsupported client application protocol"); + } + + { + std::lock_guard guard(lock); + sessions.emplace(id, std::make_pair("", session)); + sessions_peak = std::max(sessions_peak, sessions.size()); + } + return session; + } + + // Open the outbound socket for a client session created via create_client. + // Marshalled onto the loop thread. + void connect(ConnID id, const std::string& host, const std::string& port) + { + loop_executor->enqueue([self = shared_from_this(), id, host, port]() { + if (!self->tcp_sockets.connect(id, host, port)) + { + self->on_socket_gone(id); + } + }); + } + + private: + // ----- UDP / datagram helpers (loop thread) ----------------------------- + + void accept_udp( + ConnID id, + const ListenInterfaceID& listen_interface_id, + ListenInterface& li) + { + // Caller holds `lock`. + LOG_DEBUG_FMT("New UDP endpoint {}", id); + + std::shared_ptr session; + if (li.app_protocol == "QUIC") + { + session = std::make_shared( + rpc_map, id, listen_interface_id, *this); + } + else if (custom_protocol_subsystem) + { + // Custom protocol session is created lazily on the first inbound + // datagram (the creation function may not be registered yet). Store a + // nullptr placeholder so the interface mapping and caps are tracked. + session = nullptr; + } + else + { + throw std::runtime_error( + "unknown UDP protocol and custom protocol subsystem missing"); + } + + sessions.emplace( + id, std::make_pair(listen_interface_id, std::move(session))); + li.open_sessions++; + li.peak_sessions = std::max(li.peak_sessions, li.open_sessions); + sessions_peak = std::max(sessions_peak, sessions.size()); + } + + // Returns the session for `id`, lazily creating a custom-protocol datagram + // session on first inbound if one was deferred at accept time. Works for + // stream sessions too (which are always present, never deferred). + std::shared_ptr find_session_for_inbound(ConnID id) + { + std::lock_guard guard(lock); + + auto search = sessions.find(id); + if (search == sessions.end()) + { + return nullptr; + } + + if (search->second.second != nullptr || !custom_protocol_subsystem) + { + return search->second.second; + } + + // Deferred custom-protocol datagram session: create it now. + const auto& interface_id = search->second.first; + auto iit = listening_interfaces.find(interface_id); + if (iit == listening_interfaces.end()) + { + LOG_DEBUG_FMT( + "Cannot create custom protocol session for {}: unknown interface {}", + id, + interface_id); + return nullptr; + } + + try + { + search->second.second = custom_protocol_subsystem->create_session( + iit->second.app_protocol, id, nullptr); + } + catch (const std::exception& ex) + { + LOG_DEBUG_FMT( + "Failure to create custom protocol session {}: {}", id, ex.what()); + return nullptr; + } + + if (search->second.second == nullptr) + { + LOG_DEBUG_FMT("Failure to create custom protocol session {}", id); + } + return search->second.second; + } + }; +} diff --git a/src/host/rpc_socket_set.h b/src/host/rpc_socket_set.h new file mode 100644 index 000000000000..710e74aa535b --- /dev/null +++ b/src/host/rpc_socket_set.h @@ -0,0 +1,263 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +#include "ccf/service/node_info_network.h" +#include "host/tcp.h" +#include "host/udp.h" + +#include +#include +#include + +namespace ccf +{ + template + constexpr bool socket_is_tcp() + { + return std::is_same_v; + } + + template + constexpr bool socket_is_udp() + { + return std::is_same_v; + } + + // Callbacks an RPCSocketSet needs from its owner (RPCConnectionManager). + // + // These all run on the libuv loop thread, invoked from socket behaviours. + class SocketSetHost + { + public: + using ConnID = ::tcp::ConnID; + + virtual ~SocketSetHost() = default; + + // Allocate the next positive (host-side) connection id. + virtual ConnID get_next_server_id() = 0; + + // A new connection endpoint is ready and should be given a session. For + // stream sockets this is a freshly accepted peer; for datagram sockets it + // is the listening socket itself. `udp` selects the datagram path. + virtual void on_socket_start( + ConnID id, const ListenInterfaceID& interface_id, bool udp) = 0; + + // Inbound bytes for connection `id`. `addr` identifies the source peer for + // datagram sockets and is unused for stream sockets. + virtual void on_socket_inbound( + ConnID id, const uint8_t* data, size_t len, sockaddr addr) = 0; + + // The socket for `id` failed or disconnected. + virtual void on_socket_gone(ConnID id) = 0; + }; + + // Owns the libuv sockets of a single transport type (TCP or UDP) and bridges + // their callbacks to a SocketSetHost. All methods must be called on the loop + // thread. This is composed (not inherited) into RPCConnectionManager so that + // a single manager can own both a TCP and a UDP set while keeping the + // transport-specific socket handling isolated here. + template + class RPCSocketSet + { + public: + using ConnID = ::tcp::ConnID; + + private: + SocketSetHost& host; + std::unordered_map sockets; + std::unordered_map listen_socket_interface; + + ListenInterfaceID interface_for(ConnID id) + { + const auto it = listen_socket_interface.find(id); + if (it == listen_socket_interface.end()) + { + throw std::logic_error( + fmt::format("No listening interface for socket {}", id)); + } + return it->second; + } + + // Behaviour for an accepted stream peer or an outbound client socket. + class PeerBehaviour : public asynchost::SocketBehaviour + { + public: + RPCSocketSet& set; + ConnID id; + + PeerBehaviour(RPCSocketSet& set_, ConnID id_) : + asynchost::SocketBehaviour("RPC", "TCP"), + set(set_), + id(id_) + {} + + bool on_read(size_t len, uint8_t*& data, sockaddr /*addr*/) override + { + set.host.on_socket_inbound(id, data, len, sockaddr{}); + return true; + } + + void on_disconnect() override + { + set.host.on_socket_gone(id); + } + + void on_connect_failed() override + { + set.host.on_socket_gone(id); + } + + void on_resolve_failed() override + { + set.host.on_socket_gone(id); + } + }; + + // Behaviour for the listening socket. For TCP it accepts peers; for UDP it + // is also the data-carrying socket, delivering reads with a source address. + class ListenBehaviour : public asynchost::SocketBehaviour + { + public: + RPCSocketSet& set; + ConnID id; + + ListenBehaviour(RPCSocketSet& set_, ConnID id_) : + asynchost::SocketBehaviour("RPC", "TCP"), + set(set_), + id(id_) + {} + + void on_accept(ConnType& peer) override + { + if constexpr (socket_is_tcp()) + { + const auto peer_id = set.host.get_next_server_id(); + peer->set_behaviour( + std::make_unique(set, peer_id)); + set.sockets.emplace(peer_id, peer); + set.host.on_socket_start(peer_id, set.interface_for(id), false); + } + } + + void on_start(int64_t /*peer_id*/) override + { + if constexpr (socket_is_udp()) + { + set.host.on_socket_start(id, set.interface_for(id), true); + } + } + + bool on_read(size_t len, uint8_t*& data, sockaddr addr) override + { + if constexpr (socket_is_udp()) + { + set.host.on_socket_inbound(id, data, len, addr); + } + return true; + } + }; + + public: + explicit RPCSocketSet(SocketSetHost& host_) : host(host_) {} + + bool listen( + ConnID id, + const std::string& addr_host, + const std::string& addr_port, + const ListenInterfaceID& name) + { + if (sockets.find(id) != sockets.end()) + { + LOG_FAIL_FMT("Cannot listen on id {}: already in use", id); + return false; + } + + ConnType s; + s->set_behaviour(std::make_unique(*this, id)); + + std::string h = addr_host; + std::string p = addr_port; + if (!s->listen(h, p, name)) + { + return false; + } + + sockets.emplace(id, s); + listen_socket_interface.emplace(id, name); + + // UDP has no accept step: the listening socket carries data, so start it + // immediately to trigger session creation. + if constexpr (socket_is_udp()) + { + s->start(id); + } + + return true; + } + + // Open an outbound stream connection (TCP only). + bool connect( + ConnID id, const std::string& addr_host, const std::string& addr_port) + { + if constexpr (socket_is_tcp()) + { + if (sockets.find(id) != sockets.end()) + { + LOG_FAIL_FMT("Cannot connect on id {}: already in use", id); + return false; + } + + auto s = ConnType(true); + s->set_behaviour(std::make_unique(*this, id)); + if (!s->connect(addr_host, addr_port)) + { + return false; + } + sockets.emplace(id, s); + return true; + } + else + { + (void)id; + (void)addr_host; + (void)addr_port; + return false; + } + } + + bool write(ConnID id, const std::vector& data, sockaddr addr) + { + auto it = sockets.find(id); + if (it == sockets.end() || it->second.is_null()) + { + return false; + } + return it->second->write(data.size(), data.data(), addr); + } + + // Invalidate the socket: the uv handle is closed, no further reads or + // writes occur, but the entry is retained until close(). + bool stop(ConnID id) + { + auto it = sockets.find(id); + if (it == sockets.end()) + { + return false; + } + it->second = nullptr; + return true; + } + + bool close(ConnID id) + { + listen_socket_interface.erase(id); + return sockets.erase(id) > 0; + } + + bool has(ConnID id) const + { + return sockets.find(id) != sockets.end(); + } + }; +} diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp new file mode 100644 index 000000000000..8a2d7fb3ff5a --- /dev/null +++ b/src/host/test/openssl_server_test.cpp @@ -0,0 +1,312 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. + +// Vertical slice for the OpenSSL-native RPC transport: drives the epoll + +// SSL_set_fd server (src/host/tls/openssl_server.h) with a real TLS client and +// exercises handshake, plaintext round-trip, large transfers (backpressure +// path) and concurrent connections. + +#include "host/tls/openssl_server.h" + +#include "ccf/crypto/ec_key_pair.h" +#include "ccf/ds/x509_time_fmt.h" +#include "crypto/certs.h" +#include "host/tls/openssl_session_manager.h" + +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace asynchost; + +namespace +{ + std::pair make_server_cert() + { + using namespace std::literals; + auto kp = ccf::crypto::make_ec_key_pair(); + const auto valid_from = + ccf::ds::to_x509_time_string(std::chrono::system_clock::now() - 24h); + auto cert = ccf::crypto::create_self_signed_cert( + kp, "CN=localhost", {}, valid_from, /*validity_days*/ 365); + return {cert.str(), kp->private_key_pem().str()}; + } + + // Blocking TLS client: connects, sends `req` in full, reads exactly + // `expected_resp` bytes. Verification is disabled (self-signed slice cert). + std::vector tls_client_exchange( + uint16_t port, const std::vector& req, size_t expected_resp) + { + const int fd = ::socket(AF_INET, SOCK_STREAM, 0); + REQUIRE(fd >= 0); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + REQUIRE(inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) == 1); + REQUIRE( + ::connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0); + + SSL_CTX* cctx = SSL_CTX_new(TLS_client_method()); + REQUIRE(cctx != nullptr); + SSL* ssl = SSL_new(cctx); + REQUIRE(ssl != nullptr); + REQUIRE(SSL_set_fd(ssl, fd) == 1); + SSL_set_connect_state(ssl); + REQUIRE(SSL_connect(ssl) == 1); + + size_t off = 0; + while (off < req.size()) + { + const int n = + SSL_write(ssl, req.data() + off, static_cast(req.size() - off)); + REQUIRE(n > 0); + off += static_cast(n); + } + + std::vector resp; + resp.reserve(expected_resp); + while (resp.size() < expected_resp) + { + uint8_t buf[16384]; + const int n = SSL_read(ssl, buf, static_cast(sizeof(buf))); + if (n <= 0) + { + break; + } + resp.insert(resp.end(), buf, buf + static_cast(n)); + } + + SSL_shutdown(ssl); + SSL_free(ssl); + SSL_CTX_free(cctx); + ::close(fd); + return resp; + } + + std::vector random_bytes(size_t n) + { + std::vector v(n); + std::mt19937 rng(12345); + for (auto& b : v) + { + b = static_cast(rng()); + } + return v; + } + + // Echoes received plaintext back to the same connection via send(). + struct EchoServer + { + std::unique_ptr server; + + EchoServer(const std::string& cert, const std::string& key) + { + server = std::make_unique( + cert, + key, + "127.0.0.1", + static_cast(0), + [this](uint64_t id, std::vector d) { + server->send(id, d.data(), d.size()); + }); + server->start(); + } + + ~EchoServer() + { + server->stop(); + } + + uint16_t port() const + { + return server->port(); + } + }; + + // A minimal ccf::Session that echoes received bytes back through its writer, + // exercising the real Session / SessionWriter seam over TLS. + struct EchoSession : public ccf::Session + { + ::tcp::ConnID id; + ccf::SessionWriter& writer; + + EchoSession(::tcp::ConnID id_, ccf::SessionWriter& w) : id(id_), writer(w) + {} + + void handle_incoming_data( + std::span data, sockaddr /*addr*/ = {}) override + { + writer.write_outbound(id, data); + } + + void send_data(std::vector&& /*data*/) override {} + + void close_session() override + { + writer.close_socket(id); + } + }; +} + +TEST_CASE("TLS handshake and small round-trip") +{ + auto [cert, key] = make_server_cert(); + EchoServer s(cert, key); + REQUIRE(s.port() != 0); + + const std::vector msg = {'h', 'e', 'l', 'l', 'o'}; + REQUIRE(tls_client_exchange(s.port(), msg, msg.size()) == msg); +} + +TEST_CASE("Large transfer exercises the backpressure path") +{ + auto [cert, key] = make_server_cert(); + EchoServer s(cert, key); + + // 4 MiB forces the socket send buffer to fill, so SSL_write returns + // WANT_WRITE and the server must buffer + re-arm EPOLLOUT. + const auto payload = random_bytes(4 * 1024 * 1024); + const auto resp = tls_client_exchange(s.port(), payload, payload.size()); + + REQUIRE(resp.size() == payload.size()); + REQUIRE(resp == payload); +} + +TEST_CASE("Concurrent connections") +{ + auto [cert, key] = make_server_cert(); + EchoServer s(cert, key); + const uint16_t port = s.port(); + + constexpr int num_clients = 16; + std::vector clients; + std::atomic ok{0}; + clients.reserve(num_clients); + for (int i = 0; i < num_clients; ++i) + { + clients.emplace_back([port, i, &ok]() { + const std::vector msg( + 64, static_cast('A' + (i % 26))); + const auto resp = tls_client_exchange(port, msg, msg.size()); + if (resp == msg) + { + ok.fetch_add(1); + } + }); + } + for (auto& t : clients) + { + t.join(); + } + + REQUIRE(ok.load() == num_clients); +} + +// Models the production dispatch path: the epoll thread hands the request to a +// worker thread, which replies via send() - exercising cross-thread send + +// eventfd loop wakeup. +TEST_CASE("Reply from a worker thread") +{ + auto [cert, key] = make_server_cert(); + + OpenSSLServer* sp = nullptr; + std::mutex m; + std::condition_variable cv; + std::deque>> q; + std::atomic stop{false}; + + std::thread worker([&]() { + for (;;) + { + std::unique_lock l(m); + cv.wait(l, [&]() { return stop.load() || !q.empty(); }); + if (stop.load() && q.empty()) + { + return; + } + auto item = std::move(q.front()); + q.pop_front(); + l.unlock(); + sp->send(item.first, item.second.data(), item.second.size()); + } + }); + + OpenSSLServer server( + cert, key, "127.0.0.1", static_cast(0), [&]( + uint64_t id, + std::vector d) { + { + std::lock_guard l(m); + q.emplace_back(id, std::move(d)); + } + cv.notify_one(); + }); + sp = &server; + server.start(); + + const std::vector msg = {'w', 'o', 'r', 'k', 'e', 'r'}; + REQUIRE(tls_client_exchange(server.port(), msg, msg.size()) == msg); + + server.stop(); + { + std::lock_guard l(m); + stop.store(true); + } + cv.notify_one(); + worker.join(); +} + +TEST_CASE("Session bridge: round-trip via ccf::Session + SessionWriter") +{ + auto [cert, key] = make_server_cert(); + OpenSSLSessionManager mgr( + cert, + key, + "127.0.0.1", + static_cast(0), + [](::tcp::ConnID id, ccf::SessionWriter& w) { + return std::make_shared(id, w); + }); + mgr.start(); + REQUIRE(mgr.port() != 0); + + const std::vector msg = {'b', 'r', 'i', 'd', 'g', 'e'}; + REQUIRE(tls_client_exchange(mgr.port(), msg, msg.size()) == msg); + + mgr.stop(); +} + +TEST_CASE("Session bridge: large transfer through the seam") +{ + auto [cert, key] = make_server_cert(); + OpenSSLSessionManager mgr( + cert, + key, + "127.0.0.1", + static_cast(0), + [](::tcp::ConnID id, ccf::SessionWriter& w) { + return std::make_shared(id, w); + }); + mgr.start(); + + const auto payload = random_bytes(2 * 1024 * 1024); + const auto resp = tls_client_exchange(mgr.port(), payload, payload.size()); + REQUIRE(resp == payload); + + mgr.stop(); +} diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h new file mode 100644 index 000000000000..c0d9acacaf5f --- /dev/null +++ b/src/host/tls/openssl_server.h @@ -0,0 +1,708 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +// Vertical-slice OpenSSL-native TLS server, validating the model for the RPC +// stack rewrite: +// * OpenSSL owns the socket fd directly (SSL_set_fd on a non-blocking fd) - +// no memory-BIO indirection, no libuv. +// * Our own epoll loop drives readiness; the handshake and I/O run as a +// non-blocking state machine. +// * Real TCP backpressure falls out: a non-blocking SSL_write that returns +// WANT_WRITE leaves the unsent plaintext buffered and arms EPOLLOUT. +// +// Scope/limits of this slice (deliberately minimal): +// * Single epoll thread; the on_data callback is invoked synchronously on +// that thread and replies by appending to the connection's outbound buffer. +// The production target is SO_REUSEPORT + one epoll per worker, with +// callbacks dispatched to the OrderedTasks pool (so replies would arrive +// from another thread and wake the loop). +// * Level-triggered epoll, for simplicity/correctness over raw throughput. +// * No session caps / certs-per-interface / protocol handling - that policy +// is harvested separately. This proves transport + threading + backpressure. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace asynchost +{ + class OpenSSLServer + { + public: + // Invoked on the epoll thread with a complete chunk of decrypted bytes for + // connection `conn_id`. The handler typically hands processing to a worker + // (e.g. OrderedTasks) and later calls send()/close_connection() from that + // thread - both are thread-safe and wake the loop. + using OnData = + std::function data)>; + + // Invoked on the epoll thread when a connection is torn down (peer + // disconnect, error, or close_connection()). Lets an owner drop per- + // connection state. + using OnClose = std::function; + + private: + static constexpr size_t read_chunk = 16384; + + struct Conn + { + int fd = -1; + SSL* ssl = nullptr; + uint64_t id = 0; + enum State : uint8_t + { + Handshaking, + Ready + } state = Handshaking; + // Pending plaintext to be encrypted/written; out_off bytes already sent. + std::vector outbuf; + size_t out_off = 0; + // True when progress needs the socket to become writable (handshake + // wants write, or there is buffered outbound data). + bool want_write = false; + }; + + SSL_CTX* ctx = nullptr; + int listen_fd = -1; + int epoll_fd = -1; + int stop_fd = -1; + int wake_fd = -1; + uint16_t bound_port = 0; + OnData on_data; + OnClose on_close; + bool verbose = false; + + std::unordered_map> conns; + std::unordered_map id_to_fd; + uint64_t next_id = 1; + + // Cross-thread outbound queue: send()/close_connection() append here from + // any thread and wake the loop, which drains it on the epoll thread. + struct OutItem + { + uint64_t id = 0; + std::vector data; + bool close = false; + }; + std::mutex out_mutex; + std::vector pending_out; + + std::thread loop_thread; + std::atomic running{false}; + + void logf(const char* fmt, ...) const + { + if (!verbose) + { + return; + } + va_list args; // NOLINT + va_start(args, fmt); + std::vfprintf(stderr, fmt, args); + std::fputc('\n', stderr); + va_end(args); + } + + static bool set_nonblocking(int fd) + { + int flags = fcntl(fd, F_GETFL, 0); + if (flags < 0) + { + return false; + } + return fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0; + } + + static bool load_cert_key( + SSL_CTX* ctx, + const std::string& cert_pem, + const std::string& key_pem) + { + BIO* cbio = + BIO_new_mem_buf(cert_pem.data(), static_cast(cert_pem.size())); + if (cbio == nullptr) + { + return false; + } + X509* cert = PEM_read_bio_X509(cbio, nullptr, nullptr, nullptr); + BIO_free(cbio); + if (cert == nullptr) + { + return false; + } + const bool cert_ok = SSL_CTX_use_certificate(ctx, cert) == 1; + X509_free(cert); + if (!cert_ok) + { + return false; + } + + BIO* kbio = + BIO_new_mem_buf(key_pem.data(), static_cast(key_pem.size())); + if (kbio == nullptr) + { + return false; + } + EVP_PKEY* pkey = PEM_read_bio_PrivateKey(kbio, nullptr, nullptr, nullptr); + BIO_free(kbio); + if (pkey == nullptr) + { + return false; + } + const bool key_ok = SSL_CTX_use_PrivateKey(ctx, pkey) == 1; + EVP_PKEY_free(pkey); + if (!key_ok) + { + return false; + } + + return SSL_CTX_check_private_key(ctx) == 1; + } + + void update_interest(Conn& c) const + { + epoll_event ev{}; + ev.data.fd = c.fd; + ev.events = EPOLLIN | (c.want_write ? EPOLLOUT : 0); + epoll_ctl(epoll_fd, EPOLL_CTL_MOD, c.fd, &ev); + } + + // Returns false if the connection should be closed. + bool do_handshake(Conn& c) + { + const int r = SSL_accept(c.ssl); + if (r == 1) + { + c.state = Conn::Ready; + c.want_write = false; + logf("conn %llu: handshake complete", (unsigned long long)c.id); + return do_read(c) && do_write(c); + } + + const int e = SSL_get_error(c.ssl, r); + if (e == SSL_ERROR_WANT_READ) + { + c.want_write = false; + return true; + } + if (e == SSL_ERROR_WANT_WRITE) + { + c.want_write = true; + return true; + } + logf("conn %llu: handshake error %d", (unsigned long long)c.id, e); + return false; + } + + // Returns false if the connection should be closed. + bool do_read(Conn& c) + { + for (;;) + { + uint8_t buf[read_chunk]; + const int n = SSL_read(c.ssl, buf, static_cast(sizeof(buf))); + if (n > 0) + { + if (on_data) + { + on_data(c.id, std::vector(buf, buf + n)); + } + continue; + } + + const int e = SSL_get_error(c.ssl, n); + if (e == SSL_ERROR_WANT_READ) + { + return true; + } + if (e == SSL_ERROR_WANT_WRITE) + { + // A renegotiation needs the socket to become writable. + c.want_write = true; + return true; + } + // SSL_ERROR_ZERO_RETURN (clean close) or a fatal error. + logf("conn %llu: read closed/err %d", (unsigned long long)c.id, e); + return false; + } + } + + // Returns false if the connection should be closed. Implements backpressure: + // a WANT_WRITE leaves the remaining plaintext buffered and arms EPOLLOUT. + bool do_write(Conn& c) + { + while (c.out_off < c.outbuf.size()) + { + const int n = SSL_write( + c.ssl, + c.outbuf.data() + c.out_off, + static_cast(c.outbuf.size() - c.out_off)); + if (n > 0) + { + c.out_off += static_cast(n); + continue; + } + + const int e = SSL_get_error(c.ssl, n); + if (e == SSL_ERROR_WANT_WRITE) + { + c.want_write = true; + return true; + } + if (e == SSL_ERROR_WANT_READ) + { + // A renegotiation needs to read before we can write more. + return true; + } + logf("conn %llu: write err %d", (unsigned long long)c.id, e); + return false; + } + + // Fully flushed. + c.outbuf.clear(); + c.out_off = 0; + c.want_write = false; + return true; + } + + void close_conn(int fd) + { + auto it = conns.find(fd); + if (it == conns.end()) + { + return; + } + if (on_close) + { + on_close(it->second->id); + } + epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, nullptr); + id_to_fd.erase(it->second->id); + SSL* ssl = it->second->ssl; + if (ssl != nullptr) + { + SSL_shutdown(ssl); + SSL_free(ssl); + } + ::close(fd); + conns.erase(it); + } + + void accept_all() + { + for (;;) + { + sockaddr_in peer{}; + socklen_t plen = sizeof(peer); + const int cfd = accept4( + listen_fd, + reinterpret_cast(&peer), + &plen, + SOCK_NONBLOCK); + if (cfd < 0) + { + if (errno == EAGAIN || errno == EWOULDBLOCK) + { + break; + } + if (errno == EINTR) + { + continue; + } + logf("accept error: %s", std::strerror(errno)); + break; + } + + SSL* ssl = SSL_new(ctx); + if (ssl == nullptr) + { + ::close(cfd); + continue; + } + SSL_set_mode( + ssl, + SSL_MODE_ENABLE_PARTIAL_WRITE | SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); + if (SSL_set_fd(ssl, cfd) != 1) + { + SSL_free(ssl); + ::close(cfd); + continue; + } + SSL_set_accept_state(ssl); + + auto c = std::make_unique(); + c->fd = cfd; + c->ssl = ssl; + c->id = next_id++; + + epoll_event ev{}; + ev.data.fd = cfd; + ev.events = EPOLLIN; + if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, cfd, &ev) != 0) + { + SSL_free(ssl); + ::close(cfd); + continue; + } + const uint64_t cid = c->id; + conns.emplace(cfd, std::move(c)); + id_to_fd.emplace(cid, cfd); + logf("accepted conn on fd %d", cfd); + } + } + + void on_conn_event(int fd, uint32_t events) + { + auto it = conns.find(fd); + if (it == conns.end()) + { + return; + } + Conn& c = *it->second; + + bool alive = true; + if (c.state == Conn::Handshaking) + { + alive = do_handshake(c); + } + else + { + if ((events & (EPOLLIN | EPOLLERR | EPOLLHUP)) != 0) + { + alive = do_read(c); + } + if (alive) + { + alive = do_write(c); + } + } + + if (!alive) + { + close_conn(fd); + return; + } + update_interest(c); + } + + void wake() const + { + if (wake_fd >= 0) + { + const uint64_t one = 1; + [[maybe_unused]] auto w = ::write(wake_fd, &one, sizeof(one)); + } + } + + // Drain the cross-thread outbound queue on the epoll thread: append queued + // plaintext to each connection and flush (with backpressure), or close. + void drain_pending_out() + { + uint64_t counter = 0; + while (::read(wake_fd, &counter, sizeof(counter)) > 0) + { + // Clear the eventfd counter. + } + + std::vector items; + { + std::lock_guard g(out_mutex); + std::swap(items, pending_out); + } + + for (auto& item : items) + { + auto fit = id_to_fd.find(item.id); + if (fit == id_to_fd.end()) + { + continue; + } + const int fd = fit->second; + if (item.close) + { + close_conn(fd); + continue; + } + auto cit = conns.find(fd); + if (cit == conns.end()) + { + continue; + } + Conn& c = *cit->second; + c.outbuf.insert(c.outbuf.end(), item.data.begin(), item.data.end()); + if (!do_write(c)) + { + close_conn(fd); + continue; + } + update_interest(c); + } + } + + void run() + { + constexpr int max_events = 64; + std::vector events(max_events); + while (running.load()) + { + const int n = + epoll_wait(epoll_fd, events.data(), max_events, /*timeout*/ -1); + if (n < 0) + { + if (errno == EINTR) + { + continue; + } + logf("epoll_wait error: %s", std::strerror(errno)); + break; + } + + for (int i = 0; i < n; ++i) + { + const int fd = events[i].data.fd; + if (fd == stop_fd) + { + running.store(false); + break; + } + if (fd == wake_fd) + { + drain_pending_out(); + continue; + } + if (fd == listen_fd) + { + accept_all(); + continue; + } + on_conn_event(fd, events[i].events); + } + } + + // Tear down all live connections on the loop thread. + while (!conns.empty()) + { + close_conn(conns.begin()->first); + } + } + + public: + OpenSSLServer( + const std::string& cert_pem, + const std::string& key_pem, + const std::string& host, + uint16_t port, + OnData on_data_, + OnClose on_close_ = {}, + bool verbose_ = false) : + on_data(std::move(on_data_)), + on_close(std::move(on_close_)), + verbose(verbose_) + { + ctx = SSL_CTX_new(TLS_server_method()); + if (ctx == nullptr) + { + throw std::runtime_error("SSL_CTX_new failed"); + } + SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION); + if (!load_cert_key(ctx, cert_pem, key_pem)) + { + SSL_CTX_free(ctx); + ctx = nullptr; + throw std::runtime_error("Failed to load server cert/key"); + } + + listen_fd = socket(AF_INET, SOCK_STREAM, 0); + if (listen_fd < 0) + { + cleanup(); + throw std::runtime_error("socket() failed"); + } + const int one = 1; + setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + // SO_REUSEPORT is the idiom that will let each worker run its own + // listening socket + epoll loop in the production design. + setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + if (inet_pton(AF_INET, host.c_str(), &addr.sin_addr) != 1) + { + cleanup(); + throw std::runtime_error("inet_pton failed"); + } + if ( + bind( + listen_fd, reinterpret_cast(&addr), sizeof(addr)) != 0) + { + cleanup(); + throw std::runtime_error("bind() failed"); + } + if (listen(listen_fd, SOMAXCONN) != 0) + { + cleanup(); + throw std::runtime_error("listen() failed"); + } + if (!set_nonblocking(listen_fd)) + { + cleanup(); + throw std::runtime_error("set_nonblocking(listen) failed"); + } + + // Read back the actual bound port (supports ephemeral port 0). + sockaddr_in bound{}; + socklen_t blen = sizeof(bound); + if ( + getsockname( + listen_fd, reinterpret_cast(&bound), &blen) == 0) + { + bound_port = ntohs(bound.sin_port); + } + + epoll_fd = epoll_create1(0); + if (epoll_fd < 0) + { + cleanup(); + throw std::runtime_error("epoll_create1 failed"); + } + stop_fd = eventfd(0, EFD_NONBLOCK); + if (stop_fd < 0) + { + cleanup(); + throw std::runtime_error("eventfd failed"); + } + wake_fd = eventfd(0, EFD_NONBLOCK); + if (wake_fd < 0) + { + cleanup(); + throw std::runtime_error("eventfd (wake) failed"); + } + + epoll_event ev{}; + ev.data.fd = listen_fd; + ev.events = EPOLLIN; + epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev); + ev.data.fd = stop_fd; + epoll_ctl(epoll_fd, EPOLL_CTL_ADD, stop_fd, &ev); + ev.data.fd = wake_fd; + epoll_ctl(epoll_fd, EPOLL_CTL_ADD, wake_fd, &ev); + } + + OpenSSLServer(const OpenSSLServer&) = delete; + OpenSSLServer& operator=(const OpenSSLServer&) = delete; + OpenSSLServer(OpenSSLServer&&) = delete; + OpenSSLServer& operator=(OpenSSLServer&&) = delete; + + ~OpenSSLServer() + { + stop(); + cleanup(); + } + + uint16_t port() const + { + return bound_port; + } + + void start() + { + running.store(true); + loop_thread = std::thread([this]() { run(); }); + } + + void stop() + { + if (!running.exchange(false)) + { + if (loop_thread.joinable()) + { + loop_thread.join(); + } + return; + } + if (stop_fd >= 0) + { + const uint64_t one = 1; + [[maybe_unused]] auto w = ::write(stop_fd, &one, sizeof(one)); + } + if (loop_thread.joinable()) + { + loop_thread.join(); + } + } + + // Thread-safe. Queue plaintext to be encrypted and written to `conn_id`. + void send(uint64_t conn_id, const uint8_t* data, size_t len) + { + { + std::lock_guard g(out_mutex); + pending_out.push_back( + {conn_id, std::vector(data, data + len), false}); + } + wake(); + } + + // Thread-safe. Request that `conn_id` be torn down. + void close_connection(uint64_t conn_id) + { + { + std::lock_guard g(out_mutex); + pending_out.push_back({conn_id, {}, true}); + } + wake(); + } + + private: + void cleanup() + { + if (wake_fd >= 0) + { + ::close(wake_fd); + wake_fd = -1; + } + if (stop_fd >= 0) + { + ::close(stop_fd); + stop_fd = -1; + } + if (epoll_fd >= 0) + { + ::close(epoll_fd); + epoll_fd = -1; + } + if (listen_fd >= 0) + { + ::close(listen_fd); + listen_fd = -1; + } + if (ctx != nullptr) + { + SSL_CTX_free(ctx); + ctx = nullptr; + } + } + }; +} diff --git a/src/host/tls/openssl_session_manager.h b/src/host/tls/openssl_session_manager.h new file mode 100644 index 000000000000..8ce1e86a121e --- /dev/null +++ b/src/host/tls/openssl_session_manager.h @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +// Bridges the OpenSSL-native transport (OpenSSLServer) to ccf::Session objects. +// +// This is the seam the real HTTP/HTTP2 sessions plug into once TLS lives in the +// connection layer: +// * inbound plaintext from a connection -> ccf::Session::handle_incoming_data +// * ccf::Session output (via ccf::SessionWriter) -> OpenSSLServer::send, which +// encrypts + writes with backpressure +// * connection teardown -> the owning session is dropped +// +// One ccf::Session is created per connection by a caller-supplied factory (e.g. +// "make an HTTPServerSession for this interface"). Sessions are created lazily +// on first inbound data and removed on close. +// +// Threading: OpenSSLServer invokes on_data/on_close on its epoll thread; the +// session may then process on OrderedTasks workers and reply via write_outbound +// from those threads. write_outbound/close_socket forward to OpenSSLServer's +// thread-safe send/close_connection, so this class is safe to call from any +// thread. The sessions map is guarded by a mutex. + +#include "ccf/node/session.h" +#include "enclave/session_writer.h" +#include "host/tls/openssl_server.h" + +#include +#include +#include +#include +#include +#include + +namespace asynchost +{ + class OpenSSLSessionManager : public ccf::SessionWriter + { + public: + // Creates the protocol session for a freshly seen connection. `writer` is + // this manager - the session emits its (plaintext) output through it. + using SessionFactory = std::function( + ::tcp::ConnID conn_id, ccf::SessionWriter& writer)>; + + private: + std::unique_ptr server; + SessionFactory factory; + + std::mutex sessions_mutex; + std::unordered_map<::tcp::ConnID, std::shared_ptr> sessions; + + void on_data(uint64_t id, std::vector data) + { + const auto conn_id = static_cast<::tcp::ConnID>(id); + std::shared_ptr session; + { + std::lock_guard guard(sessions_mutex); + auto it = sessions.find(conn_id); + if (it == sessions.end()) + { + session = factory(conn_id, *this); + sessions.emplace(conn_id, session); + } + else + { + session = it->second; + } + } + + if (session != nullptr) + { + session->handle_incoming_data({data.data(), data.size()}); + } + } + + void on_close(uint64_t id) + { + const auto conn_id = static_cast<::tcp::ConnID>(id); + std::lock_guard guard(sessions_mutex); + sessions.erase(conn_id); + } + + public: + OpenSSLSessionManager( + const std::string& cert_pem, + const std::string& key_pem, + const std::string& host, + uint16_t port, + SessionFactory factory_, + bool verbose = false) : + factory(std::move(factory_)) + { + server = std::make_unique( + cert_pem, + key_pem, + host, + port, + [this](uint64_t id, std::vector data) { + on_data(id, std::move(data)); + }, + [this](uint64_t id) { on_close(id); }, + verbose); + } + + void start() + { + server->start(); + } + + void stop() + { + server->stop(); + } + + uint16_t port() const + { + return server->port(); + } + + // ccf::SessionWriter (callable from any thread). + + void write_outbound( + ::tcp::ConnID id, + std::span data, + sockaddr /*addr*/ = {}) override + { + server->send( + static_cast(id), data.data(), data.size()); + } + + void close_socket(::tcp::ConnID id) override + { + { + std::lock_guard guard(sessions_mutex); + sessions.erase(id); + } + server->close_connection(static_cast(id)); + } + }; +} diff --git a/src/node/node_state.h b/src/node/node_state.h index caf4357872a8..235a9c8cfba0 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -441,7 +441,7 @@ namespace ccf std::shared_ptr> cmd_forwarder; std::shared_ptr commit_callbacks = nullptr; std::shared_ptr signature_cache = nullptr; - std::shared_ptr rpcsessions; + std::shared_ptr rpcsessions; std::shared_ptr history; std::shared_ptr encryptor; @@ -641,7 +641,7 @@ namespace ccf NodeState( ringbuffer::AbstractWriterFactory& writer_factory, NetworkState& network, - std::shared_ptr rpcsessions, + std::shared_ptr rpcsessions, ccf::crypto::CurveID curve_id_) : sm("NodeState", NodeStartupState::uninitialized), curve_id(curve_id_), diff --git a/src/quic/quic_session.h b/src/quic/quic_session.h index 3d49d92ab040..5f1042193711 100644 --- a/src/quic/quic_session.h +++ b/src/quic/quic_session.h @@ -241,15 +241,11 @@ namespace quic const_cast(data.data()), data.size(), addr); } - void handle_incoming_data(std::span data) override + void handle_incoming_data( + std::span data, sockaddr addr) override { - auto [_, addr_family, addr_data, body] = - ringbuffer::read_message(data); - - task_scheduler->add_action(std::make_shared( - shared_from_this(), - body, - udp::sockaddr_decode(addr_family, addr_data))); + task_scheduler->add_action( + std::make_shared(shared_from_this(), data, addr)); } virtual void recv(const uint8_t* data_, size_t size_, sockaddr addr_) = 0; From b543bf7980b8fb7c5a4b20f45ce1eefa81e87a44 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Wed, 24 Jun 2026 14:32:22 +0000 Subject: [PATCH 03/59] WIP: OpenSSL-native RPC cutover - sessions plaintext+SessionWriter, transport cert-deferred listening + ALPN + outbound client, RPCConnectionManager (AbstractRPCSessions). Not yet wired into enclave/run.cpp. --- src/enclave/client_session.h | 60 ++ src/enclave/no_more_sessions.h | 33 +- src/enclave/session.h | 108 +-- src/host/rpc_connection_manager.h | 977 ++++++++++--------------- src/host/rpc_socket_set.h | 263 ------- src/host/test/openssl_server_test.cpp | 4 +- src/host/tls/openssl_server.h | 432 ++++++++++- src/host/tls/openssl_session_manager.h | 79 +- src/http/http2_session.h | 78 +- src/http/http_session.h | 136 +++- 10 files changed, 1156 insertions(+), 1014 deletions(-) create mode 100644 src/enclave/client_session.h delete mode 100644 src/host/rpc_socket_set.h diff --git a/src/enclave/client_session.h b/src/enclave/client_session.h new file mode 100644 index 000000000000..a7dd074c1efd --- /dev/null +++ b/src/enclave/client_session.h @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +#include "http/http_builder.h" +#include "tcp/msg_types.h" + +#include + +namespace ccf +{ + class ClientSession + { + public: + virtual ~ClientSession() = default; + + using HandleDataCallback = std::function&& body)>; + + using HandleErrorCallback = + std::function; + + // Opens an outbound transport connection for `id` to host:service. Supplied + // by the connection manager when it creates the client session. + using ConnectCallback = std::function; + + protected: + HandleDataCallback handle_data_cb; + HandleErrorCallback handle_error_cb; + + private: + int64_t client_session_id; + ConnectCallback connect_cb; + + public: + ClientSession(int64_t client_session_id_, ConnectCallback connect_cb_) : + client_session_id(client_session_id_), + connect_cb(std::move(connect_cb_)) + {} + + virtual void send_request(::http::Request&& request) = 0; + + virtual void connect( + const std::string& hostname, + const std::string& service, + const HandleDataCallback f, + const HandleErrorCallback e = nullptr) + { + if (connect_cb) + { + connect_cb(client_session_id, hostname, service); + } + handle_data_cb = f; + handle_error_cb = e; + } + }; +} diff --git a/src/enclave/no_more_sessions.h b/src/enclave/no_more_sessions.h index 1b70845c89b4..12814baca241 100644 --- a/src/enclave/no_more_sessions.h +++ b/src/enclave/no_more_sessions.h @@ -3,20 +3,17 @@ #pragma once #include "ccf/odata_error.h" -#include "enclave/tls_session.h" namespace ccf { // Session wrapper used when an interface is over its soft session limit. // - // It completes the TLS handshake far enough to send a single 503 response - // explaining that the service is busy, then closes the connection. It is - // templated on the concrete server session type (HTTPServerSession / - // HTTP2ServerSession) so it reuses that session's TLS plumbing and response - // machinery. + // It sends a single 503 response explaining that the service is busy, then + // closes the connection. It is templated on the concrete server session type + // (HTTPServerSession / HTTP2ServerSession) so it reuses that session's + // response machinery. // - // Previously nested inside RPCSessions; pulled out so both the legacy - // RPCSessions and the new RPCConnectionManager can share it. + // Previously nested inside RPCSessions; pulled out so it can be shared. template class NoMoreSessionsImpl : public Base { @@ -25,21 +22,15 @@ namespace ccf NoMoreSessionsImpl(Ts&&... ts) : Base(std::forward(ts)...) {} - void handle_incoming_data_thread(std::vector&& data) override + void handle_incoming_data_thread(std::vector&& /*data*/) override { - Base::tls_io->recv_buffered(data.data(), data.size()); + // The transport already terminated TLS, so we can respond immediately. + Base::send_odata_error_response(ccf::ErrorDetails{ + HTTP_STATUS_SERVICE_UNAVAILABLE, + ccf::errors::SessionCapExhausted, + "Service is currently busy and unable to serve new connections"}); - if (Base::tls_io->get_status() == ccf::SessionStatus::ready) - { - // Send response describing soft session limit - Base::send_odata_error_response(ccf::ErrorDetails{ - HTTP_STATUS_SERVICE_UNAVAILABLE, - ccf::errors::SessionCapExhausted, - "Service is currently busy and unable to serve new connections"}); - - // Close connection - Base::tls_io->close(); - } + Base::close_session(); } }; } diff --git a/src/enclave/session.h b/src/enclave/session.h index 7636eb1c635c..1e01e672038e 100644 --- a/src/enclave/session.h +++ b/src/enclave/session.h @@ -3,13 +3,14 @@ #pragma once #include "ccf/node/session.h" -#include "enclave/tls_session.h" +#include "enclave/session_writer.h" #include "tasks/ordered_tasks.h" #include "tasks/task.h" #include "tasks/task_system.h" #include "tcp/msg_types.h" #include +#include namespace ccf { @@ -116,110 +117,59 @@ namespace ccf virtual void close_session_thread() = 0; }; - class EncryptedSession : public ThreadedSession + // A protocol session (HTTP/HTTP2/...) running over a transport that owns the + // TLS connection (the host-side OpenSSL connection). It receives and emits + // plaintext: inbound bytes are already decrypted, and outbound bytes are + // handed to a SessionWriter which encrypts and writes them. The peer + // certificate and SNI (captured by the transport at handshake) are provided + // for caller authentication. + class PlaintextSession : public ThreadedSession { public: virtual bool parse(std::span data) = 0; protected: - std::shared_ptr tls_io; ::tcp::ConnID session_id; + ccf::SessionWriter& session_writer; + std::vector peer_cert_; + std::string sni_; - EncryptedSession( + PlaintextSession( ::tcp::ConnID session_id_, - ringbuffer::AbstractWriterFactory& writer_factory, - std::unique_ptr ctx) : + ccf::SessionWriter& writer, + std::vector peer_cert = {}, + std::string sni = {}) : ThreadedSession(session_id_), - tls_io(std::make_shared( - session_id_, writer_factory, std::move(ctx))), - session_id(session_id_) + session_id(session_id_), + session_writer(writer), + peer_cert_(std::move(peer_cert)), + sni_(std::move(sni)) {} public: - void send_data_thread(std::vector&& data) override - { - tls_io->send_data(data.data(), data.size()); - } - - void handle_incoming_data_thread(std::vector&& data) override + const std::vector& peer_cert() const { - tls_io->recv_buffered(data.data(), data.size()); - - LOG_TRACE_FMT("recv called with {} bytes", data.size()); - - // Try to parse all incoming data, reusing the vector we were just passed - // for storage. Increase the size if the received vector was too small - // (for the case where this chunk is very small, but we had some previous - // data to continue reading). - constexpr auto min_read_block_size = 4096; - if (data.size() < min_read_block_size) - { - data.resize(min_read_block_size); - } - - auto n_read = tls_io->read(data.data(), data.size(), false); - - while (true) - { - if (n_read == 0) - { - return; - } - - LOG_TRACE_FMT("Going to parse {} bytes", n_read); - - bool cont = parse({data.data(), n_read}); - if (!cont) - { - return; - } - - // Used all provided bytes - check if more are available - n_read = tls_io->read(data.data(), data.size(), false); - } + return peer_cert_; } - void close_session_thread() override + const std::string& hostname() const { - tls_io->close(); + return sni_; } - }; - - class UnencryptedSession : public ccf::ThreadedSession - { - public: - virtual bool parse(std::span data) = 0; - - protected: - ::tcp::ConnID session_id; - ringbuffer::WriterPtr to_host; - - UnencryptedSession( - ::tcp::ConnID session_id_, - ringbuffer::AbstractWriterFactory& writer_factory_) : - ccf::ThreadedSession(session_id_), - session_id(session_id_), - to_host(writer_factory_.create_writer_to_outside()) - {} void send_data_thread(std::vector&& data) override { - RINGBUFFER_WRITE_MESSAGE( - ::tcp::tcp_outbound, - to_host, - session_id, - serializer::ByteRange{data.data(), data.size()}); + session_writer.write_outbound(session_id, {data.data(), data.size()}); } - void close_session_thread() override + void handle_incoming_data_thread(std::vector&& data) override { - RINGBUFFER_WRITE_MESSAGE( - ::tcp::tcp_stop, to_host, session_id, std::string("Session closed")); + parse({data.data(), data.size()}); } - void handle_incoming_data_thread(std::vector&& data) override + void close_session_thread() override { - parse(data); + session_writer.close_socket(session_id); } }; } diff --git a/src/host/rpc_connection_manager.h b/src/host/rpc_connection_manager.h index a9ece021ec2d..80f44f00da20 100644 --- a/src/host/rpc_connection_manager.h +++ b/src/host/rpc_connection_manager.h @@ -2,757 +2,528 @@ // Licensed under the Apache 2.0 License. #pragma once -// NOTE: This is a fresh, standalone rewrite that merges the responsibilities -// previously split across: -// - src/enclave/rpc_sessions.h (RPCSessions: ccf::Session lifecycle, -// listening-interface limits, certs) -// - src/host/rpc_connections.h (RPCConnectionsImpl: libuv sockets, ConnID -// allocation, socket <-> ringbuffer bridge) +// Host-side, OpenSSL-native RPC connection manager. // -// The host/enclave ringbuffer split was technical debt from SGX. With that gone -// we own both the libuv sockets and the ccf::Session objects in one place, and -// data flows directly: -// inbound: socket on_read --------------------> session.handle_incoming_data -// outbound: session -> SessionWriter sink -> LoopExecutor -> socket.write +// Replaces the SGX-era split of RPCSessions (enclave) + RPCConnectionsImpl +// (host) bridged over a ringbuffer. It owns one OpenSSL transport per listening +// interface (TLS terminated in the connection, see host/tls/openssl_server.h), +// creates the protocol session for each connection, applies per-interface +// session caps and certificates, and exposes outbound client creation. It +// implements ccf::AbstractRPCSessions so the node (NodeState/frontends) reaches +// it without depending on the transport backend. // -// This file is intentionally NOT yet added to the build. It is meant to be -// reviewed standalone and then plugged in (potentially behind a compile-time -// switch) once complete. It targets the *post-swap* session interfaces: -// * Sessions are constructed with a `ccf::SessionWriter&` instead of a -// `ringbuffer::AbstractWriterFactory&`. -// * Session::handle_incoming_data receives raw socket bytes (no serialised -// tcp_inbound / udp_inbound framing) plus the source address (used by -// datagram transports, ignored by stream transports). -// Those session-side changes are deliberately left for the wiring step. -// -// Socket I/O is factored per transport into RPCSocketSet/ (see -// rpc_socket_set.h); this manager owns one of each and holds all the shared -// session/interface/cert state. That composition is purely an in-process -// organisation detail - there is no ringbuffer or arms-length boundary. +// Cert-deferred listening: interfaces bind at startup even before their +// certificate exists (a joining node receives the service cert later). A TLS +// interface with no cert yet refuses connections until set_cert() supplies one; +// UNSECURED interfaces listen in plaintext. -#include "ccf/pal/locking.h" +#include "ccf/crypto/pem.h" #include "ccf/service/node_info_network.h" #include "ds/internal_logger.h" +#include "enclave/abstract_rpc_sessions.h" #include "enclave/no_more_sessions.h" -#include "enclave/session.h" -#include "enclave/session_writer.h" -#include "forwarder_types.h" -#include "host/loop_executor.h" -#include "host/rpc_socket_set.h" +#include "enclave/rpc_map.h" +#include "host/tls/openssl_session_manager.h" +#include "http/error_reporter.h" #include "http/http2_session.h" #include "http/http_session.h" -#include "node/rpc/custom_protocol_subsystem.h" #include "node/session_metrics.h" -#include "quic/quic_session.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" #include #include #include -#include +#include +#include +#include +#include +#include namespace ccf { - using QUICSessionImpl = quic::QUICEchoSession; - - static constexpr size_t cm_max_open_sessions_soft_default = 1000; - static constexpr size_t cm_max_open_sessions_hard_default = 1010; - static const ccf::Endorsement cm_endorsement_default = { + static constexpr size_t ocm_max_open_sessions_soft_default = 1000; + static constexpr size_t ocm_max_open_sessions_hard_default = 1010; + static const ccf::Endorsement ocm_endorsement_default = { ccf::Authority::SERVICE}; - // Single owner of libuv sockets and ccf::Session objects for RPC traffic. - // - // Threading model: - // * The `sockets` map is only ever touched on the libuv loop thread - // (listen/connect/accept/on_read/write/close). It therefore needs no - // lock. - // * The `sessions`/`listening_interfaces`/`certs` maps are touched both on - // the loop thread (accept/close) and on session worker threads - // (reply_async/find_session). They are guarded by `lock`. - // * Sessions run their work on OrderedTasks worker threads and call back - // into write_outbound()/close_socket() from those threads. Those methods - // only enqueue onto the LoopExecutor, which is thread-safe, and the real - // socket operation runs later on the loop thread. class RPCConnectionManager : public std::enable_shared_from_this, - public ccf::SessionWriter, - public ccf::SocketSetHost, - public ccf::AbstractRPCResponder, + public ccf::AbstractRPCSessions, public ::http::ErrorReporter { - public: - using ConnID = ::tcp::ConnID; - private: struct ListenInterface { - size_t open_sessions = 0; - size_t peak_sessions = 0; - size_t max_open_sessions_soft = 0; - size_t max_open_sessions_hard = 0; - ccf::Endorsement endorsement{}; + std::string name; + size_t max_open_sessions_soft = ocm_max_open_sessions_soft_default; + size_t max_open_sessions_hard = ocm_max_open_sessions_hard_default; + ccf::Endorsement endorsement = ocm_endorsement_default; http::ParserConfiguration http_configuration; - ccf::SessionMetrics::Errors errors{}; - ccf::ApplicationProtocol app_protocol; + ccf::ApplicationProtocol app_protocol = "HTTP1"; + + std::atomic open_sessions{0}; + std::atomic peak_sessions{0}; + std::atomic err_parsing{0}; + std::atomic err_payload_too_large{0}; + std::atomic err_header_too_large{0}; + + // The transport for this interface (created on listen()). + std::unique_ptr bridge; }; std::shared_ptr rpc_map; - std::shared_ptr loop_executor; - - std::shared_ptr custom_protocol_subsystem = - nullptr; - std::shared_ptr commit_callbacks_subsystem = - nullptr; - - // Loop-thread-only socket ownership, split by transport. - RPCSocketSet tcp_sockets; - RPCSocketSet udp_sockets; - - ccf::pal::Mutex lock; - std::map listening_interfaces; - std::unordered_map> certs; - std::unordered_map< - ConnID, - std::pair>> - sessions; - size_t sessions_peak = 0; - - // Positive IDs: sockets accepted/listened on the host side. - std::atomic next_server_id = 1; - // Negative IDs: outbound client sessions created locally (was create_client - // inside the enclave). Kept in a separate range to preserve the historical - // convention relied upon elsewhere (e.g. forwarding). - std::atomic next_client_id = -1; - - // ----- session construction --------------------------------------------- + std::shared_ptr custom_protocol_subsystem; + std::shared_ptr commit_callbacks_subsystem; + + std::mutex interfaces_mutex; + std::map> interfaces; + // cert/key PEM per endorsement authority (for cert-deferred listening). + std::map> certs; + + // Global connection-id source shared by all interface transports, so the + // session registry / reply routing have a single id space. + std::atomic shared_conn_id{1}; + // Outbound client sessions use the negative range, matching the historical + // convention relied upon by forwarding. + std::atomic next_client_id{-1}; + + std::shared_ptr<::http::ErrorReporter> error_reporter() + { + return shared_from_this(); + } + + // Build the protocol session for a connection on `li`, applying caps. + // Returns nullptr to refuse (hard cap). Runs on the interface's loop thread. + std::shared_ptr make_session( + ListenInterface* li, + ::tcp::ConnID conn_id, + ccf::SessionWriter& writer, + std::vector peer_cert) + { + const size_t open = li->open_sessions.load(); + if (open >= li->max_open_sessions_hard) + { + LOG_INFO_FMT( + "Refusing session {} on interface {} - {} open, hard limit {}", + conn_id, + li->name, + open, + li->max_open_sessions_hard); + return nullptr; + } + + const size_t now_open = ++li->open_sessions; + size_t prev_peak = li->peak_sessions.load(); + while (now_open > prev_peak && + !li->peak_sessions.compare_exchange_weak(prev_peak, now_open)) + { + } + + if (open >= li->max_open_sessions_soft) + { + LOG_INFO_FMT( + "Soft-refusing session {} (503) on interface {} - {} open, soft " + "limit {}", + conn_id, + li->name, + open, + li->max_open_sessions_soft); + return make_capped_session(li, conn_id, writer, std::move(peer_cert)); + } + + return make_server_session(li, conn_id, writer, std::move(peer_cert)); + } std::shared_ptr make_server_session( - const std::string& app_protocol, - ConnID id, - const ListenInterfaceID& listen_interface_id, - std::unique_ptr&& ctx, - const http::ParserConfiguration& parser_configuration) + ListenInterface* li, + ::tcp::ConnID conn_id, + ccf::SessionWriter& writer, + std::vector peer_cert) { - // NOTE: post-swap, these session constructors take `*this` (a - // ccf::SessionWriter&) where they previously took a - // ringbuffer::AbstractWriterFactory&. - if (app_protocol == "HTTP2") + if (li->app_protocol == "HTTP2") { return std::make_shared<::http::HTTP2ServerSession>( rpc_map, - id, - listen_interface_id, - *this, - std::move(ctx), - parser_configuration, - shared_from_this()); + conn_id, + li->name, + writer, + std::move(peer_cert), + li->http_configuration, + error_reporter()); } - if (app_protocol == "HTTP1") + if (li->app_protocol == "HTTP1") { return std::make_shared<::http::HTTPServerSession>( rpc_map, - id, - listen_interface_id, - *this, - std::move(ctx), - parser_configuration, - shared_from_this(), + conn_id, + li->name, + writer, + std::move(peer_cert), + li->http_configuration, + error_reporter(), commit_callbacks_subsystem); } - if (custom_protocol_subsystem) - { - return custom_protocol_subsystem->create_session( - app_protocol, id, std::move(ctx)); - } - throw std::runtime_error(fmt::format( - "unknown protocol '{}' and custom protocol subsystem missing", - app_protocol)); + "Unsupported application protocol '{}' (custom protocols are not " + "supported on the OpenSSL RPC path)", + li->app_protocol)); } std::shared_ptr make_capped_session( - const ListenInterface& li, - ConnID id, - const ListenInterfaceID& listen_interface_id) + ListenInterface* li, + ::tcp::ConnID conn_id, + ccf::SessionWriter& writer, + std::vector peer_cert) { - // NOTE: post-swap, these session constructors take `*this` (a - // ccf::SessionWriter&) where they previously took a - // ringbuffer::AbstractWriterFactory&. - auto ctx = std::make_unique<::tls::Server>(certs[listen_interface_id]); - if (li.app_protocol == "HTTP2") + if (li->app_protocol == "HTTP2") { return std::make_shared>( rpc_map, - id, - listen_interface_id, - *this, - std::move(ctx), - li.http_configuration, - shared_from_this()); + conn_id, + li->name, + writer, + std::move(peer_cert), + li->http_configuration, + error_reporter()); } return std::make_shared>( rpc_map, - id, - listen_interface_id, - *this, - std::move(ctx), - li.http_configuration, - shared_from_this(), + conn_id, + li->name, + writer, + std::move(peer_cert), + li->http_configuration, + error_reporter(), commit_callbacks_subsystem); } - ListenInterface& get_interface_from_interface_id( - const ListenInterfaceID& id) + asynchost::OpenSSLSessionManager* primary_bridge() { - auto it = listening_interfaces.find(id); - if (it != listening_interfaces.end()) + for (auto& [name, li] : interfaces) { - return it->second; - } - throw std::logic_error( - fmt::format("No RPC interface for interface ID {}", id)); - } - - ConnID get_next_client_id() - { - std::lock_guard guard(lock); - auto id = next_client_id--; - const auto initial = id; - - if (next_client_id > 0) - { - next_client_id = -1; - } - - while (sessions.find(id) != sessions.end()) - { - id--; - if (id > 0) - { - id = -1; - } - if (id == initial) + if (li->bridge != nullptr) { - throw std::runtime_error("Exhausted all IDs for client sessions"); + return li->bridge.get(); } } - return id; - } - - // ----- outbound (loop thread, invoked via LoopExecutor) ----------------- - - void write_on_loop(ConnID id, std::vector data, sockaddr addr) - { - if ( - tcp_sockets.write(id, data, addr) || udp_sockets.write(id, data, addr)) - { - return; - } - LOG_DEBUG_FMT( - "Dropping {} outbound bytes for unknown socket {}", data.size(), id); - } - - void close_on_loop(ConnID id) - { - tcp_sockets.stop(id); - tcp_sockets.close(id); - udp_sockets.stop(id); - udp_sockets.close(id); - remove_session(id); + return nullptr; } public: - RPCConnectionManager( - std::shared_ptr rpc_map_, - std::shared_ptr loop_executor_) : - rpc_map(std::move(rpc_map_)), - loop_executor(std::move(loop_executor_)), - tcp_sockets(*this), - udp_sockets(*this) + explicit RPCConnectionManager(std::shared_ptr rpc_map_) : + rpc_map(std::move(rpc_map_)) {} - void set_custom_protocol_subsystem( - std::shared_ptr cpss) + ~RPCConnectionManager() override { - custom_protocol_subsystem = std::move(cpss); + stop(); } - void set_commit_callbacks_subsystem( - std::shared_ptr fcss) + void stop() { - commit_callbacks_subsystem = std::move(fcss); - } - - // ----- SocketSetHost (loop thread) -------------------------------------- - - ConnID get_next_server_id() override - { - return next_server_id++; - } - - void on_socket_start( - ConnID id, const ListenInterfaceID& interface_id, bool udp) override - { - accept(id, interface_id, udp); - } - - void on_socket_inbound( - ConnID id, const uint8_t* data, size_t len, sockaddr addr) override - { - auto session = find_session_for_inbound(id); - if (session == nullptr) + std::lock_guard guard(interfaces_mutex); + for (auto& [name, li] : interfaces) { - LOG_DEBUG_FMT("Ignoring inbound for unknown session {}", id); - return; + if (li->bridge != nullptr) + { + li->bridge->stop(); + } } - // Post-swap: handle_incoming_data takes raw bytes (no tcp_inbound / - // udp_inbound frame) plus the source address. `addr` is meaningful for - // datagram transports and ignored by stream sessions. - session->handle_incoming_data({data, len}, addr); - } - - void on_socket_gone(ConnID id) override - { - remove_session(id); - // Defer the socket erase so we are not destroying the behaviour that is - // currently executing this callback. - loop_executor->enqueue([self = shared_from_this(), id]() { - self->tcp_sockets.close(id); - self->udp_sockets.close(id); - }); - } - - // ----- SessionWriter (called from session worker threads) --------------- - - void write_outbound( - ConnID id, std::span data, sockaddr addr = {}) override - { - std::vector copy(data.begin(), data.end()); - loop_executor->enqueue( - [self = shared_from_this(), - id, - copy = std::move(copy), - addr]() mutable { - self->write_on_loop(id, std::move(copy), addr); - }); - } - - void close_socket(ConnID id) override - { - loop_executor->enqueue( - [self = shared_from_this(), id]() { self->close_on_loop(id); }); } - // ----- AbstractRPCResponder --------------------------------------------- - - bool reply_async( - ConnID id, bool terminate_after_send, std::vector&& data) - override + // Bind and start listening on `name` (which must have been configured via + // update_listening_interface_options). Returns the bound port (supports + // ephemeral port 0), or 0 on failure. + uint16_t listen( + const std::string& name, + const std::string& host, + const std::string& port) { - auto session = find_session(id); - if (session == nullptr) + std::lock_guard guard(interfaces_mutex); + auto it = interfaces.find(name); + if (it == interfaces.end()) { - LOG_DEBUG_FMT("Refusing to reply to unknown session {}", id); - return false; + throw std::logic_error(fmt::format( + "Cannot listen on unconfigured interface '{}'", name)); } + auto* li = it->second.get(); - LOG_DEBUG_FMT("Replying to session {}", id); - session->send_data(std::move(data)); + const bool plaintext = + li->endorsement.authority == ccf::Authority::UNSECURED; + const std::string alpn = + plaintext ? "" : (li->app_protocol == "HTTP2" ? "h2" : "http/1.1"); - if (terminate_after_send) + std::string cert_pem; + std::string key_pem; + if (!plaintext) { - session->close_session(); + auto c = certs.find(li->endorsement.authority); + if (c != certs.end()) + { + cert_pem = c->second.first; + key_pem = c->second.second; + } } - return true; - } - - // ----- ErrorReporter ---------------------------------------------------- - void report_parsing_error(const ListenInterfaceID& id) override - { - std::lock_guard guard(lock); - get_interface_from_interface_id(id).errors.parsing++; - } - - void report_request_payload_too_large_error( - const ListenInterfaceID& id) override - { - std::lock_guard guard(lock); - get_interface_from_interface_id(id).errors.request_payload_too_large++; - } + auto factory = + [this, li]( + ::tcp::ConnID cid, + ccf::SessionWriter& w, + std::vector pc) { + return make_session(li, cid, w, std::move(pc)); + }; + auto on_closed = [li](::tcp::ConnID) { + size_t expected = li->open_sessions.load(); + while (expected > 0 && + !li->open_sessions.compare_exchange_weak(expected, expected - 1)) + { + } + }; + + const uint16_t port_num = static_cast(std::stoi(port)); + li->bridge = std::make_unique( + cert_pem, + key_pem, + host, + port_num, + factory, + alpn, + plaintext, + false, + &shared_conn_id, + on_closed); + li->bridge->start(); + return li->bridge->port(); + } + + // ----- AbstractRPCSessions / AbstractRPCResponder ----------------------- - void report_request_header_too_large_error( - const ListenInterfaceID& id) override + std::shared_ptr create_client( + const std::shared_ptr<::tls::Cert>& /*cert*/, + const std::string& app_protocol = "HTTP1") override { - std::lock_guard guard(lock); - get_interface_from_interface_id(id).errors.request_header_too_large++; - } + // TODO: wire outbound client certificate + CA verification (see + // OpenSSLServer client context). Currently the outbound connection is + // unverified and presents no client certificate. + const int64_t id = next_client_id.fetch_sub(1); - // ----- interface configuration / certs ---------------------------------- + asynchost::OpenSSLSessionManager* bridge = nullptr; + { + std::lock_guard guard(interfaces_mutex); + bridge = primary_bridge(); + } + if (bridge == nullptr) + { + throw std::runtime_error( + "Cannot create outbound client: no listening interface"); + } - void update_listening_interface_options( - const ccf::NodeInfoNetwork& node_info) - { - std::lock_guard guard(lock); + auto connect_cb = + [bridge](int64_t cid, const std::string& h, const std::string& s) { + bridge->connect(static_cast<::tcp::ConnID>(cid), h, s); + }; - for (const auto& [name, interface] : node_info.rpc_interfaces) + std::shared_ptr session; + std::shared_ptr as_session; + if (app_protocol == "HTTP2") { - auto& li = listening_interfaces[name]; - - li.max_open_sessions_soft = interface.max_open_sessions_soft.value_or( - cm_max_open_sessions_soft_default); - li.max_open_sessions_hard = interface.max_open_sessions_hard.value_or( - cm_max_open_sessions_hard_default); - li.endorsement = interface.endorsement.value_or(cm_endorsement_default); - li.http_configuration = - interface.http_configuration.value_or(http::ParserConfiguration{}); - li.app_protocol = interface.app_protocol.value_or("HTTP1"); - - LOG_INFO_FMT( - "Setting max open sessions on interface \"{}\" ({}) to [{}, {}] and " - "endorsement authority to {}", - name, - interface.bind_address, - li.max_open_sessions_soft, - li.max_open_sessions_hard, - li.endorsement.authority); + auto s = std::make_shared<::http::HTTP2ClientSession>( + id, *bridge, connect_cb); + session = s; + as_session = s; + } + else + { + auto s = std::make_shared<::http::HTTPClientSession>( + id, *bridge, connect_cb); + session = s; + as_session = s; } - } - - void set_node_cert(const ccf::crypto::Pem& cert_, const ccf::crypto::Pem& pk) - { - set_cert(ccf::Authority::NODE, cert_, pk); - } - void set_network_cert( - const ccf::crypto::Pem& cert_, const ccf::crypto::Pem& pk) - { - set_cert(ccf::Authority::SERVICE, cert_, pk); + bridge->register_session(static_cast<::tcp::ConnID>(id), as_session); + return session; } - void set_cert( - ccf::Authority authority, - const ccf::crypto::Pem& cert_, - const ccf::crypto::Pem& pk) + bool reply_async( + int64_t id, bool terminate_after_reply, std::vector&& data) + override { - // Caller authentication is done by each frontend by looking up the - // caller's certificate in the relevant store table; verification is not - // required here. - auto cert = std::make_shared<::tls::Cert>( - nullptr, cert_, pk, std::nullopt, /*auth_required ==*/false); - - std::lock_guard guard(lock); - for (auto& [listen_interface_id, interface] : listening_interfaces) + std::vector bridges; { - if (interface.endorsement.authority == authority) + std::lock_guard guard(interfaces_mutex); + for (auto& [name, li] : interfaces) { - certs.insert_or_assign(listen_interface_id, cert); + if (li->bridge != nullptr) + { + bridges.push_back(li->bridge.get()); + } } } - } - - ccf::SessionMetrics get_session_metrics() - { - ccf::SessionMetrics sm; - std::lock_guard guard(lock); - sm.active = sessions.size(); - sm.peak = sessions_peak; - for (const auto& [name, interface] : listening_interfaces) + for (auto* bridge : bridges) { - sm.interfaces[name] = { - interface.open_sessions, - interface.peak_sessions, - interface.max_open_sessions_soft, - interface.max_open_sessions_hard, - interface.errors}; + auto session = bridge->get_session(id); + if (session != nullptr) + { + session->send_data(std::move(data)); + if (terminate_after_reply) + { + session->close_session(); + } + return true; + } } - return sm; + LOG_DEBUG_FMT("Refusing to reply to unknown session {}", id); + return false; } - ccf::ApplicationProtocol get_app_protocol_main_interface() const + ccf::ApplicationProtocol get_app_protocol_main_interface() const override { - if (listening_interfaces.empty()) + // NB: const_cast to lock - the mutex is logically mutable here. + auto& self = const_cast(*this); + std::lock_guard guard(self.interfaces_mutex); + if (self.interfaces.empty()) { throw std::logic_error("No listening interface for this node"); } - return listening_interfaces.begin()->second.app_protocol; + return self.interfaces.begin()->second->app_protocol; } - // ----- listen / connect (loop thread) ----------------------------------- - - bool listen( - const std::string& host, - const std::string& port, - const ListenInterfaceID& name, - bool udp = false) + ccf::SessionMetrics get_session_metrics() override { - const auto id = next_server_id++; - if (udp) + ccf::SessionMetrics sm; + std::lock_guard guard(interfaces_mutex); + size_t active = 0; + size_t peak = 0; + for (auto& [name, li] : interfaces) { - return udp_sockets.listen(id, host, port, name); - } - return tcp_sockets.listen(id, host, port, name); - } + ccf::SessionMetrics::Errors errs; + errs.parsing = li->err_parsing.load(); + errs.request_payload_too_large = li->err_payload_too_large.load(); + errs.request_header_too_large = li->err_header_too_large.load(); - // ----- session lifecycle ------------------------------------------------ + sm.interfaces[name] = { + li->open_sessions.load(), + li->peak_sessions.load(), + li->max_open_sessions_soft, + li->max_open_sessions_hard, + errs}; - std::shared_ptr find_session(ConnID id) - { - std::lock_guard guard(lock); - auto search = sessions.find(id); - if (search == sessions.end()) - { - return nullptr; + active += li->open_sessions.load(); + peak += li->peak_sessions.load(); } - return search->second.second; + sm.active = active; + sm.peak = peak; + return sm; } - // Create a session for a newly started connection, applying per-interface - // session caps. Runs on the loop thread (from on_socket_start). - void accept( - ConnID id, const ListenInterfaceID& listen_interface_id, bool udp) + void set_node_cert( + const ccf::crypto::Pem& cert, const ccf::crypto::Pem& pk) override { - std::lock_guard guard(lock); - - if (sessions.find(id) != sessions.end()) - { - throw std::logic_error( - fmt::format("Duplicate conn ID received: {}", id)); - } - - auto it = listening_interfaces.find(listen_interface_id); - if (it == listening_interfaces.end()) - { - throw std::logic_error(fmt::format( - "Can't accept RPC session {} from unknown interface {}", - id, - listen_interface_id)); - } - auto& li = it->second; - - if (udp) - { - accept_udp(id, listen_interface_id, li); - return; - } - - const bool needs_cert = li.endorsement.authority != Authority::UNSECURED; - if (needs_cert && certs.find(listen_interface_id) == certs.end()) - { - LOG_DEBUG_FMT( - "Refusing TLS session {} - interface {} has no certificate yet", - id, - listen_interface_id); - close_socket(id); - return; - } + set_cert(ccf::Authority::NODE, cert, pk); + } - if (li.open_sessions >= li.max_open_sessions_hard) - { - LOG_INFO_FMT( - "Refusing session {} - {} sessions on interface {}, hard limit {}", - id, - li.open_sessions, - listen_interface_id, - li.max_open_sessions_hard); - close_socket(id); - return; - } + void set_network_cert( + const ccf::crypto::Pem& cert, const ccf::crypto::Pem& pk) override + { + set_cert(ccf::Authority::SERVICE, cert, pk); + } - std::shared_ptr session; - if (li.open_sessions >= li.max_open_sessions_soft) - { - LOG_INFO_FMT( - "Soft-refusing session {} (503) - {} sessions on interface {}, soft " - "limit {}", - id, - li.open_sessions, - listen_interface_id, - li.max_open_sessions_soft); - session = make_capped_session(li, id, listen_interface_id); - } - else + void set_cert( + ccf::Authority authority, + const ccf::crypto::Pem& cert, + const ccf::crypto::Pem& pk) + { + std::lock_guard guard(interfaces_mutex); + certs[authority] = {cert.str(), pk.str()}; + for (auto& [name, li] : interfaces) { - LOG_DEBUG_FMT( - "Accepting session {} on interface \"{}\"", id, listen_interface_id); - - std::unique_ptr ctx; - if (li.endorsement.authority == Authority::UNSECURED) + if (li->endorsement.authority == authority && li->bridge != nullptr) { - ctx = std::make_unique(); + li->bridge->set_server_cert(cert.str(), pk.str()); } - else - { - ctx = std::make_unique<::tls::Server>( - certs[listen_interface_id], li.app_protocol == "HTTP2"); - } - - session = make_server_session( - li.app_protocol, - id, - listen_interface_id, - std::move(ctx), - li.http_configuration); } - - sessions.emplace( - id, std::make_pair(listen_interface_id, std::move(session))); - li.open_sessions++; - li.peak_sessions = std::max(li.peak_sessions, li.open_sessions); - sessions_peak = std::max(sessions_peak, sessions.size()); } - void remove_session(ConnID id) + void update_listening_interface_options( + const ccf::NodeInfoNetwork& node_info) override { - std::lock_guard guard(lock); - LOG_DEBUG_FMT("Closing session {}", id); - const auto search = sessions.find(id); - if (search != sessions.end()) + std::lock_guard guard(interfaces_mutex); + for (const auto& [name, interface] : node_info.rpc_interfaces) { - auto it = listening_interfaces.find(search->second.first); - if (it != listening_interfaces.end()) + auto it = interfaces.find(name); + if (it == interfaces.end()) { - it->second.open_sessions--; + it = interfaces.emplace(name, std::make_unique()) + .first; + it->second->name = name; } - sessions.erase(search); + auto* li = it->second.get(); + + li->max_open_sessions_soft = interface.max_open_sessions_soft.value_or( + ocm_max_open_sessions_soft_default); + li->max_open_sessions_hard = interface.max_open_sessions_hard.value_or( + ocm_max_open_sessions_hard_default); + li->endorsement = + interface.endorsement.value_or(ocm_endorsement_default); + li->http_configuration = + interface.http_configuration.value_or(http::ParserConfiguration{}); + li->app_protocol = interface.app_protocol.value_or("HTTP1"); + + LOG_INFO_FMT( + "Setting max open sessions on interface \"{}\" ({}) to [{}, {}] and " + "endorsement authority to {}", + name, + interface.bind_address, + li->max_open_sessions_soft, + li->max_open_sessions_hard, + li->endorsement.authority); } } - std::shared_ptr create_client( - const std::shared_ptr<::tls::Cert>& cert, - const std::string& app_protocol = "HTTP1") + void set_custom_protocol_subsystem( + std::shared_ptr cpss) override { - auto id = get_next_client_id(); - auto ctx = std::make_unique<::tls::Client>(cert); - - LOG_DEBUG_FMT("Creating client session {}", id); - - std::shared_ptr session; - if (app_protocol == "HTTP2") - { - session = std::make_shared<::http::HTTP2ClientSession>( - id, *this, std::move(ctx)); - } - else if (app_protocol == "HTTP1") - { - session = std::make_shared<::http::HTTPClientSession>( - id, *this, std::move(ctx)); - } - else - { - throw std::runtime_error("unsupported client application protocol"); - } - - { - std::lock_guard guard(lock); - sessions.emplace(id, std::make_pair("", session)); - sessions_peak = std::max(sessions_peak, sessions.size()); - } - return session; + custom_protocol_subsystem = std::move(cpss); } - // Open the outbound socket for a client session created via create_client. - // Marshalled onto the loop thread. - void connect(ConnID id, const std::string& host, const std::string& port) + void set_commit_callbacks_subsystem( + std::shared_ptr fcss) override { - loop_executor->enqueue([self = shared_from_this(), id, host, port]() { - if (!self->tcp_sockets.connect(id, host, port)) - { - self->on_socket_gone(id); - } - }); + commit_callbacks_subsystem = std::move(fcss); } - private: - // ----- UDP / datagram helpers (loop thread) ----------------------------- + // ----- ErrorReporter ---------------------------------------------------- - void accept_udp( - ConnID id, - const ListenInterfaceID& listen_interface_id, - ListenInterface& li) + void report_parsing_error(const ccf::ListenInterfaceID& id) override { - // Caller holds `lock`. - LOG_DEBUG_FMT("New UDP endpoint {}", id); - - std::shared_ptr session; - if (li.app_protocol == "QUIC") + std::lock_guard guard(interfaces_mutex); + auto it = interfaces.find(id); + if (it != interfaces.end()) { - session = std::make_shared( - rpc_map, id, listen_interface_id, *this); + it->second->err_parsing++; } - else if (custom_protocol_subsystem) - { - // Custom protocol session is created lazily on the first inbound - // datagram (the creation function may not be registered yet). Store a - // nullptr placeholder so the interface mapping and caps are tracked. - session = nullptr; - } - else - { - throw std::runtime_error( - "unknown UDP protocol and custom protocol subsystem missing"); - } - - sessions.emplace( - id, std::make_pair(listen_interface_id, std::move(session))); - li.open_sessions++; - li.peak_sessions = std::max(li.peak_sessions, li.open_sessions); - sessions_peak = std::max(sessions_peak, sessions.size()); } - // Returns the session for `id`, lazily creating a custom-protocol datagram - // session on first inbound if one was deferred at accept time. Works for - // stream sessions too (which are always present, never deferred). - std::shared_ptr find_session_for_inbound(ConnID id) + void report_request_payload_too_large_error( + const ccf::ListenInterfaceID& id) override { - std::lock_guard guard(lock); - - auto search = sessions.find(id); - if (search == sessions.end()) - { - return nullptr; - } - - if (search->second.second != nullptr || !custom_protocol_subsystem) - { - return search->second.second; - } - - // Deferred custom-protocol datagram session: create it now. - const auto& interface_id = search->second.first; - auto iit = listening_interfaces.find(interface_id); - if (iit == listening_interfaces.end()) + std::lock_guard guard(interfaces_mutex); + auto it = interfaces.find(id); + if (it != interfaces.end()) { - LOG_DEBUG_FMT( - "Cannot create custom protocol session for {}: unknown interface {}", - id, - interface_id); - return nullptr; - } - - try - { - search->second.second = custom_protocol_subsystem->create_session( - iit->second.app_protocol, id, nullptr); - } - catch (const std::exception& ex) - { - LOG_DEBUG_FMT( - "Failure to create custom protocol session {}: {}", id, ex.what()); - return nullptr; + it->second->err_payload_too_large++; } + } - if (search->second.second == nullptr) + void report_request_header_too_large_error( + const ccf::ListenInterfaceID& id) override + { + std::lock_guard guard(interfaces_mutex); + auto it = interfaces.find(id); + if (it != interfaces.end()) { - LOG_DEBUG_FMT("Failure to create custom protocol session {}", id); + it->second->err_header_too_large++; } - return search->second.second; } }; } diff --git a/src/host/rpc_socket_set.h b/src/host/rpc_socket_set.h deleted file mode 100644 index 710e74aa535b..000000000000 --- a/src/host/rpc_socket_set.h +++ /dev/null @@ -1,263 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the Apache 2.0 License. -#pragma once - -#include "ccf/service/node_info_network.h" -#include "host/tcp.h" -#include "host/udp.h" - -#include -#include -#include - -namespace ccf -{ - template - constexpr bool socket_is_tcp() - { - return std::is_same_v; - } - - template - constexpr bool socket_is_udp() - { - return std::is_same_v; - } - - // Callbacks an RPCSocketSet needs from its owner (RPCConnectionManager). - // - // These all run on the libuv loop thread, invoked from socket behaviours. - class SocketSetHost - { - public: - using ConnID = ::tcp::ConnID; - - virtual ~SocketSetHost() = default; - - // Allocate the next positive (host-side) connection id. - virtual ConnID get_next_server_id() = 0; - - // A new connection endpoint is ready and should be given a session. For - // stream sockets this is a freshly accepted peer; for datagram sockets it - // is the listening socket itself. `udp` selects the datagram path. - virtual void on_socket_start( - ConnID id, const ListenInterfaceID& interface_id, bool udp) = 0; - - // Inbound bytes for connection `id`. `addr` identifies the source peer for - // datagram sockets and is unused for stream sockets. - virtual void on_socket_inbound( - ConnID id, const uint8_t* data, size_t len, sockaddr addr) = 0; - - // The socket for `id` failed or disconnected. - virtual void on_socket_gone(ConnID id) = 0; - }; - - // Owns the libuv sockets of a single transport type (TCP or UDP) and bridges - // their callbacks to a SocketSetHost. All methods must be called on the loop - // thread. This is composed (not inherited) into RPCConnectionManager so that - // a single manager can own both a TCP and a UDP set while keeping the - // transport-specific socket handling isolated here. - template - class RPCSocketSet - { - public: - using ConnID = ::tcp::ConnID; - - private: - SocketSetHost& host; - std::unordered_map sockets; - std::unordered_map listen_socket_interface; - - ListenInterfaceID interface_for(ConnID id) - { - const auto it = listen_socket_interface.find(id); - if (it == listen_socket_interface.end()) - { - throw std::logic_error( - fmt::format("No listening interface for socket {}", id)); - } - return it->second; - } - - // Behaviour for an accepted stream peer or an outbound client socket. - class PeerBehaviour : public asynchost::SocketBehaviour - { - public: - RPCSocketSet& set; - ConnID id; - - PeerBehaviour(RPCSocketSet& set_, ConnID id_) : - asynchost::SocketBehaviour("RPC", "TCP"), - set(set_), - id(id_) - {} - - bool on_read(size_t len, uint8_t*& data, sockaddr /*addr*/) override - { - set.host.on_socket_inbound(id, data, len, sockaddr{}); - return true; - } - - void on_disconnect() override - { - set.host.on_socket_gone(id); - } - - void on_connect_failed() override - { - set.host.on_socket_gone(id); - } - - void on_resolve_failed() override - { - set.host.on_socket_gone(id); - } - }; - - // Behaviour for the listening socket. For TCP it accepts peers; for UDP it - // is also the data-carrying socket, delivering reads with a source address. - class ListenBehaviour : public asynchost::SocketBehaviour - { - public: - RPCSocketSet& set; - ConnID id; - - ListenBehaviour(RPCSocketSet& set_, ConnID id_) : - asynchost::SocketBehaviour("RPC", "TCP"), - set(set_), - id(id_) - {} - - void on_accept(ConnType& peer) override - { - if constexpr (socket_is_tcp()) - { - const auto peer_id = set.host.get_next_server_id(); - peer->set_behaviour( - std::make_unique(set, peer_id)); - set.sockets.emplace(peer_id, peer); - set.host.on_socket_start(peer_id, set.interface_for(id), false); - } - } - - void on_start(int64_t /*peer_id*/) override - { - if constexpr (socket_is_udp()) - { - set.host.on_socket_start(id, set.interface_for(id), true); - } - } - - bool on_read(size_t len, uint8_t*& data, sockaddr addr) override - { - if constexpr (socket_is_udp()) - { - set.host.on_socket_inbound(id, data, len, addr); - } - return true; - } - }; - - public: - explicit RPCSocketSet(SocketSetHost& host_) : host(host_) {} - - bool listen( - ConnID id, - const std::string& addr_host, - const std::string& addr_port, - const ListenInterfaceID& name) - { - if (sockets.find(id) != sockets.end()) - { - LOG_FAIL_FMT("Cannot listen on id {}: already in use", id); - return false; - } - - ConnType s; - s->set_behaviour(std::make_unique(*this, id)); - - std::string h = addr_host; - std::string p = addr_port; - if (!s->listen(h, p, name)) - { - return false; - } - - sockets.emplace(id, s); - listen_socket_interface.emplace(id, name); - - // UDP has no accept step: the listening socket carries data, so start it - // immediately to trigger session creation. - if constexpr (socket_is_udp()) - { - s->start(id); - } - - return true; - } - - // Open an outbound stream connection (TCP only). - bool connect( - ConnID id, const std::string& addr_host, const std::string& addr_port) - { - if constexpr (socket_is_tcp()) - { - if (sockets.find(id) != sockets.end()) - { - LOG_FAIL_FMT("Cannot connect on id {}: already in use", id); - return false; - } - - auto s = ConnType(true); - s->set_behaviour(std::make_unique(*this, id)); - if (!s->connect(addr_host, addr_port)) - { - return false; - } - sockets.emplace(id, s); - return true; - } - else - { - (void)id; - (void)addr_host; - (void)addr_port; - return false; - } - } - - bool write(ConnID id, const std::vector& data, sockaddr addr) - { - auto it = sockets.find(id); - if (it == sockets.end() || it->second.is_null()) - { - return false; - } - return it->second->write(data.size(), data.data(), addr); - } - - // Invalidate the socket: the uv handle is closed, no further reads or - // writes occur, but the entry is retained until close(). - bool stop(ConnID id) - { - auto it = sockets.find(id); - if (it == sockets.end()) - { - return false; - } - it->second = nullptr; - return true; - } - - bool close(ConnID id) - { - listen_socket_interface.erase(id); - return sockets.erase(id) > 0; - } - - bool has(ConnID id) const - { - return sockets.find(id) != sockets.end(); - } - }; -} diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp index 8a2d7fb3ff5a..2f9f2e56675c 100644 --- a/src/host/test/openssl_server_test.cpp +++ b/src/host/test/openssl_server_test.cpp @@ -279,7 +279,7 @@ TEST_CASE("Session bridge: round-trip via ccf::Session + SessionWriter") key, "127.0.0.1", static_cast(0), - [](::tcp::ConnID id, ccf::SessionWriter& w) { + [](::tcp::ConnID id, ccf::SessionWriter& w, std::vector) { return std::make_shared(id, w); }); mgr.start(); @@ -299,7 +299,7 @@ TEST_CASE("Session bridge: large transfer through the seam") key, "127.0.0.1", static_cast(0), - [](::tcp::ConnID id, ccf::SessionWriter& w) { + [](::tcp::ConnID id, ccf::SessionWriter& w, std::vector) { return std::make_shared(id, w); }); mgr.start(); diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index c0d9acacaf5f..c53e6dfd840c 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -32,10 +32,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -44,6 +46,7 @@ #include #include #include +#include #include namespace asynchost @@ -76,6 +79,8 @@ namespace asynchost Handshaking, Ready } state = Handshaking; + // Outbound (client) connection: drives SSL_connect rather than SSL_accept. + bool is_client = false; // Pending plaintext to be encrypted/written; out_off bytes already sent. std::vector outbuf; size_t out_off = 0; @@ -85,6 +90,13 @@ namespace asynchost }; SSL_CTX* ctx = nullptr; + // Lazily created client context for outbound connections. + SSL_CTX* client_ctx = nullptr; + // Plaintext (UNSECURED) interface: no TLS, raw socket I/O. + bool plaintext = false; + // ALPN protocol advertised by the server (wire format, length-prefixed), + // e.g. "\x02h2" or "\x08http/1.1". Empty disables ALPN. + std::string alpn_wire; int listen_fd = -1; int epoll_fd = -1; int stop_fd = -1; @@ -97,6 +109,10 @@ namespace asynchost std::unordered_map> conns; std::unordered_map id_to_fd; uint64_t next_id = 1; + // Optional shared id source so multiple servers (one per interface) allocate + // connection ids from a single global space - required for a global session + // registry and reply routing. + std::atomic* shared_next_id = nullptr; // Cross-thread outbound queue: send()/close_connection() append here from // any thread and wake the loop, which drains it on the epoll thread. @@ -109,6 +125,19 @@ namespace asynchost std::mutex out_mutex; std::vector pending_out; + // Cross-thread outbound connect requests (for client sessions). + struct ConnectReq + { + int64_t id = 0; + std::string host; + std::string port; + }; + std::vector pending_connects; + + // Cross-thread server-cert (re)load requests (deferred cert / rotation), + // applied on the loop thread so `ctx` is only ever touched there. + std::vector> pending_certs; + std::thread loop_thread; std::atomic running{false}; @@ -181,6 +210,29 @@ namespace asynchost return SSL_CTX_check_private_key(ctx) == 1; } + // Build a server SSL_CTX (min TLS 1.2, ALPN if configured) and load the + // cert/key. Returns nullptr on failure. Called on the loop thread. + SSL_CTX* build_server_ctx( + const std::string& cert_pem, const std::string& key_pem) + { + SSL_CTX* c = SSL_CTX_new(TLS_server_method()); + if (c == nullptr) + { + return nullptr; + } + SSL_CTX_set_min_proto_version(c, TLS1_2_VERSION); + if (!alpn_wire.empty()) + { + SSL_CTX_set_alpn_select_cb(c, alpn_select_cb, this); + } + if (!load_cert_key(c, cert_pem, key_pem)) + { + SSL_CTX_free(c); + return nullptr; + } + return c; + } + void update_interest(Conn& c) const { epoll_event ev{}; @@ -189,10 +241,40 @@ namespace asynchost epoll_ctl(epoll_fd, EPOLL_CTL_MOD, c.fd, &ev); } + static int alpn_select_cb( + SSL* /*ssl*/, + const unsigned char** out, + unsigned char* outlen, + const unsigned char* in, + unsigned int inlen, + void* arg) + { + auto* self = static_cast(arg); + const auto& wire = self->alpn_wire; + if (wire.empty()) + { + return SSL_TLSEXT_ERR_NOACK; + } + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + const auto* protos = reinterpret_cast(wire.data()); + if ( + SSL_select_next_proto( + const_cast(out), + outlen, + protos, + static_cast(wire.size()), + in, + inlen) != OPENSSL_NPN_NEGOTIATED) + { + return SSL_TLSEXT_ERR_NOACK; + } + return SSL_TLSEXT_ERR_OK; + } + // Returns false if the connection should be closed. bool do_handshake(Conn& c) { - const int r = SSL_accept(c.ssl); + const int r = c.is_client ? SSL_connect(c.ssl) : SSL_accept(c.ssl); if (r == 1) { c.state = Conn::Ready; @@ -216,9 +298,77 @@ namespace asynchost return false; } + // Returns false if the connection should be closed. + bool do_read_plaintext(Conn& c) + { + for (;;) + { + uint8_t buf[read_chunk]; + const ssize_t n = ::recv(c.fd, buf, sizeof(buf), 0); + if (n > 0) + { + if (on_data) + { + on_data( + c.id, std::vector(buf, buf + static_cast(n))); + } + continue; + } + if (n == 0) + { + return false; // peer closed + } + if (errno == EAGAIN || errno == EWOULDBLOCK) + { + return true; + } + if (errno == EINTR) + { + continue; + } + return false; + } + } + + // Returns false if the connection should be closed. + bool do_write_plaintext(Conn& c) + { + while (c.out_off < c.outbuf.size()) + { + const ssize_t n = ::send( + c.fd, + c.outbuf.data() + c.out_off, + c.outbuf.size() - c.out_off, + MSG_NOSIGNAL); + if (n > 0) + { + c.out_off += static_cast(n); + continue; + } + if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) + { + c.want_write = true; + return true; + } + if (n < 0 && errno == EINTR) + { + continue; + } + return false; + } + c.outbuf.clear(); + c.out_off = 0; + c.want_write = false; + return true; + } + // Returns false if the connection should be closed. bool do_read(Conn& c) { + if (c.ssl == nullptr) + { + return do_read_plaintext(c); + } for (;;) { uint8_t buf[read_chunk]; @@ -253,6 +403,10 @@ namespace asynchost // a WANT_WRITE leaves the remaining plaintext buffered and arms EPOLLOUT. bool do_write(Conn& c) { + if (c.ssl == nullptr) + { + return do_write_plaintext(c); + } while (c.out_off < c.outbuf.size()) { const int n = SSL_write( @@ -335,34 +489,53 @@ namespace asynchost break; } - SSL* ssl = SSL_new(ctx); - if (ssl == nullptr) + auto c = std::make_unique(); + c->fd = cfd; + c->id = + (shared_next_id != nullptr) ? shared_next_id->fetch_add(1) : next_id++; + + if (plaintext) { - ::close(cfd); - continue; + // No TLS: ready to read/write raw bytes immediately. + c->state = Conn::Ready; } - SSL_set_mode( - ssl, - SSL_MODE_ENABLE_PARTIAL_WRITE | SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); - if (SSL_set_fd(ssl, cfd) != 1) + else { - SSL_free(ssl); - ::close(cfd); - continue; + if (ctx == nullptr) + { + // No server certificate yet - refuse (mirrors the old "Session + // refused until cert present" behaviour). + ::close(cfd); + continue; + } + SSL* ssl = SSL_new(ctx); + if (ssl == nullptr) + { + ::close(cfd); + continue; + } + SSL_set_mode( + ssl, + SSL_MODE_ENABLE_PARTIAL_WRITE | SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); + if (SSL_set_fd(ssl, cfd) != 1) + { + SSL_free(ssl); + ::close(cfd); + continue; + } + SSL_set_accept_state(ssl); + c->ssl = ssl; } - SSL_set_accept_state(ssl); - - auto c = std::make_unique(); - c->fd = cfd; - c->ssl = ssl; - c->id = next_id++; epoll_event ev{}; ev.data.fd = cfd; ev.events = EPOLLIN; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, cfd, &ev) != 0) { - SSL_free(ssl); + if (c->ssl != nullptr) + { + SSL_free(c->ssl); + } ::close(cfd); continue; } @@ -427,9 +600,33 @@ namespace asynchost } std::vector items; + std::vector connects; + std::vector> certs; { std::lock_guard g(out_mutex); std::swap(items, pending_out); + std::swap(connects, pending_connects); + std::swap(certs, pending_certs); + } + + for (auto& [cert_pem, key_pem] : certs) + { + SSL_CTX* nc = build_server_ctx(cert_pem, key_pem); + if (nc == nullptr) + { + logf("set_server_cert: build context failed"); + continue; + } + if (ctx != nullptr) + { + SSL_CTX_free(ctx); + } + ctx = nc; + } + + for (auto& req : connects) + { + do_connect(req.id, req.host, req.port); } for (auto& item : items) @@ -461,6 +658,113 @@ namespace asynchost } } + // Open an outbound client connection for `id` (loop thread). TLS client + // handshake is driven by the normal epoll state machine (is_client). + void do_connect(int64_t id, const std::string& host, const std::string& port) + { + if (client_ctx == nullptr) + { + client_ctx = SSL_CTX_new(TLS_client_method()); + if (client_ctx == nullptr) + { + logf("client SSL_CTX_new failed"); + if (on_close) + { + on_close(static_cast(id)); + } + return; + } + SSL_CTX_set_min_proto_version(client_ctx, TLS1_2_VERSION); + // TODO: wire CA verification for outbound (create_client cert) before + // production; currently the peer is not verified here. + SSL_CTX_set_verify(client_ctx, SSL_VERIFY_NONE, nullptr); + } + + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + addrinfo* res = nullptr; + if (getaddrinfo(host.c_str(), port.c_str(), &hints, &res) != 0) + { + logf("getaddrinfo(%s:%s) failed", host.c_str(), port.c_str()); + if (on_close) + { + on_close(static_cast(id)); + } + return; + } + + const int cfd = + socket(res->ai_family, SOCK_STREAM | SOCK_NONBLOCK, res->ai_protocol); + if (cfd < 0) + { + freeaddrinfo(res); + if (on_close) + { + on_close(static_cast(id)); + } + return; + } + const int rc = ::connect(cfd, res->ai_addr, res->ai_addrlen); + freeaddrinfo(res); + if (rc != 0 && errno != EINPROGRESS) + { + ::close(cfd); + if (on_close) + { + on_close(static_cast(id)); + } + return; + } + + SSL* ssl = SSL_new(client_ctx); + if (ssl == nullptr) + { + ::close(cfd); + if (on_close) + { + on_close(static_cast(id)); + } + return; + } + SSL_set_mode( + ssl, + SSL_MODE_ENABLE_PARTIAL_WRITE | SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); + SSL_set_connect_state(ssl); + if (SSL_set_fd(ssl, cfd) != 1) + { + SSL_free(ssl); + ::close(cfd); + if (on_close) + { + on_close(static_cast(id)); + } + return; + } + + auto c = std::make_unique(); + c->fd = cfd; + c->ssl = ssl; + c->id = static_cast(id); + c->is_client = true; + + epoll_event ev{}; + ev.data.fd = cfd; + ev.events = EPOLLIN | EPOLLOUT; + if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, cfd, &ev) != 0) + { + SSL_free(ssl); + ::close(cfd); + if (on_close) + { + on_close(static_cast(id)); + } + return; + } + conns.emplace(cfd, std::move(c)); + id_to_fd.emplace(id, cfd); + } + void run() { constexpr int max_events = 64; @@ -516,22 +820,32 @@ namespace asynchost uint16_t port, OnData on_data_, OnClose on_close_ = {}, - bool verbose_ = false) : + const std::string& alpn = "", + bool plaintext_ = false, + bool verbose_ = false, + std::atomic* shared_next_id_ = nullptr) : + plaintext(plaintext_), on_data(std::move(on_data_)), on_close(std::move(on_close_)), - verbose(verbose_) + verbose(verbose_), + shared_next_id(shared_next_id_) { - ctx = SSL_CTX_new(TLS_server_method()); - if (ctx == nullptr) + if (!alpn.empty()) { - throw std::runtime_error("SSL_CTX_new failed"); + alpn_wire.push_back(static_cast(alpn.size())); + alpn_wire.append(alpn); } - SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION); - if (!load_cert_key(ctx, cert_pem, key_pem)) + + // Plaintext interfaces have no TLS context. TLS interfaces build their + // context now if the cert is already available, or defer until + // set_server_cert() (e.g. a joining node receiving the service cert). + if (!plaintext && !cert_pem.empty()) { - SSL_CTX_free(ctx); - ctx = nullptr; - throw std::runtime_error("Failed to load server cert/key"); + ctx = build_server_ctx(cert_pem, key_pem); + if (ctx == nullptr) + { + throw std::runtime_error("Failed to load server cert/key"); + } } listen_fd = socket(AF_INET, SOCK_STREAM, 0); @@ -675,6 +989,61 @@ namespace asynchost wake(); } + // Thread-safe. Open an outbound client (TLS) connection bound to `id`. + void connect( + int64_t id, const std::string& host, const std::string& port) + { + { + std::lock_guard g(out_mutex); + pending_connects.push_back({id, host, port}); + } + wake(); + } + + // Thread-safe. (Re)load the server certificate/key. Used for deferred cert + // (a node that learns the service cert after binding) and rotation; applies + // to connections accepted after it takes effect on the loop thread. + void set_server_cert( + const std::string& cert_pem, const std::string& key_pem) + { + { + std::lock_guard g(out_mutex); + pending_certs.emplace_back(cert_pem, key_pem); + } + wake(); + } + + // Peer certificate (DER) for `conn_id`, or empty. MUST be called on the + // loop thread (e.g. synchronously from within the OnData callback). + std::vector get_peer_cert(uint64_t conn_id) + { + auto fit = id_to_fd.find(conn_id); + if (fit == id_to_fd.end()) + { + return {}; + } + auto cit = conns.find(fit->second); + if (cit == conns.end() || cit->second->ssl == nullptr) + { + return {}; + } + X509* cert = SSL_get_peer_certificate(cit->second->ssl); + if (cert == nullptr) + { + return {}; + } + std::vector der; + const int len = i2d_X509(cert, nullptr); + if (len > 0) + { + der.resize(static_cast(len)); + unsigned char* p = der.data(); + i2d_X509(cert, &p); + } + X509_free(cert); + return der; + } + private: void cleanup() { @@ -703,6 +1072,11 @@ namespace asynchost SSL_CTX_free(ctx); ctx = nullptr; } + if (client_ctx != nullptr) + { + SSL_CTX_free(client_ctx); + client_ctx = nullptr; + } } }; } diff --git a/src/host/tls/openssl_session_manager.h b/src/host/tls/openssl_session_manager.h index 8ce1e86a121e..3f92b7acfc04 100644 --- a/src/host/tls/openssl_session_manager.h +++ b/src/host/tls/openssl_session_manager.h @@ -25,6 +25,7 @@ #include "enclave/session_writer.h" #include "host/tls/openssl_server.h" +#include #include #include #include @@ -39,12 +40,19 @@ namespace asynchost public: // Creates the protocol session for a freshly seen connection. `writer` is // this manager - the session emits its (plaintext) output through it. + // `peer_cert` is the client certificate (DER) captured at handshake, for + // caller authentication. using SessionFactory = std::function( - ::tcp::ConnID conn_id, ccf::SessionWriter& writer)>; + ::tcp::ConnID conn_id, + ccf::SessionWriter& writer, + std::vector peer_cert)>; private: std::unique_ptr server; SessionFactory factory; + // Invoked (on the loop thread) when a connection's session is dropped, so an + // owner can update per-interface counters/metrics. + std::function on_session_closed; std::mutex sessions_mutex; std::unordered_map<::tcp::ConnID, std::shared_ptr> sessions; @@ -58,7 +66,18 @@ namespace asynchost auto it = sessions.find(conn_id); if (it == sessions.end()) { - session = factory(conn_id, *this); + // Lazily create the session for a newly accepted connection. The + // peer certificate is fetched here (on the loop thread) from the + // handshaken connection. + auto peer_cert = server->get_peer_cert(id); + session = factory(conn_id, *this, std::move(peer_cert)); + if (session == nullptr) + { + // Factory refused (e.g. hard session cap) - tear the connection + // down. + server->close_connection(id); + return; + } sessions.emplace(conn_id, session); } else @@ -76,8 +95,15 @@ namespace asynchost void on_close(uint64_t id) { const auto conn_id = static_cast<::tcp::ConnID>(id); - std::lock_guard guard(sessions_mutex); - sessions.erase(conn_id); + bool had_session = false; + { + std::lock_guard guard(sessions_mutex); + had_session = sessions.erase(conn_id) > 0; + } + if (had_session && on_session_closed) + { + on_session_closed(conn_id); + } } public: @@ -87,8 +113,13 @@ namespace asynchost const std::string& host, uint16_t port, SessionFactory factory_, - bool verbose = false) : - factory(std::move(factory_)) + const std::string& alpn = "", + bool plaintext = false, + bool verbose = false, + std::atomic* shared_next_id = nullptr, + std::function on_session_closed_ = {}) : + factory(std::move(factory_)), + on_session_closed(std::move(on_session_closed_)) { server = std::make_unique( cert_pem, @@ -99,7 +130,41 @@ namespace asynchost on_data(id, std::move(data)); }, [this](uint64_t id) { on_close(id); }, - verbose); + alpn, + plaintext, + verbose, + shared_next_id); + } + + // The session for `id`, or nullptr. Thread-safe. + std::shared_ptr get_session(::tcp::ConnID id) + { + std::lock_guard guard(sessions_mutex); + auto it = sessions.find(id); + return it == sessions.end() ? nullptr : it->second; + } + + // (Re)load this interface's server certificate (deferred cert / rotation). + void set_server_cert( + const std::string& cert_pem, const std::string& key_pem) + { + server->set_server_cert(cert_pem, key_pem); + } + + // Register a pre-built session (used for outbound client sessions, whose + // session is created before the connection is opened). + void register_session( + ::tcp::ConnID id, std::shared_ptr session) + { + std::lock_guard guard(sessions_mutex); + sessions.emplace(id, std::move(session)); + } + + // Open an outbound client connection bound to `id` (thread-safe). + void connect( + ::tcp::ConnID id, const std::string& host, const std::string& service) + { + server->connect(static_cast(id), host, service); } void start() diff --git a/src/http/http2_session.h b/src/http/http2_session.h index bffa330d4bfb..fdc3cc74ef44 100644 --- a/src/http/http2_session.h +++ b/src/http/http2_session.h @@ -12,7 +12,7 @@ namespace http { - using HTTP2Session = ccf::EncryptedSession; + using HTTP2Session = ccf::PlaintextSession; struct HTTP2SessionContext : public ccf::SessionContext { @@ -200,7 +200,7 @@ namespace http it, stream_id, std::make_shared( - session_id, tls_io->peer_cert(), interface_id, stream_id)); + session_id, peer_cert(), interface_id, stream_id)); } return it->second; @@ -241,11 +241,11 @@ namespace http std::shared_ptr rpc_map_, int64_t session_id_, ccf::ListenInterfaceID interface_id_, - ringbuffer::AbstractWriterFactory& writer_factory, - std::unique_ptr ctx, + ccf::SessionWriter& writer, + std::vector peer_cert, const ccf::http::ParserConfiguration& configuration, const std::shared_ptr& error_reporter_) : - HTTP2Session(session_id_, writer_factory, std::move(ctx)), + HTTP2Session(session_id_, writer, std::move(peer_cert)), server_parser( std::make_shared(*this, configuration)), rpc_map(std::move(rpc_map_)), @@ -433,4 +433,72 @@ 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_, + ccf::SessionWriter& writer, + ccf::ClientSession::ConnectCallback connect_cb) : + HTTP2Session(session_id_, writer), + ccf::ClientSession(session_id_, std::move(connect_cb)), + client_parser(*this) + { + client_parser.set_outgoing_data_handler( + [this](std::span data) { + send_data(std::vector(data.begin(), data.end())); + }); + } + + bool parse(std::span 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(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&& body) override + { + handle_data_cb(status, std::move(headers), std::move(body)); + + LOG_TRACE_FMT("Closing connection, message handled"); + close_session(); + } + }; } diff --git a/src/http/http_session.h b/src/http/http_session.h index 0b22cd89096f..f38c2c82da7a 100644 --- a/src/http/http_session.h +++ b/src/http/http_session.h @@ -12,7 +12,7 @@ namespace http { - using HTTPSession = ccf::EncryptedSession; + using HTTPSession = ccf::PlaintextSession; class HTTPServerSession : public HTTPSession, public http::RequestProcessor, @@ -33,12 +33,12 @@ namespace http std::shared_ptr rpc_map_, ::tcp::ConnID session_id_, ccf::ListenInterfaceID interface_id_, - ringbuffer::AbstractWriterFactory& writer_factory, - std::unique_ptr ctx, + ccf::SessionWriter& writer, + std::vector peer_cert, const ccf::http::ParserConfiguration& configuration, const std::shared_ptr& error_reporter_, const std::shared_ptr& commit_callbacks_) : - HTTPSession(session_id_, writer_factory, std::move(ctx)), + HTTPSession(session_id_, writer, std::move(peer_cert)), request_parser(*this, configuration), rpc_map(std::move(rpc_map_)), error_reporter(error_reporter_), @@ -139,7 +139,7 @@ namespace http if (session_ctx == nullptr) { session_ctx = std::make_shared( - session_id, tls_io->peer_cert(), interface_id); + session_id, peer_cert(), interface_id); } std::shared_ptr rpc_ctx = nullptr; @@ -302,4 +302,130 @@ namespace http std::move(body)); } }; + + class HTTPClientSession : public HTTPSession, + public ccf::ClientSession, + public ::http::ResponseProcessor + { + private: + ::http::ResponseParser response_parser; + + public: + HTTPClientSession( + ::tcp::ConnID session_id_, + ccf::SessionWriter& writer, + ccf::ClientSession::ConnectCallback connect_cb) : + HTTPSession(session_id_, writer), + ClientSession(session_id_, std::move(connect_cb)), + response_parser(*this) + {} + + bool parse(std::span data) override + { + // Catch response parsing errors and log them + try + { + response_parser.execute(data.data(), data.size()); + + return true; + } + catch (const std::exception& e) + { + LOG_FAIL_FMT("Error parsing HTTP response on session {}", session_id); + LOG_DEBUG_FMT("Error parsing HTTP response: {}", e.what()); + LOG_DEBUG_FMT( + "Error occurred while parsing fragment {} byte fragment:\n{}", + data.size(), + std::string_view( + reinterpret_cast(data.data()), data.size())); + + close_session(); + } + return false; + } + + void send_request(http::Request&& request) override + { + auto data = request.build_request(); + send_data(std::move(data)); + } + + void handle_response( + ccf::http_status status, + ccf::http::HeaderMap&& headers, + std::vector&& body) override + { + handle_data_cb(status, std::move(headers), std::move(body)); + + LOG_TRACE_FMT("Closing connection, message handled"); + close_session(); + } + }; + + using UnencryptedHTTPSession = ccf::PlaintextSession; + + class UnencryptedHTTPClientSession : public UnencryptedHTTPSession, + public ccf::ClientSession, + public ::http::ResponseProcessor + { + private: + ::http::ResponseParser response_parser; + + public: + UnencryptedHTTPClientSession( + ::tcp::ConnID session_id_, + ccf::SessionWriter& writer, + ccf::ClientSession::ConnectCallback connect_cb) : + UnencryptedHTTPSession(session_id_, writer), + ClientSession(session_id_, std::move(connect_cb)), + response_parser(*this) + {} + + bool parse(std::span data) override + { + try + { + response_parser.execute(data.data(), data.size()); + return true; + } + catch (const std::exception& e) + { + LOG_FAIL_FMT("Error parsing HTTP response on session {}", session_id); + LOG_DEBUG_FMT("Error parsing HTTP response: {}", e.what()); + LOG_DEBUG_FMT( + "Error occurred while parsing fragment {} byte fragment:\n{}", + data.size(), + std::string_view( + reinterpret_cast(data.data()), data.size())); + + close_session(); + } + return false; + } + + void send_request(http::Request&& request) override + { + auto data = request.build_request(); + send_data(std::move(data)); + } + + void connect( + const std::string& hostname, + const std::string& service, + const HandleDataCallback f, + const HandleErrorCallback e) override + { + ccf::ClientSession::connect(hostname, service, f, e); + } + + void handle_response( + ccf::http_status status, + ccf::http::HeaderMap&& headers, + std::vector&& body) override + { + handle_data_cb(status, std::move(headers), std::move(body)); + LOG_TRACE_FMT("Closing connection, message handled"); + close_session(); + } + }; } From aea2b2737ceb023f1e5a35629eb597bc77d7b916 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Wed, 24 Jun 2026 15:16:57 +0000 Subject: [PATCH 04/59] OpenSSL-native RPC cutover: relocate RPC listening into enclave lib, wire RPCConnectionManager into enclave.h/run.cpp, delete RPCSessions/rpc_connections/tls_session. Full build green, 53/53 unit tests pass. --- src/enclave/enclave.h | 48 ++- src/enclave/entry_points.h | 1 + src/enclave/main.cpp | 3 +- src/enclave/rpc_sessions.h | 637 ---------------------------------- src/enclave/tls_session.h | 671 ------------------------------------ src/host/rpc_connections.h | 524 ---------------------------- src/host/run.cpp | 117 +------ src/http/http_parser.h | 2 +- src/http/http_proc.h | 2 +- src/node/http_node_client.h | 1 + src/node/node_state.h | 4 +- 11 files changed, 63 insertions(+), 1947 deletions(-) delete mode 100644 src/enclave/rpc_sessions.h delete mode 100644 src/enclave/tls_session.h delete mode 100644 src/host/rpc_connections.h diff --git a/src/enclave/enclave.h b/src/enclave/enclave.h index 5e864b7f9e0e..ce9936558a4f 100644 --- a/src/enclave/enclave.h +++ b/src/enclave/enclave.h @@ -33,8 +33,8 @@ #include "node/rpc/node_operation.h" #include "node/rpc/user_frontend.h" #include "node/signature_cache_subsystem.h" +#include "host/rpc_connection_manager.h" #include "rpc_map.h" -#include "rpc_sessions.h" #include "tasks/worker.h" namespace ccf @@ -48,7 +48,7 @@ namespace ccf ccf::ds::WorkBeaconPtr work_beacon; ccf::NetworkState network; std::shared_ptr rpc_map; - std::shared_ptr rpcsessions; + std::shared_ptr rpcsessions; std::unique_ptr node; ringbuffer::WriterPtr to_host = nullptr; std::chrono::high_resolution_clock::time_point last_tick_time; @@ -92,7 +92,7 @@ namespace ccf writer_factory(std::move(writer_factory_)), work_beacon(std::move(work_beacon_)), rpc_map(std::make_shared()), - rpcsessions(std::make_shared(*writer_factory, rpc_map)) + rpcsessions(std::make_shared(rpc_map)) { to_host = writer_factory->create_writer_to_outside(); @@ -193,14 +193,51 @@ namespace ccf CreateNodeStatus create_new_node( StartType start_type_, - const ccf::StartupConfig& ccf_config_, + ccf::StartupConfig ccf_config_, std::vector& node_cert, - std::vector& service_cert) + std::vector& service_cert, + std::vector& rpc_addresses) { start_type = start_type_; rpcsessions->update_listening_interface_options(ccf_config_.network); + // Bind and start listening on each configured RPC interface. TLS is now + // terminated in the connection: an interface whose certificate is not yet + // available refuses connections until set_*_cert provides one (a joining + // node receives the service cert later). Ephemeral ports (bind ":0") are + // assigned here, so the resolved addresses are reported back to the host + // (which writes the rpc addresses file). + { + nlohmann::json resolved_rpc_addresses; + for (auto& [name, interface] : ccf_config_.network.rpc_interfaces) + { + const auto [host, port] = + ccf::split_net_address(interface.bind_address); + const uint16_t bound = rpcsessions->listen(name, host, port); + interface.bind_address = + ccf::make_net_address(host, std::to_string(bound)); + + if (interface.published_address.empty()) + { + interface.published_address = interface.bind_address; + } + else + { + const auto [phost, pport] = + ccf::split_net_address(interface.published_address); + if (pport == "0") + { + interface.published_address = + ccf::make_net_address(phost, std::to_string(bound)); + } + } + resolved_rpc_addresses[name] = interface.bind_address; + } + const auto dumped = resolved_rpc_addresses.dump(); + rpc_addresses.assign(dumped.begin(), dumped.end()); + } + node->set_n2n_message_limit(ccf_config_.node_to_node_message_limit); historical_state_cache->set_soft_cache_limit( @@ -392,7 +429,6 @@ namespace ccf } }); - rpcsessions->register_message_handlers(bp.get_dispatcher()); // Maximum number of inbound ringbuffer messages which will be // processed in a single iteration diff --git a/src/enclave/entry_points.h b/src/enclave/entry_points.h index 8dc35be490d7..9fcca727ab02 100644 --- a/src/enclave/entry_points.h +++ b/src/enclave/entry_points.h @@ -15,6 +15,7 @@ namespace ccf const ccf::StartupConfig& ccf_config, std::vector& node_cert, std::vector& service_cert, + std::vector& rpc_addresses, StartType start_type, ccf::LoggerLevel log_level, size_t num_worker_thread, diff --git a/src/enclave/main.cpp b/src/enclave/main.cpp index fe1b11530989..cc556daff90e 100644 --- a/src/enclave/main.cpp +++ b/src/enclave/main.cpp @@ -30,6 +30,7 @@ namespace ccf const ccf::StartupConfig& ccf_config, std::vector& node_cert, std::vector& service_cert, + std::vector& rpc_addresses, StartType start_type, ccf::LoggerLevel log_level, size_t num_worker_threads, @@ -132,7 +133,7 @@ namespace ccf try { status = enclave->create_new_node( - start_type, ccf_config, node_cert, service_cert); + start_type, ccf_config, node_cert, service_cert, rpc_addresses); } catch (...) { diff --git a/src/enclave/rpc_sessions.h b/src/enclave/rpc_sessions.h deleted file mode 100644 index 2c47aacf8210..000000000000 --- a/src/enclave/rpc_sessions.h +++ /dev/null @@ -1,637 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the Apache 2.0 License. -#pragma once - -#include "ccf/pal/locking.h" -#include "ccf/service/node_info_network.h" -#include "ds/internal_logger.h" -#include "ds/serialized.h" -#include "enclave/abstract_rpc_sessions.h" -#include "enclave/session.h" -#include "forwarder_types.h" -#include "http/http2_session.h" -#include "http/http_responder.h" -#include "http/http_session.h" -#include "node/rpc/custom_protocol_subsystem.h" -#include "node/session_metrics.h" -#include "rpc_handler.h" -#include "tls/cert.h" -#include "tls/context.h" -#include "tls/plaintext_server.h" -#include "tls/server.h" -#include "udp/msg_types.h" - -// NB: This should be HTTP3 including QUIC, but this is -// ok for now, as we only have an echo service for now -#include "quic/quic_session.h" - -#include -#include -#include -#include - -namespace ccf -{ - using QUICSessionImpl = quic::QUICEchoSession; - - static constexpr size_t max_open_sessions_soft_default = 1000; - static constexpr size_t max_open_sessions_hard_default = 1010; - static const ccf::Endorsement endorsement_default = {ccf::Authority::SERVICE}; - - class RPCSessions : public std::enable_shared_from_this, - public AbstractRPCSessions, - public ::http::ErrorReporter - { - private: - struct ListenInterface - { - size_t open_sessions = 0; - size_t peak_sessions = 0; - size_t max_open_sessions_soft = 0; - size_t max_open_sessions_hard = 0; - ccf::Endorsement endorsement{}; - http::ParserConfiguration http_configuration; - ccf::SessionMetrics::Errors errors{}; - ccf::ApplicationProtocol app_protocol; - }; - std::map listening_interfaces; - - ringbuffer::AbstractWriterFactory& writer_factory; - ringbuffer::WriterPtr to_host = nullptr; - std::shared_ptr rpc_map; - std::unordered_map> certs; - std::shared_ptr custom_protocol_subsystem = - nullptr; - std::shared_ptr commit_callbacks_subsystem = - nullptr; - - ccf::pal::Mutex lock; - std::unordered_map< - ccf::tls::ConnID, - std::pair>> - sessions; - size_t sessions_peak = 0; - - template - class NoMoreSessionsImpl : public Base - { - public: - template - NoMoreSessionsImpl(Ts&&... ts) : Base(std::forward(ts)...) - {} - - void handle_incoming_data_thread(std::vector&& data) override - { - Base::tls_io->recv_buffered(data.data(), data.size()); - - if (Base::tls_io->get_status() == ccf::SessionStatus::ready) - { - // Send response describing soft session limit - Base::send_odata_error_response(ccf::ErrorDetails{ - HTTP_STATUS_SERVICE_UNAVAILABLE, - ccf::errors::SessionCapExhausted, - "Service is currently busy and unable to serve new connections"}); - - // Close connection - Base::tls_io->close(); - } - } - }; - - ListenInterface& get_interface_from_interface_id( - const ccf::ListenInterfaceID& id) - { - auto it = listening_interfaces.find(id); - if (it != listening_interfaces.end()) - { - return it->second; - } - - throw std::logic_error( - fmt::format("No RPC interface for interface ID {}", id)); - } - - std::shared_ptr make_server_session( - const std::string& app_protocol, - ccf::tls::ConnID id, - const ListenInterfaceID& listen_interface_id, - std::unique_ptr&& ctx, - const http::ParserConfiguration& parser_configuration) - { - if (app_protocol == "HTTP2") - { - return std::make_shared<::http::HTTP2ServerSession>( - rpc_map, - id, - listen_interface_id, - writer_factory, - std::move(ctx), - parser_configuration, - shared_from_this()); - } - if (app_protocol == "HTTP1") - { - return std::make_shared<::http::HTTPServerSession>( - rpc_map, - id, - listen_interface_id, - writer_factory, - std::move(ctx), - parser_configuration, - shared_from_this(), - commit_callbacks_subsystem); - } - if (custom_protocol_subsystem) - { - return custom_protocol_subsystem->create_session( - app_protocol, id, std::move(ctx)); - } - - throw std::runtime_error(fmt::format( - "unknown protocol '{}' and custom protocol subsystem missing", - app_protocol)); - } - - public: - RPCSessions( - ringbuffer::AbstractWriterFactory& writer_factory, - std::shared_ptr rpc_map_) : - writer_factory(writer_factory), - rpc_map(std::move(rpc_map_)) - { - to_host = writer_factory.create_writer_to_outside(); - } - - void set_custom_protocol_subsystem( - std::shared_ptr cpss) override - { - custom_protocol_subsystem = cpss; - } - - void set_commit_callbacks_subsystem( - std::shared_ptr fcss) override - { - commit_callbacks_subsystem = fcss; - } - - void report_parsing_error(const ccf::ListenInterfaceID& id) override - { - std::lock_guard guard(lock); - get_interface_from_interface_id(id).errors.parsing++; - } - - void report_request_payload_too_large_error( - const ccf::ListenInterfaceID& id) override - { - std::lock_guard guard(lock); - get_interface_from_interface_id(id).errors.request_payload_too_large++; - } - - void report_request_header_too_large_error( - const ccf::ListenInterfaceID& id) override - { - std::lock_guard guard(lock); - get_interface_from_interface_id(id).errors.request_header_too_large++; - } - - void update_listening_interface_options( - const ccf::NodeInfoNetwork& node_info) override - { - std::lock_guard guard(lock); - - for (const auto& [name, interface] : node_info.rpc_interfaces) - { - auto& li = listening_interfaces[name]; - - li.max_open_sessions_soft = interface.max_open_sessions_soft.value_or( - max_open_sessions_soft_default); - - li.max_open_sessions_hard = interface.max_open_sessions_hard.value_or( - max_open_sessions_hard_default); - - li.endorsement = interface.endorsement.value_or(endorsement_default); - - li.http_configuration = - interface.http_configuration.value_or(http::ParserConfiguration{}); - - li.app_protocol = interface.app_protocol.value_or("HTTP1"); - - LOG_INFO_FMT( - "Setting max open sessions on interface \"{}\" ({}) to [{}, " - "{}] and endorsement authority to {}", - name, - interface.bind_address, - li.max_open_sessions_soft, - li.max_open_sessions_hard, - li.endorsement.authority); - } - } - - ccf::SessionMetrics get_session_metrics() override - { - ccf::SessionMetrics sm; - std::lock_guard guard(lock); - - sm.active = sessions.size(); - sm.peak = sessions_peak; - - for (const auto& [name, interface] : listening_interfaces) - { - sm.interfaces[name] = { - interface.open_sessions, - interface.peak_sessions, - interface.max_open_sessions_soft, - interface.max_open_sessions_hard, - interface.errors}; - } - - return sm; - } - - ccf::ApplicationProtocol get_app_protocol_main_interface() const override - { - // Note: this is a temporary function to conveniently find out which - // protocol to use when creating client endpoints (e.g. for join - // protocol). This can be removed once the HTTP and HTTP/2 endpoints have - // been merged. - if (listening_interfaces.empty()) - { - throw std::logic_error("No listening interface for this node"); - } - - return listening_interfaces.begin()->second.app_protocol; - } - - void set_node_cert( - const ccf::crypto::Pem& cert_, const ccf::crypto::Pem& pk) override - { - set_cert(ccf::Authority::NODE, cert_, pk); - } - - void set_network_cert( - const ccf::crypto::Pem& cert_, const ccf::crypto::Pem& pk) override - { - set_cert(ccf::Authority::SERVICE, cert_, pk); - } - - void set_cert( - ccf::Authority authority, - const ccf::crypto::Pem& cert_, - const ccf::crypto::Pem& pk) - { - // Caller authentication is done by each frontend by looking up - // the caller's certificate in the relevant store table. The caller - // certificate does not have to be signed by a known CA (nullptr) and - // verification is not required here. - auto cert = std::make_shared<::tls::Cert>( - nullptr, cert_, pk, std::nullopt, /*auth_required ==*/false); - - std::lock_guard guard(lock); - - for (auto& [listen_interface_id, interface] : listening_interfaces) - { - if (interface.endorsement.authority == authority) - { - certs.insert_or_assign(listen_interface_id, cert); - } - } - } - - void accept( - ccf::tls::ConnID id, - const ListenInterfaceID& listen_interface_id, - bool udp = false) - { - std::lock_guard guard(lock); - - if (sessions.find(id) != sessions.end()) - { - throw std::logic_error( - fmt::format("Duplicate conn ID received inside enclave: {}", id)); - } - - auto it = listening_interfaces.find(listen_interface_id); - if (it == listening_interfaces.end()) - { - throw std::logic_error(fmt::format( - "Can't accept new RPC session {} - comes from unknown listening " - "interface {}", - id, - listen_interface_id)); - } - - auto& per_listen_interface = it->second; - - if ( - per_listen_interface.endorsement.authority != Authority::UNSECURED && - certs.find(listen_interface_id) == certs.end()) - { - LOG_DEBUG_FMT( - "Refusing TLS session {} inside the enclave - interface {} " - "has no TLS certificate yet", - id, - listen_interface_id); - - RINGBUFFER_WRITE_MESSAGE( - ::tcp::tcp_stop, to_host, id, std::string("Session refused")); - } - else if ( - per_listen_interface.open_sessions >= - per_listen_interface.max_open_sessions_hard) - { - LOG_INFO_FMT( - "Refusing TLS session {} inside the enclave - already have {} " - "sessions from interface {} and limit is {}", - id, - per_listen_interface.open_sessions, - listen_interface_id, - per_listen_interface.max_open_sessions_hard); - - RINGBUFFER_WRITE_MESSAGE( - ::tcp::tcp_stop, to_host, id, std::string("Session refused")); - } - else if ( - per_listen_interface.open_sessions >= - per_listen_interface.max_open_sessions_soft) - { - LOG_INFO_FMT( - "Soft refusing session {} (returning 503) inside the enclave - " - "already have {} sessions from interface {} and limit is {}", - id, - per_listen_interface.open_sessions, - listen_interface_id, - per_listen_interface.max_open_sessions_soft); - - auto ctx = std::make_unique<::tls::Server>(certs[listen_interface_id]); - std::shared_ptr capped_session; - if (per_listen_interface.app_protocol == "HTTP2") - { - capped_session = - std::make_shared>( - rpc_map, - id, - listen_interface_id, - writer_factory, - std::move(ctx), - per_listen_interface.http_configuration, - shared_from_this()); - } - else - { - capped_session = - std::make_shared>( - rpc_map, - id, - listen_interface_id, - writer_factory, - std::move(ctx), - per_listen_interface.http_configuration, - shared_from_this(), - commit_callbacks_subsystem); - } - sessions.insert(std::make_pair( - id, std::make_pair(listen_interface_id, std::move(capped_session)))); - per_listen_interface.open_sessions++; - per_listen_interface.peak_sessions = std::max( - per_listen_interface.peak_sessions, - per_listen_interface.open_sessions); - } - else - { - LOG_DEBUG_FMT( - "Accepting a session {} inside the enclave from interface \"{}\"", - id, - listen_interface_id); - - if (udp) - { - LOG_DEBUG_FMT("New UDP endpoint at {}", id); - if (per_listen_interface.app_protocol == "QUIC") - { - auto session = std::make_shared( - rpc_map, id, listen_interface_id, writer_factory); - sessions.insert(std::make_pair( - id, std::make_pair(listen_interface_id, std::move(session)))); - } - else if (custom_protocol_subsystem) - { - // We know it's a custom protocol, but the session creation function - // hasn't been registered yet, so we keep a nullptr until the first - // udp::udp_inbound message. - sessions.insert( - std::make_pair(id, std::make_pair(listen_interface_id, nullptr))); - } - else - { - throw std::runtime_error( - "unknown UDP protocol and custom protocol subsystem missing"); - } - per_listen_interface.open_sessions++; - per_listen_interface.peak_sessions = std::max( - per_listen_interface.peak_sessions, - per_listen_interface.open_sessions); - } - else - { - std::unique_ptr ctx; - if ( - per_listen_interface.endorsement.authority == Authority::UNSECURED) - { - ctx = std::make_unique(); - } - else - { - ctx = std::make_unique<::tls::Server>( - certs[listen_interface_id], - per_listen_interface.app_protocol == "HTTP2"); - } - - auto session = make_server_session( - per_listen_interface.app_protocol, - id, - listen_interface_id, - std::move(ctx), - per_listen_interface.http_configuration); - - sessions.insert(std::make_pair( - id, std::make_pair(listen_interface_id, std::move(session)))); - per_listen_interface.open_sessions++; - per_listen_interface.peak_sessions = std::max( - per_listen_interface.peak_sessions, - per_listen_interface.open_sessions); - } - } - - sessions_peak = std::max(sessions_peak, sessions.size()); - } - - std::shared_ptr find_session(ccf::tls::ConnID id) - { - std::lock_guard guard(lock); - - auto search = sessions.find(id); - if (search == sessions.end()) - { - return nullptr; - } - - return search->second.second; - } - - bool reply_async( - ccf::tls::ConnID id, - bool terminate_after_send, - std::vector&& data) override - { - auto session = find_session(id); - if (session == nullptr) - { - LOG_DEBUG_FMT("Refusing to reply to unknown session {}", id); - return false; - } - - LOG_DEBUG_FMT("Replying to session {}", id); - - session->send_data(std::move(data)); - - if (terminate_after_send) - { - session->close_session(); - } - - return true; - } - - void remove_session(ccf::tls::ConnID id) - { - std::lock_guard guard(lock); - LOG_DEBUG_FMT("Closing a session inside the enclave: {}", id); - const auto search = sessions.find(id); - if (search != sessions.end()) - { - auto it = listening_interfaces.find(search->second.first); - if (it != listening_interfaces.end()) - { - it->second.open_sessions--; - } - sessions.erase(search); - } - else - { - // Enclave doesn't know this ID, but host is still talking about it. - // Continue with the normal closure flow - RINGBUFFER_WRITE_MESSAGE(::tcp::tcp_closed, to_host, id); - } - } - - void register_message_handlers( - messaging::Dispatcher& disp) - { - DISPATCHER_SET_MESSAGE_HANDLER( - disp, ::tcp::tcp_start, [this](const uint8_t* data, size_t size) { - auto [new_tls_id, listen_interface_name] = - ringbuffer::read_message<::tcp::tcp_start>(data, size); - accept(new_tls_id, listen_interface_name); - }); - - DISPATCHER_SET_MESSAGE_HANDLER( - disp, ::tcp::tcp_inbound, [this](const uint8_t* data, size_t size) { - auto [id, body] = - ringbuffer::read_message<::tcp::tcp_inbound>(data, size); - - auto session = find_session(id); - if (session == nullptr) - { - LOG_DEBUG_FMT( - "Ignoring tls_inbound for unknown or refused session: {}", id); - return; - } - - session->handle_incoming_data(body); - }); - - DISPATCHER_SET_MESSAGE_HANDLER( - disp, ::tcp::tcp_close, [this](const uint8_t* data, size_t size) { - auto [id] = ringbuffer::read_message<::tcp::tcp_close>(data, size); - remove_session(id); - }); - - DISPATCHER_SET_MESSAGE_HANDLER( - disp, udp::udp_start, [this](const uint8_t* data, size_t size) { - auto [new_id, listen_interface_name] = - ringbuffer::read_message(data, size); - accept(new_id, listen_interface_name, true); - }); - - DISPATCHER_SET_MESSAGE_HANDLER( - disp, udp::udp_inbound, [this](const uint8_t* data, size_t size) { - auto id = serialized::peek(data, size); - - std::shared_ptr session; - { - std::lock_guard guard(lock); - - auto search = sessions.find(id); - if (search == sessions.end()) - { - LOG_DEBUG_FMT( - "Ignoring udp::udp_inbound for unknown or refused session: {}", - id); - return; - } - - if (!search->second.second && custom_protocol_subsystem) - { - LOG_DEBUG_FMT("Creating custom UDP session {}", id); - - try - { - const auto& conn_id = search->first; - const auto& interface_id = search->second.first; - - auto iit = listening_interfaces.find(interface_id); - if (iit == listening_interfaces.end()) - { - LOG_DEBUG_FMT( - "Failure to create custom protocol session because of " - "unknown interface '{}', ignoring udp::udp_inbound for " - "session: " - "{}", - interface_id, - id); - } - - const auto& interface = iit->second; - - search->second.second = - custom_protocol_subsystem->create_session( - interface.app_protocol, conn_id, nullptr); - - if (!search->second.second) - { - LOG_DEBUG_FMT( - "Failure to create custom protocol session, ignoring " - "udp::udp_inbound for session: {}", - id); - return; - } - } - catch (const std::exception& ex) - { - LOG_DEBUG_FMT( - "Failure to create custom protocol session: {}", ex.what()); - return; - } - } - - session = search->second.second; - } - - auto [_, addr_family, addr_data, body] = - ringbuffer::read_message(data, size); - session->handle_incoming_data( - body, udp::sockaddr_decode(addr_family, addr_data)); - }); - } - }; -} diff --git a/src/enclave/tls_session.h b/src/enclave/tls_session.h deleted file mode 100644 index ee5ca7121c61..000000000000 --- a/src/enclave/tls_session.h +++ /dev/null @@ -1,671 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the Apache 2.0 License. -#pragma once - -#include "ds/internal_logger.h" -#include "ds/messaging.h" -#include "ds/ring_buffer.h" -#include "tcp/msg_types.h" -#include "tls/context.h" -#include "tls/tls.h" - -#include - -namespace ccf -{ - enum SessionStatus : uint8_t - { - handshake, - ready, - closing, - closed, - authfail, - error - }; - - class TLSSession : public std::enable_shared_from_this - { - public: - using HandshakeErrorCB = std::function; - - protected: - ringbuffer::WriterPtr to_host; - ::tcp::ConnID session_id; - - private: - std::vector pending_write; - std::vector pending_read; - // Decrypted data - std::vector read_buffer; - - std::unique_ptr ctx; - SessionStatus status = handshake; - - HandshakeErrorCB handshake_error_cb; - - bool can_send() - { - // Closing endpoint should still be able to respond to clients (e.g. to - // report errors) - return status == ready || status == closing; - } - - bool can_recv() - { - return status == ready || status == handshake; - } - - public: - TLSSession( - int64_t session_id_, - ringbuffer::AbstractWriterFactory& writer_factory_, - std::unique_ptr ctx_) : - to_host(writer_factory_.create_writer_to_outside()), - session_id(session_id_), - ctx(std::move(ctx_)) - { - ctx->set_bio(this, send_callback_openssl, recv_callback_openssl); - } - - virtual ~TLSSession() - { - RINGBUFFER_WRITE_MESSAGE(::tcp::tcp_closed, to_host, session_id); - } - - SessionStatus get_status() const - { - return status; - } - - void on_handshake_error(std::string&& error_msg) - { - if (handshake_error_cb) - { - handshake_error_cb(std::move(error_msg)); - } - else - { - LOG_TRACE_FMT("{}", error_msg); - } - } - - void set_handshake_error_cb(HandshakeErrorCB&& cb) - { - handshake_error_cb = std::move(cb); - } - - std::string hostname() - { - if (status != ready) - { - return {}; - } - - return ctx->host(); - } - - std::vector peer_cert() - { - return ctx->peer_cert(); - } - - // Returns count N of bytes read, which will be the first N bytes of data, - // up to a maximum of size. If exact is true, will only return either size - // or 0 (when size bytes are not currently available). data may be accessed - // beyond N during operation, up to size, but only the first N should be - // used by caller. - size_t read(uint8_t* data, size_t size, bool exact = false) - { - // This will return empty if the connection isn't - // ready, but it will not block on the handshake. - do_handshake(); - - if (status != ready) - { - LOG_TRACE_FMT("Not ready to read {} bytes", size); - return 0; - } - - LOG_TRACE_FMT("Requesting up to {} bytes", size); - - // Send pending writes. - flush(); - - size_t offset = 0; - - if (!read_buffer.empty()) - { - LOG_TRACE_FMT( - "Have existing read_buffer of size: {}", read_buffer.size()); - offset = std::min(size, read_buffer.size()); - ::memcpy(data, read_buffer.data(), offset); - - if (offset < read_buffer.size()) - { - read_buffer.erase(read_buffer.begin(), read_buffer.begin() + offset); - } - else - { - read_buffer.clear(); - } - - if (offset == size) - { - return size; - } - - // NB: If we continue past here, read_buffer is empty - } - - auto r = ctx->read(data + offset, size - offset); - LOG_TRACE_FMT("ctx->read returned: {}", r); - - switch (r) - { - case 0: - case TLS_ERR_CONN_CLOSE_NOTIFY: - { - LOG_TRACE_FMT( - "TLS {} close on read: {}", session_id, ::tls::error_string(r)); - - stop(closed); - - if (!exact) - { - // Hit an error, but may still have some useful data from the - // previous read_buffer - return offset; - } - - return 0; - } - - case TLS_ERR_WANT_READ: - case TLS_ERR_WANT_WRITE: - { - if (!exact) - { - return offset; - } - - // May have read something but not enough - copy it into read_buffer - // for next call - read_buffer.insert(read_buffer.end(), data, data + offset); - return 0; - } - - default: - { - } - } - - if (r < 0) - { - LOG_TRACE_FMT( - "TLS {} error on read: {}", session_id, ::tls::error_string(r)); - stop(error); - return 0; - } - - auto total = r + offset; - - // We read _some_ data but not enough, and didn't get - // TLS_ERR_WANT_READ. Probably hit an internal size limit - try - // again - if (exact && (total < size)) - { - LOG_TRACE_FMT( - "Asked for exactly {}, received {}, retrying", size, total); - read_buffer.insert(read_buffer.end(), data, data + total); - return read(data, size, exact); - } - - return total; - } - - void recv_buffered(const uint8_t* data, size_t size) - { - if (can_recv()) - { - pending_read.insert(pending_read.end(), data, data + size); - } - - do_handshake(); - } - - void close() - { - status = closing; - - switch (status) - { - case handshake: - { - LOG_TRACE_FMT("TLS {} closed during handshake", session_id); - stop(closed); - break; - } - - case ready: - case closing: - { - int r = ctx->close(); - - switch (r) - { - case TLS_ERR_WANT_READ: - case TLS_ERR_WANT_WRITE: - { - LOG_TRACE_FMT("TLS {} has pending data ({})", session_id, r); - // FALLTHROUGH - } - case 0: - { - LOG_TRACE_FMT("TLS {} closed ({})", session_id, r); - stop(closed); - break; - } - - default: - { - LOG_TRACE_FMT( - "TLS {} error on_close: {}", - session_id, - ::tls::error_string(r)); - stop(error); - break; - } - } - break; - } - - case closed: - case authfail: - case error: - { - break; - } - } - } - - void send_data(const uint8_t* data, size_t size) - { - // Writes as much of the data as possible. If the data cannot all - // be written now, we store the remainder. We - // will try to send pending writes again whenever write() is called. - do_handshake(); - - if (status == handshake) - { - pending_write.insert(pending_write.end(), data, data + size); - return; - } - - if (!can_send()) - { - return; - } - - pending_write.insert(pending_write.end(), data, data + size); - - flush(); - } - - private: - void send_buffered(const std::vector& data) - { - pending_write.insert(pending_write.end(), data.begin(), data.end()); - } - - void flush() - { - do_handshake(); - - if (!can_send()) - { - return; - } - - while (!pending_write.empty()) - { - auto r = write_some(pending_write); - - if (r > 0) - { - pending_write.erase(pending_write.begin(), pending_write.begin() + r); - } - else if (r == 0) - { - break; - } - else - { - LOG_TRACE_FMT("TLS session {} error on flush: {}", session_id, -r); - stop(error); - break; - } - } - } - - void do_handshake() - { - // This should be called when additional data is written to the - // input buffer, until the handshake is complete. - if (status != handshake) - { - return; - } - - auto rc = ctx->handshake(); - - switch (rc) - { - case 0: - { - status = ready; - break; - } - - case TLS_ERR_WANT_READ: - case TLS_ERR_WANT_WRITE: - break; - - case TLS_ERR_NEED_CERT: - { - on_handshake_error(fmt::format( - "TLS {} verify error on handshake: {}", - session_id, - ::tls::error_string(rc))); - stop(authfail); - break; - } - - case TLS_ERR_CONN_CLOSE_NOTIFY: - { - LOG_TRACE_FMT( - "TLS {} closed on handshake: {}", - session_id, - ::tls::error_string(rc)); - stop(closed); - break; - } - - case TLS_ERR_X509_VERIFY: - { - auto err = ctx->get_verify_error(); - on_handshake_error(fmt::format( - "TLS {} invalid cert on handshake: {} [{}]", - session_id, - err, - ::tls::error_string(rc))); - stop(authfail); - return; - } - - default: - { - on_handshake_error(fmt::format( - "TLS {} error on handshake: {}", - session_id, - ::tls::error_string(rc))); - stop(error); - break; - } - } - } - - int write_some(const std::vector& data) - { - auto r = ctx->write(data.data(), data.size()); - - switch (r) - { - case TLS_ERR_WANT_READ: - case TLS_ERR_WANT_WRITE: - return 0; - - default: - return r; - } - } - - void stop(SessionStatus status_) - { - switch (status) - { - case closed: - case authfail: - case error: - return; - - case handshake: - case ready: - case closing: - { - break; - } - } - - status = status_; - - switch (status) - { - case handshake: - case ready: - { - break; - } - case closing: - case closed: - { - RINGBUFFER_WRITE_MESSAGE( - ::tcp::tcp_stop, - to_host, - session_id, - std::string("Session closed")); - break; - } - - case authfail: - { - RINGBUFFER_WRITE_MESSAGE( - ::tcp::tcp_stop, - to_host, - session_id, - std::string("Authentication failed")); - } - case error: - { - RINGBUFFER_WRITE_MESSAGE( - ::tcp::tcp_stop, to_host, session_id, std::string("Error")); - break; - } - - default: - throw std::logic_error( - fmt::format("TLS {} unknown status: {}", session_id, status)); - } - } - - int handle_send(const uint8_t* buf, size_t len) - { - // Either write all of the data or none of it. - auto wrote = RINGBUFFER_TRY_WRITE_MESSAGE( - ::tcp::tcp_outbound, - to_host, - session_id, - serializer::ByteRange{buf, len}); - - if (!wrote) - { - return TLS_WRITING; - } - - return static_cast(len); - } - - int handle_recv(uint8_t* buf, size_t len) - { - if (!pending_read.empty()) - { - // Use the pending data vector. This is populated when the host - // writes a chunk larger than the size requested by the enclave. - size_t rd = std::min(len, pending_read.size()); - ::memcpy(buf, pending_read.data(), rd); - - if (rd >= pending_read.size()) - { - pending_read.clear(); - } - else - { - pending_read.erase(pending_read.begin(), pending_read.begin() + rd); - } - - return (int)rd; - } - - return TLS_READING; - } - - static int send_callback(void* ctx, const unsigned char* buf, size_t len) - { - return reinterpret_cast(ctx)->handle_send(buf, len); - } - - static int recv_callback(void* ctx, unsigned char* buf, size_t len) - { - return reinterpret_cast(ctx)->handle_recv(buf, len); - } - - // These callbacks below are complex, using the callbacks above and - // manipulating OpenSSL's BIO objects accordingly. This is just so we can - // emulate what MbedTLS used to do. - // Now that we have removed it from the code, we can move the callbacks - // above to handle BIOs directly and hopefully remove the complexity below. - // This work will be carried out in #3429. - static long send_callback_openssl( - BIO* b, - int oper, - const char* argp, - size_t len, - int argi, - long argl, - int ret, - size_t* processed) - { - // Unused arguments - (void)argi; - (void)argl; - (void)argp; - - if (ret != 0 && len > 0 && oper == (BIO_CB_WRITE | BIO_CB_RETURN)) - { - // Flush BIO so the "pipe doesn't clog", but we don't use the - // data here, because 'argp' already has it. - BIO_flush(b); - size_t pending = BIO_pending(b); - if (pending != 0) - { - BIO_reset(b); - } - - // Pipe object - void* ctx = BIO_get_callback_arg(b); - int put = - send_callback(ctx, reinterpret_cast(argp), len); - - // WANTS_WRITE - if (put == TLS_WRITING) - { - BIO_set_retry_write(b); - LOG_TRACE_FMT("TLS Session::send_cb() : WANTS_WRITE"); - *processed = 0; - return -1; - } - - LOG_TRACE_FMT("TLS Session::send_cb() : Put {} bytes", put); - - // Update the number of bytes to external users - *processed = put; - } - - // Unless we detected an error, the return value is always the same as the - // original operation. - return ret; - } - - static long recv_callback_openssl( - BIO* b, - int oper, - const char* argp, - size_t len, - int argi, - long argl, - int ret, - size_t* processed) - { - // Unused arguments - (void)argi; - (void)argl; - - if (ret == 1 && oper == (BIO_CB_CTRL | BIO_CB_RETURN)) - { - // This callback may be fired at the end of large batches of TLS frames - // on OpenSSL 3.x. Note that processed == nullptr in this case, hence - // the early exit. - return 0; - } - - if (ret != 0 && (oper == (BIO_CB_READ | BIO_CB_RETURN))) - { - // Pipe object - void* ctx = BIO_get_callback_arg(b); - // NOLINTNEXTLINE(cppcoreguidelines-pro-type-const-cast) - int got = recv_callback( - ctx, reinterpret_cast(const_cast(argp)), len); - - // WANTS_READ - if (got == TLS_READING) - { - BIO_set_retry_read(b); - LOG_TRACE_FMT("TLS Session::recv_cb() : WANTS_READ"); - *processed = 0; - return -1; - } - - LOG_TRACE_FMT("TLS Session::recv_cb() : Got {} bytes of {}", got, len); - - // If got less than requested, return WANT_READ - if ((size_t)got < len) - { - *processed = got; - return 1; - } - - // Write to the actual BIO so SSL can use it - BIO_write_ex(b, argp, got, processed); - - // The buffer should be enough, we can't return WANT_WRITE here - if ((size_t)got != *processed) - { - LOG_TRACE_FMT("TLS Session::recv_cb() : BIO error"); - *processed = got; - return -1; - } - - // If original return was -1 because it didn't find anything to read, - // return 1 to say we actually read something. This is common when the - // buffer is empty and needs an external read, so let's not log this. - if (got > 0 && ret < 0) - { - return 1; - } - } - - // Unless we detected an error, the return value is always the same as the - // original operation. - return ret; - } - }; -} diff --git a/src/host/rpc_connections.h b/src/host/rpc_connections.h deleted file mode 100644 index 78b8df00f033..000000000000 --- a/src/host/rpc_connections.h +++ /dev/null @@ -1,524 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the Apache 2.0 License. -#pragma once - -#include "../tcp/msg_types.h" -#include "../udp/msg_types.h" -#include "ds/messaging.h" -#include "tcp.h" -#include "timer.h" -#include "udp.h" - -#include -#include -#include -#include - -namespace // NOLINT(cert-dcl59-cpp) -{ - template - constexpr bool isTCP() - { - return std::is_same(); - } - - template - constexpr bool isUDP() - { - return std::is_same(); - } - - template - constexpr const char* getConnTypeName() - { - if constexpr (isTCP()) - { - return "TCP"; - } - else if constexpr (isUDP()) - { - return "UDP"; - } - else - { - throw std::runtime_error("Invalid connection type"); - } - } -} - -namespace asynchost -{ - /** - * Generates next ID, passed as an argument to RPCConnectionsImpl so that we - * can have multiple and avoid reusing the same ConnID across each. - */ - class ConnIDGenerator - { - public: - /// This is the same as ccf::tls::ConnID and udp::ConnID - using ConnID = int64_t; - static_assert(std::is_same<::tcp::ConnID, udp::ConnID>()); - static_assert(std::is_same<::tcp::ConnID, ConnID>()); - - ConnIDGenerator() : next_id(1) {} - - template - ConnID get_next_id(T& sockets) - { - auto id = next_id++; - const auto initial = id; - - if (next_id < 0) - { - next_id = 1; - } - - while (sockets.find(id) != sockets.end()) - { - id++; - - if (id < 0) - { - id = 1; - } - - if (id == initial) - { - throw std::runtime_error( - "Exhausted all IDs for host RPC connections"); - } - } - - return id; - } - - private: - std::atomic next_id; - }; - - template - class RPCConnectionsImpl - { - using ConnID = ConnIDGenerator::ConnID; - - class RPCClientBehaviour : public SocketBehaviour - { - public: - RPCConnectionsImpl& parent; - ConnID id; - - RPCClientBehaviour(RPCConnectionsImpl& parent, ConnID id) : - SocketBehaviour("RPC Client", getConnTypeName()), - parent(parent), - id(id) - { - parent.mark_active(id); - } - - void on_resolve_failed() override - { - LOG_DEBUG_FMT("rpc resolve failed {}", id); - cleanup(); - } - - void on_connect_failed() override - { - LOG_DEBUG_FMT("rpc connect failed {}", id); - cleanup(); - } - - bool on_read(size_t len, uint8_t*& data, sockaddr /*unused*/) override - { - LOG_DEBUG_FMT("rpc read {}: {}", id, len); - - parent.mark_active(id); - - RINGBUFFER_WRITE_MESSAGE( - ::tcp::tcp_inbound, - parent.to_enclave, - id, - serializer::ByteRange{data, len}); - - return true; - } - - void on_disconnect() override - { - LOG_DEBUG_FMT("rpc disconnect {}", id); - cleanup(); - } - - void cleanup() - { - if constexpr (isTCP()) - { - RINGBUFFER_WRITE_MESSAGE(::tcp::tcp_close, parent.to_enclave, id); - } - } - }; - - class RPCServerBehaviour : public SocketBehaviour - { - public: - RPCConnectionsImpl& parent; - ConnID id; - - RPCServerBehaviour(RPCConnectionsImpl& parent, ConnID id) : - SocketBehaviour("RPC Client", getConnTypeName()), - parent(parent), - id(id) - {} - - void on_accept(ConnType& peer) override - { - // UDP connections don't register peers - if constexpr (isUDP()) - { - return; - } - - auto client_id = parent.get_next_id(); - peer->set_behaviour( - std::make_unique(parent, client_id)); - parent.sockets.emplace(client_id, peer); - - on_start(client_id); - } - - void on_start(int64_t peer_id) override - { - const auto interface_name = parent.get_interface_listen_name(id); - - LOG_DEBUG_FMT( - "rpc start {} on interface \"{}\" as {}", - peer_id, - interface_name, - this->conn_name); - - if constexpr (isTCP()) - { - RINGBUFFER_WRITE_MESSAGE( - ::tcp::tcp_start, parent.to_enclave, peer_id, interface_name); - return; - } - - if constexpr (isUDP()) - { - RINGBUFFER_WRITE_MESSAGE( - udp::udp_start, parent.to_enclave, peer_id, interface_name); - return; - } - } - - bool on_read(size_t len, uint8_t*& data, sockaddr addr) override - { - // UDP connections don't have clients, it's all done in the server - if constexpr (isUDP()) - { - auto [addr_family, addr_data] = udp::sockaddr_encode(addr); - - LOG_DEBUG_FMT("rpc udp read into ring buffer {}: {}", id, len); - RINGBUFFER_WRITE_MESSAGE( - udp::udp_inbound, - parent.to_enclave, - id, - addr_family, - addr_data, - serializer::ByteRange{data, len}); - } - - return true; - } - - void cleanup() - { - parent.sockets.erase(id); - } - }; - - std::unordered_map sockets; - // The timer close callback deletes this object asynchronously. - std::shared_ptr id_gen; - - // Measured in seconds - std::unordered_map idle_times; - - std::optional client_connection_timeout = - std::nullopt; - - std::optional idle_connection_timeout = std::nullopt; - - ringbuffer::WriterPtr to_enclave; - - public: - RPCConnectionsImpl( - ringbuffer::AbstractWriterFactory& writer_factory, - std::shared_ptr id_gen_, - std::optional client_connection_timeout_ = - std::nullopt, - std::optional idle_connection_timeout_ = - std::nullopt) : - id_gen(std::move(id_gen_)), - client_connection_timeout(client_connection_timeout_), - idle_connection_timeout(idle_connection_timeout_), - to_enclave(writer_factory.create_writer_to_inside()) - { - if (id_gen == nullptr) - { - throw std::invalid_argument( - "RPC connections require a connection ID generator"); - } - } - - bool listen( - ConnID id, std::string& host, std::string& port, const std::string& name) - { - if (id == 0) - { - id = get_next_id(); - } - - if (sockets.find(id) != sockets.end()) - { - LOG_FAIL_FMT("Cannot listen on id {}: already in use", id); - return false; - } - - ConnType s; - s->set_behaviour(std::make_unique(*this, id)); - - if (!s->listen(host, port, name)) - { - return false; - } - - host = s->get_host(); - port = s->get_port(); - - sockets.emplace(id, s); - - // UDP connections don't have peers, so we need to register the main - // socket TCP connections started via peer, on on_accept behaviour call - if constexpr (isUDP()) - { - s->start(id); - } - - return true; - } - - bool connect(ConnID id, const std::string& host, const std::string& port) - { - if (id == 0) - { - id = get_next_id(); - } - - if (sockets.find(id) != sockets.end()) - { - LOG_FAIL_FMT("Cannot connect on id {}: already in use", id); - return false; - } - - auto s = ConnType(true, client_connection_timeout); - s->set_behaviour(std::make_unique(*this, id)); - - if (!s->connect(host, port)) - { - return false; - } - - sockets.emplace(id, s); - return true; - } - - bool write(ConnID id, size_t len, const uint8_t* data, sockaddr addr = {}) - { - auto s = sockets.find(id); - - if (s == sockets.end()) - { - LOG_FAIL_FMT( - "Received an outbound message for id {} which is not a known " - "connection. Ignoring message of {} bytes", - id, - len); - return false; - } - - if (s->second.is_null()) - { - return false; - } - - mark_active(id); - - return s->second->write(len, data, addr); - } - - bool stop(ConnID id) - { - // Invalidating the TCP socket will result in the handle being closed. No - // more messages will be read from or written to the TCP socket. - sockets[id] = nullptr; - - RINGBUFFER_WRITE_MESSAGE(::tcp::tcp_close, to_enclave, id); - - return true; - } - - bool close(ConnID id) - { - if (sockets.erase(id) < 1) - { - LOG_DEBUG_FMT("Cannot close id {}: does not exist", id); - return false; - } - - // Make sure idle_times is cleaned up here, though in practice it should - // have been cleared when an earlier stop() was called - idle_times.erase(id); - - return true; - } - - void register_message_handlers( - messaging::Dispatcher& disp) - { - DISPATCHER_SET_MESSAGE_HANDLER( - disp, ::tcp::tcp_outbound, [this](const uint8_t* data, size_t size) { - auto [id, body] = - ringbuffer::read_message<::tcp::tcp_outbound>(data, size); - - auto connect_id = static_cast(id); - LOG_DEBUG_FMT("rpc write from enclave {}: {}", connect_id, body.size); - - write(connect_id, body.size, body.data); - }); - - DISPATCHER_SET_MESSAGE_HANDLER( - disp, ::tcp::tcp_connect, [this](const uint8_t* data, size_t size) { - auto [id, host, port] = - ringbuffer::read_message<::tcp::tcp_connect>(data, size); - - LOG_DEBUG_FMT("rpc connect request from enclave {}", id); - - if (check_enclave_side_id(id)) - { - connect(id, host, port); - } - else - { - LOG_FAIL_FMT( - "rpc session id is not in dedicated from-enclave range ({})", id); - } - }); - - DISPATCHER_SET_MESSAGE_HANDLER( - disp, ::tcp::tcp_stop, [this](const uint8_t* data, size_t size) { - auto [id, msg] = - ringbuffer::read_message<::tcp::tcp_stop>(data, size); - - LOG_DEBUG_FMT("rpc stop from enclave {}, {}", id, msg); - stop(id); - - // Immediately stop tracking idle timeout for this ID too - idle_times.erase(id); - }); - - DISPATCHER_SET_MESSAGE_HANDLER( - disp, ::tcp::tcp_closed, [this](const uint8_t* data, size_t size) { - auto [id] = ringbuffer::read_message<::tcp::tcp_closed>(data, size); - - LOG_DEBUG_FMT("rpc closed from enclave {}", id); - close(id); - }); - } - - void register_udp_message_handlers( - messaging::Dispatcher& disp) - { - DISPATCHER_SET_MESSAGE_HANDLER( - disp, udp::udp_outbound, [this](const uint8_t* data, size_t size) { - auto [id, addr_family, addr_data, body] = - ringbuffer::read_message(data, size); - - auto connect_id = static_cast(id); - LOG_DEBUG_FMT("rpc write from enclave {}: {}", connect_id, body.size); - - auto addr = udp::sockaddr_decode(addr_family, addr_data); - write(connect_id, body.size, body.data, addr); - }); - } - - void mark_active(ConnID id) - { - idle_times[id] = 0; - } - - void on_timer() - { - if (!idle_connection_timeout.has_value()) - { - return; - } - - const size_t max_idle_time = idle_connection_timeout->count(); - - auto it = idle_times.begin(); - while (it != idle_times.end()) - { - auto& [id, idle_time] = *it; - if (idle_time > max_idle_time) - { - LOG_INFO_FMT( - "Closing socket {} after {}s idle (max = {}s)", - id, - idle_time, - max_idle_time); - stop(id); - it = idle_times.erase(it); - } - else - { - idle_time += 1; - ++it; - } - } - } - - private: - ConnID get_next_id() - { - return id_gen->get_next_id(sockets); - } - - bool check_enclave_side_id(ConnID id) - { - return id < 0; - } - - std::string get_interface_listen_name(ConnID id) - { - const auto it = sockets.find(id); - if (it == sockets.end()) - { - LOG_FAIL_FMT( - "Requested interface number {}, has {}", id, sockets.size()); - throw std::logic_error(fmt::format("No socket with id {}", id)); - } - - auto listen_name = it->second->get_listen_name(); - if (!listen_name.has_value()) - { - throw std::logic_error( - fmt::format("Interface {} has no listen name", id)); - } - - return listen_name.value(); - } - }; - - template - using RPCConnections = proxy_ptr>>; -} diff --git a/src/host/run.cpp b/src/host/run.cpp index 68eb671002a8..ab91cd3c7d7b 100644 --- a/src/host/run.cpp +++ b/src/host/run.cpp @@ -36,7 +36,6 @@ #include "lfs_file_handler.h" #include "node_connections.h" #include "pal/quote_generation.h" -#include "rpc_connections.h" #include "sig_term.h" #include "tcp.h" #include "ticker.h" @@ -190,88 +189,7 @@ namespace ccf {} }; - void setup_rpc_interfaces( - host::HostConfig& config, - asynchost::RPCConnections& rpc, - asynchost::RPCConnections& rpc_udp) - { - ResolvedAddresses resolved_rpc_addresses; - - // Bind interfaces with an explicit (non-zero) port before those requesting - // an ephemeral port (port 0). Multiple interfaces on a node can share a - // single host address (for example the sole ::1 IPv6 loopback), and if an - // ephemeral interface is bound first the OS may assign it the exact port - // that another interface is configured to bind, making that later bind fail - // with "address already in use". - std::vector ordered_interface_names; - ordered_interface_names.reserve(config.network.rpc_interfaces.size()); - for (const auto& [name, interface] : config.network.rpc_interfaces) - { - if (cli::validate_address(interface.bind_address).second != "0") - { - ordered_interface_names.push_back(name); - } - } - for (const auto& [name, interface] : config.network.rpc_interfaces) - { - if (cli::validate_address(interface.bind_address).second == "0") - { - ordered_interface_names.push_back(name); - } - } - - for (const auto& name : ordered_interface_names) - { - auto& interface = config.network.rpc_interfaces.at(name); - auto [rpc_host, rpc_port] = cli::validate_address(interface.bind_address); - LOG_INFO_FMT( - "Registering RPC interface {}, on {} {}:{}", - name, - interface.protocol, - rpc_host, - rpc_port); - - if (interface.protocol == "udp") - { - rpc_udp->behaviour.listen(0, rpc_host, rpc_port, name); - } - else - { - rpc->behaviour.listen(0, rpc_host, rpc_port, name); - } - - LOG_INFO_FMT( - "Registered RPC interface {}, on {} {}:{}", - name, - interface.protocol, - rpc_host, - rpc_port); - - resolved_rpc_addresses[name] = ccf::make_net_address(rpc_host, rpc_port); - interface.bind_address = ccf::make_net_address(rpc_host, rpc_port); - - // If public RPC address is not set, default to local RPC address - if (interface.published_address.empty()) - { - interface.published_address = interface.bind_address; - } - - auto [pub_host, pub_port] = - cli::validate_address(interface.published_address); - if (pub_port == "0") - { - pub_port = rpc_port; - interface.published_address = ccf::make_net_address(pub_host, pub_port); - } - } - - if (!config.output_files.rpc_addresses_file.empty()) - { - files::dump( - nlohmann::json(resolved_rpc_addresses).dump(), - config.output_files.rpc_addresses_file); - } - } + void setup_rpc_interfaces_REMOVED() {} void configure_snp_attestation(ccf::StartupConfig& startup_config) { @@ -478,6 +396,7 @@ namespace ccf ccf::StartupConfig& startup_config, std::vector& node_cert, std::vector& service_cert, + std::vector& rpc_addresses, ccf::LoggerLevel log_level, ringbuffer::NotifyingWriterFactory& notifying_factory, asynchost::Ledger& ledger) @@ -499,6 +418,7 @@ namespace ccf startup_config, node_cert, service_cert, + rpc_addresses, config.command.type, log_level, config.worker_threads, @@ -529,6 +449,13 @@ namespace ccf } LOG_INFO_FMT("Created new node"); + + // The enclave resolves and binds the RPC interfaces (including ephemeral + // ports), and reports the resolved addresses back here to be written out. + if (!config.output_files.rpc_addresses_file.empty()) + { + files::dump(rpc_addresses, config.output_files.rpc_addresses_file); + } return std::nullopt; } @@ -693,37 +620,16 @@ namespace ccf config.output_files.node_to_node_address_file); } - const auto id_gen = std::make_shared(); - - asynchost::RPCConnections rpc( - 1s, // Tick once-per-second to track idle connections, - writer_factory, - id_gen, - config.client_connection_timeout, - config.idle_connection_timeout); - rpc->behaviour.register_message_handlers(buffer_processor.get_dispatcher()); - - asynchost::RPCConnections rpc_udp( - 1s, - writer_factory, - id_gen, - config.client_connection_timeout, - config.idle_connection_timeout); - rpc_udp->behaviour.register_udp_message_handlers( - buffer_processor.get_dispatcher()); - // Initialise the curlm singleton curl_global_init(CURL_GLOBAL_DEFAULT); auto curl_libuv_context = curl::CurlmLibuvContextSingleton(uv_default_loop()); - // Setup RPC interfaces - setup_rpc_interfaces(config, rpc, rpc_udp); - // Prepare startup configuration const size_t certificate_size = 4096; std::vector node_cert(certificate_size); std::vector service_cert(certificate_size); + std::vector rpc_addresses; ccf::StartupConfig startup_config(config); @@ -829,6 +735,7 @@ namespace ccf startup_config, node_cert, service_cert, + rpc_addresses, log_level, factories.notifying_factory, ledger); diff --git a/src/http/http_parser.h b/src/http/http_parser.h index 1bfa37afe2aa..ae182397e6e7 100644 --- a/src/http/http_parser.h +++ b/src/http/http_parser.h @@ -5,7 +5,7 @@ #include "ccf/ds/hex.h" #include "ccf/http_configuration.h" #include "ccf/http_query.h" -#include "enclave/tls_session.h" +#include "ds/internal_logger.h" #include "http/http_exceptions.h" #include "http_builder.h" #include "http_proc.h" diff --git a/src/http/http_proc.h b/src/http/http_proc.h index 74e93e3f2c4e..d067d3672848 100644 --- a/src/http/http_proc.h +++ b/src/http/http_proc.h @@ -2,7 +2,7 @@ // Licensed under the Apache 2.0 License. #pragma once -#include "enclave/tls_session.h" +#include "ds/internal_logger.h" #include "http2_types.h" #include "http_builder.h" diff --git a/src/node/http_node_client.h b/src/node/http_node_client.h index 6859b91855ba..53af770dbc2b 100644 --- a/src/node/http_node_client.h +++ b/src/node/http_node_client.h @@ -2,6 +2,7 @@ // Licensed under the Apache 2.0 License. #pragma once +#include "http/http_rpc_context.h" #include "node/node_client.h" #include diff --git a/src/node/node_state.h b/src/node/node_state.h index 235a9c8cfba0..5abb91a95f03 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -27,7 +27,9 @@ #include "ds/files.h" #include "ds/internal_logger.h" #include "ds/state_machine.h" -#include "enclave/rpc_sessions.h" +#include "enclave/abstract_rpc_sessions.h" +#include "tls/ca.h" +#include "tls/cert.h" #include "encryptor.h" #include "history.h" #include "http/curl.h" From 45a1a2710ad87680d409f21d0dbda951818e4311 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Wed, 24 Jun 2026 15:34:39 +0000 Subject: [PATCH 05/59] Wire outbound client TLS (peer CA verification + client cert via tls::Cert::use) and request client cert on inbound for caller auth; add peer-cert capture test. Full build green, unit tests pass. --- src/host/rpc_connection_manager.h | 19 ++-- src/host/test/openssl_server_test.cpp | 64 ++++++++++++- src/host/tls/openssl_server.h | 125 +++++++++++++++---------- src/host/tls/openssl_session_manager.h | 10 +- 4 files changed, 158 insertions(+), 60 deletions(-) diff --git a/src/host/rpc_connection_manager.h b/src/host/rpc_connection_manager.h index 80f44f00da20..26336db9e5fc 100644 --- a/src/host/rpc_connection_manager.h +++ b/src/host/rpc_connection_manager.h @@ -28,6 +28,7 @@ #include "http/http2_session.h" #include "http/http_session.h" #include "node/session_metrics.h" +#include "tls/cert.h" #include #include @@ -298,12 +299,9 @@ namespace ccf // ----- AbstractRPCSessions / AbstractRPCResponder ----------------------- std::shared_ptr create_client( - const std::shared_ptr<::tls::Cert>& /*cert*/, + const std::shared_ptr<::tls::Cert>& cert, const std::string& app_protocol = "HTTP1") override { - // TODO: wire outbound client certificate + CA verification (see - // OpenSSLServer client context). Currently the outbound connection is - // unverified and presents no client certificate. const int64_t id = next_client_id.fetch_sub(1); asynchost::OpenSSLSessionManager* bridge = nullptr; @@ -317,9 +315,18 @@ namespace ccf "Cannot create outbound client: no listening interface"); } + // The tls::Cert carries the peer CA (for server verification) and, + // optionally, this node's client certificate to present. It configures + // the outbound SSL when the connection is opened. auto connect_cb = - [bridge](int64_t cid, const std::string& h, const std::string& s) { - bridge->connect(static_cast<::tcp::ConnID>(cid), h, s); + [bridge, cert](int64_t cid, const std::string& h, const std::string& s) { + bridge->connect( + static_cast<::tcp::ConnID>(cid), h, s, [cert](SSL* ssl, SSL_CTX* ctx) { + if (cert != nullptr) + { + cert->use(ssl, ctx); + } + }); }; std::shared_ptr session; diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp index 2f9f2e56675c..4ab6c0dbd02d 100644 --- a/src/host/test/openssl_server_test.cpp +++ b/src/host/test/openssl_server_test.cpp @@ -22,7 +22,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -49,7 +51,11 @@ namespace // Blocking TLS client: connects, sends `req` in full, reads exactly // `expected_resp` bytes. Verification is disabled (self-signed slice cert). std::vector tls_client_exchange( - uint16_t port, const std::vector& req, size_t expected_resp) + uint16_t port, + const std::vector& req, + size_t expected_resp, + const std::string& client_cert = {}, + const std::string& client_key = {}) { const int fd = ::socket(AF_INET, SOCK_STREAM, 0); REQUIRE(fd >= 0); @@ -63,6 +69,23 @@ namespace SSL_CTX* cctx = SSL_CTX_new(TLS_client_method()); REQUIRE(cctx != nullptr); + if (!client_cert.empty()) + { + BIO* cb = + BIO_new_mem_buf(client_cert.data(), static_cast(client_cert.size())); + X509* xc = PEM_read_bio_X509(cb, nullptr, nullptr, nullptr); + BIO_free(cb); + REQUIRE(xc != nullptr); + REQUIRE(SSL_CTX_use_certificate(cctx, xc) == 1); + X509_free(xc); + BIO* kb = + BIO_new_mem_buf(client_key.data(), static_cast(client_key.size())); + EVP_PKEY* pk = PEM_read_bio_PrivateKey(kb, nullptr, nullptr, nullptr); + BIO_free(kb); + REQUIRE(pk != nullptr); + REQUIRE(SSL_CTX_use_PrivateKey(cctx, pk) == 1); + EVP_PKEY_free(pk); + } SSL* ssl = SSL_new(cctx); REQUIRE(ssl != nullptr); REQUIRE(SSL_set_fd(ssl, fd) == 1); @@ -310,3 +333,42 @@ TEST_CASE("Session bridge: large transfer through the seam") mgr.stop(); } + +// The server must request the client certificate during the handshake so it is +// available for application-level caller authentication (user/member cert +// auth). Verifies the cert presented by the client reaches the session factory. +TEST_CASE("Peer certificate is captured for inbound connections") +{ + auto [cert, key] = make_server_cert(); + auto [client_cert, client_key] = make_server_cert(); + + std::mutex m; + std::vector captured; + std::atomic got{false}; + + OpenSSLSessionManager mgr( + cert, + key, + "127.0.0.1", + static_cast(0), + [&](::tcp::ConnID id, ccf::SessionWriter& w, std::vector pc) { + { + std::lock_guard l(m); + captured = std::move(pc); + } + got.store(true); + return std::make_shared(id, w); + }); + mgr.start(); + + const std::vector msg = {'m', 't', 'l', 's'}; + REQUIRE( + tls_client_exchange(mgr.port(), msg, msg.size(), client_cert, client_key) == + msg); + + REQUIRE(got.load()); + std::lock_guard l(m); + REQUIRE(!captured.empty()); + + mgr.stop(); +} diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index c53e6dfd840c..56ca167bdc4f 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -66,6 +66,11 @@ namespace asynchost // connection state. using OnClose = std::function; + // Configures an outbound client SSL/SSL_CTX (peer CA verification, the + // client certificate to present, SNI). Supplied per-connect so each client + // session can use its own certificate. Invoked on the loop thread. + using ConfigureClientSSL = std::function; + private: static constexpr size_t read_chunk = 16384; @@ -90,8 +95,6 @@ namespace asynchost }; SSL_CTX* ctx = nullptr; - // Lazily created client context for outbound connections. - SSL_CTX* client_ctx = nullptr; // Plaintext (UNSECURED) interface: no TLS, raw socket I/O. bool plaintext = false; // ALPN protocol advertised by the server (wire format, length-prefixed), @@ -131,6 +134,7 @@ namespace asynchost int64_t id = 0; std::string host; std::string port; + ConfigureClientSSL configure; }; std::vector pending_connects; @@ -221,6 +225,12 @@ namespace asynchost return nullptr; } SSL_CTX_set_min_proto_version(c, TLS1_2_VERSION); + // Request the client certificate during the handshake so it can be used + // for application-level caller authentication (user/member cert auth). + // Verification is not enforced here - the application decides - mirroring + // the old Cert(auth_required = false) server behaviour. + SSL_CTX_set_verify( + c, SSL_VERIFY_PEER, [](int, X509_STORE_CTX*) { return 1; }); if (!alpn_wire.empty()) { SSL_CTX_set_alpn_select_cb(c, alpn_select_cb, this); @@ -626,7 +636,7 @@ namespace asynchost for (auto& req : connects) { - do_connect(req.id, req.host, req.port); + do_connect(req.id, req.host, req.port, req.configure); } for (auto& item : items) @@ -660,25 +670,18 @@ namespace asynchost // Open an outbound client connection for `id` (loop thread). TLS client // handshake is driven by the normal epoll state machine (is_client). - void do_connect(int64_t id, const std::string& host, const std::string& port) + void do_connect( + int64_t id, + const std::string& host, + const std::string& port, + const ConfigureClientSSL& configure) { - if (client_ctx == nullptr) - { - client_ctx = SSL_CTX_new(TLS_client_method()); - if (client_ctx == nullptr) + auto fail = [&]() { + if (on_close) { - logf("client SSL_CTX_new failed"); - if (on_close) - { - on_close(static_cast(id)); - } - return; + on_close(static_cast(id)); } - SSL_CTX_set_min_proto_version(client_ctx, TLS1_2_VERSION); - // TODO: wire CA verification for outbound (create_client cert) before - // production; currently the peer is not verified here. - SSL_CTX_set_verify(client_ctx, SSL_VERIFY_NONE, nullptr); - } + }; addrinfo hints{}; hints.ai_family = AF_UNSPEC; @@ -687,10 +690,7 @@ namespace asynchost if (getaddrinfo(host.c_str(), port.c_str(), &hints, &res) != 0) { logf("getaddrinfo(%s:%s) failed", host.c_str(), port.c_str()); - if (on_close) - { - on_close(static_cast(id)); - } + fail(); return; } @@ -699,10 +699,7 @@ namespace asynchost if (cfd < 0) { freeaddrinfo(res); - if (on_close) - { - on_close(static_cast(id)); - } + fail(); return; } const int rc = ::connect(cfd, res->ai_addr, res->ai_addrlen); @@ -710,37 +707,66 @@ namespace asynchost if (rc != 0 && errno != EINPROGRESS) { ::close(cfd); - if (on_close) - { - on_close(static_cast(id)); - } + fail(); return; } - SSL* ssl = SSL_new(client_ctx); + // Per-connection client context so each client session can present its + // own certificate and trust its own CA. + SSL_CTX* cctx = SSL_CTX_new(TLS_client_method()); + if (cctx == nullptr) + { + ::close(cfd); + fail(); + return; + } + SSL_CTX_set_min_proto_version(cctx, TLS1_2_VERSION); + + SSL* ssl = SSL_new(cctx); if (ssl == nullptr) { + SSL_CTX_free(cctx); ::close(cfd); - if (on_close) - { - on_close(static_cast(id)); - } + fail(); return; } SSL_set_mode( ssl, SSL_MODE_ENABLE_PARTIAL_WRITE | SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); SSL_set_connect_state(ssl); + + if (configure) + { + try + { + configure(ssl, cctx); + } + catch (const std::exception& e) + { + logf("client TLS configuration failed: %s", e.what()); + SSL_free(ssl); + SSL_CTX_free(cctx); + ::close(cfd); + fail(); + return; + } + } + else + { + SSL_CTX_set_verify(cctx, SSL_VERIFY_NONE, nullptr); + } + if (SSL_set_fd(ssl, cfd) != 1) { SSL_free(ssl); + SSL_CTX_free(cctx); ::close(cfd); - if (on_close) - { - on_close(static_cast(id)); - } + fail(); return; } + // The SSL holds a reference to the context, so releasing our handle now + // is safe; the context is freed when the SSL is. + SSL_CTX_free(cctx); auto c = std::make_unique(); c->fd = cfd; @@ -755,10 +781,7 @@ namespace asynchost { SSL_free(ssl); ::close(cfd); - if (on_close) - { - on_close(static_cast(id)); - } + fail(); return; } conns.emplace(cfd, std::move(c)); @@ -990,12 +1013,17 @@ namespace asynchost } // Thread-safe. Open an outbound client (TLS) connection bound to `id`. + // `configure` sets up peer verification / client certificate on the new + // connection (see ConfigureClientSSL). void connect( - int64_t id, const std::string& host, const std::string& port) + int64_t id, + const std::string& host, + const std::string& port, + ConfigureClientSSL configure = {}) { { std::lock_guard g(out_mutex); - pending_connects.push_back({id, host, port}); + pending_connects.push_back({id, host, port, std::move(configure)}); } wake(); } @@ -1072,11 +1100,6 @@ namespace asynchost SSL_CTX_free(ctx); ctx = nullptr; } - if (client_ctx != nullptr) - { - SSL_CTX_free(client_ctx); - client_ctx = nullptr; - } } }; } diff --git a/src/host/tls/openssl_session_manager.h b/src/host/tls/openssl_session_manager.h index 3f92b7acfc04..5d7f901ea63e 100644 --- a/src/host/tls/openssl_session_manager.h +++ b/src/host/tls/openssl_session_manager.h @@ -161,10 +161,16 @@ namespace asynchost } // Open an outbound client connection bound to `id` (thread-safe). + // `configure` sets up TLS verification / client certificate on the + // connection. void connect( - ::tcp::ConnID id, const std::string& host, const std::string& service) + ::tcp::ConnID id, + const std::string& host, + const std::string& service, + OpenSSLServer::ConfigureClientSSL configure = {}) { - server->connect(static_cast(id), host, service); + server->connect( + static_cast(id), host, service, std::move(configure)); } void start() From 82fc3413112a42748281f83486d87d921968cc63 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Thu, 25 Jun 2026 10:17:14 +0000 Subject: [PATCH 06/59] Listener resolves bind address via getaddrinfo (hostnames + IPv6), fixing localhost/[::1] interfaces (cpp, cpp_cose_only, common_ipv6 e2e). Add localhost/IPv6 binding tests. --- src/host/test/openssl_server_test.cpp | 103 +++++++++++++++++++++++++- src/host/tls/openssl_server.h | 71 ++++++++++++------ 2 files changed, 148 insertions(+), 26 deletions(-) diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp index 4ab6c0dbd02d..313b8bbac1db 100644 --- a/src/host/test/openssl_server_test.cpp +++ b/src/host/test/openssl_server_test.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -137,12 +138,15 @@ namespace { std::unique_ptr server; - EchoServer(const std::string& cert, const std::string& key) + EchoServer( + const std::string& cert, + const std::string& key, + const std::string& host = "127.0.0.1") { server = std::make_unique( cert, key, - "127.0.0.1", + host, static_cast(0), [this](uint64_t id, std::vector d) { server->send(id, d.data(), d.size()); @@ -372,3 +376,98 @@ TEST_CASE("Peer certificate is captured for inbound connections") mgr.stop(); } + +namespace +{ + // Connect to host:port (resolved via getaddrinfo, any family), TLS round-trip. + std::vector tls_echo_roundtrip( + const std::string& host, uint16_t port, const std::vector& msg) + { + addrinfo hints{}; + hints.ai_socktype = SOCK_STREAM; + addrinfo* res = nullptr; + REQUIRE( + getaddrinfo(host.c_str(), std::to_string(port).c_str(), &hints, &res) == + 0); + int fd = -1; + for (addrinfo* ai = res; ai != nullptr; ai = ai->ai_next) + { + fd = ::socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); + if (fd < 0) + { + continue; + } + if (::connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) + { + break; + } + ::close(fd); + fd = -1; + } + freeaddrinfo(res); + REQUIRE(fd >= 0); + + SSL_CTX* cctx = SSL_CTX_new(TLS_client_method()); + REQUIRE(cctx != nullptr); + SSL* ssl = SSL_new(cctx); + REQUIRE(SSL_set_fd(ssl, fd) == 1); + SSL_set_connect_state(ssl); + REQUIRE(SSL_connect(ssl) == 1); + + size_t off = 0; + while (off < msg.size()) + { + const int n = + SSL_write(ssl, msg.data() + off, static_cast(msg.size() - off)); + REQUIRE(n > 0); + off += static_cast(n); + } + + std::vector resp(msg.size()); + size_t roff = 0; + while (roff < resp.size()) + { + const int n = SSL_read( + ssl, resp.data() + roff, static_cast(resp.size() - roff)); + if (n <= 0) + { + break; + } + roff += static_cast(n); + } + + SSL_shutdown(ssl); + SSL_free(ssl); + SSL_CTX_free(cctx); + ::close(fd); + return resp; + } +} + +TEST_CASE("Listener binds a hostname (localhost)") +{ + auto [cert, key] = make_server_cert(); + EchoServer s(cert, key, "localhost"); + REQUIRE(s.port() != 0); + + const std::vector msg = {'l', 'o', 'c', 'a', 'l'}; + REQUIRE(tls_echo_roundtrip("localhost", s.port(), msg) == msg); +} + +TEST_CASE("Listener binds IPv6 loopback when available") +{ + auto [cert, key] = make_server_cert(); + std::unique_ptr s; + try + { + s = std::make_unique(cert, key, "::1"); + } + catch (const std::exception&) + { + MESSAGE("IPv6 loopback unavailable in this environment - skipping"); + return; + } + + const std::vector msg = {'v', '6'}; + REQUIRE(tls_echo_roundtrip("::1", s->port(), msg) == msg); +} diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index 56ca167bdc4f..4550cbdda9a6 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -478,7 +478,7 @@ namespace asynchost { for (;;) { - sockaddr_in peer{}; + sockaddr_storage peer{}; socklen_t plen = sizeof(peer); const int cfd = accept4( listen_fd, @@ -871,32 +871,47 @@ namespace asynchost } } - listen_fd = socket(AF_INET, SOCK_STREAM, 0); - if (listen_fd < 0) + // Resolve and bind the listening address. Using getaddrinfo (rather than + // inet_pton) supports hostnames (e.g. "localhost") and IPv6 (e.g. "::1"), + // not just IPv4 literals. + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + hints.ai_flags = AI_PASSIVE; + addrinfo* res = nullptr; + const std::string port_str = std::to_string(port); + if (getaddrinfo(host.c_str(), port_str.c_str(), &hints, &res) != 0) { cleanup(); - throw std::runtime_error("socket() failed"); + throw std::runtime_error("getaddrinfo failed for " + host); } - const int one = 1; - setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); - // SO_REUSEPORT is the idiom that will let each worker run its own - // listening socket + epoll loop in the production design. - setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)); - sockaddr_in addr{}; - addr.sin_family = AF_INET; - addr.sin_port = htons(port); - if (inet_pton(AF_INET, host.c_str(), &addr.sin_addr) != 1) + const int one = 1; + bool bound_ok = false; + for (addrinfo* ai = res; ai != nullptr; ai = ai->ai_next) { - cleanup(); - throw std::runtime_error("inet_pton failed"); + listen_fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); + if (listen_fd < 0) + { + continue; + } + setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + // SO_REUSEPORT is the idiom that will let each worker run its own + // listening socket + epoll loop in the production design. + setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)); + if (bind(listen_fd, ai->ai_addr, ai->ai_addrlen) == 0) + { + bound_ok = true; + break; + } + ::close(listen_fd); + listen_fd = -1; } - if ( - bind( - listen_fd, reinterpret_cast(&addr), sizeof(addr)) != 0) + freeaddrinfo(res); + if (!bound_ok) { cleanup(); - throw std::runtime_error("bind() failed"); + throw std::runtime_error("bind() failed for " + host); } if (listen(listen_fd, SOMAXCONN) != 0) { @@ -909,14 +924,22 @@ namespace asynchost throw std::runtime_error("set_nonblocking(listen) failed"); } - // Read back the actual bound port (supports ephemeral port 0). - sockaddr_in bound{}; + // Read back the actual bound port (supports ephemeral port 0, v4 and v6). + sockaddr_storage bound{}; socklen_t blen = sizeof(bound); if ( - getsockname( - listen_fd, reinterpret_cast(&bound), &blen) == 0) + getsockname(listen_fd, reinterpret_cast(&bound), &blen) == + 0) { - bound_port = ntohs(bound.sin_port); + if (bound.ss_family == AF_INET6) + { + bound_port = + ntohs(reinterpret_cast(&bound)->sin6_port); + } + else + { + bound_port = ntohs(reinterpret_cast(&bound)->sin_port); + } } epoll_fd = epoll_create1(0); From bbb9d3ba813afb83d364acfb8568d4bdc8126db7 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Thu, 25 Jun 2026 11:06:06 +0000 Subject: [PATCH 07/59] Graceful connection close: flush buffered output before teardown so a large response queued just before close_socket() is not truncated (fixes cpp/cpp_cose_only receipt 'server disconnected'). Add truncation test. --- src/host/test/openssl_server_test.cpp | 47 +++++++++++++++++++++++++++ src/host/tls/openssl_server.h | 39 ++++++++++++++++++++-- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp index 313b8bbac1db..d3454b5125f4 100644 --- a/src/host/test/openssl_server_test.cpp +++ b/src/host/test/openssl_server_test.cpp @@ -188,6 +188,30 @@ namespace writer.close_socket(id); } }; + + // Writes a (large) response then immediately closes - reproduces the close- + // truncation bug: without graceful close, the buffered response is discarded. + struct LargeThenCloseSession : public ccf::Session + { + ::tcp::ConnID id; + ccf::SessionWriter& writer; + std::vector payload; + + LargeThenCloseSession( + ::tcp::ConnID id_, ccf::SessionWriter& w, std::vector p) : + id(id_), writer(w), payload(std::move(p)) + {} + + void handle_incoming_data( + std::span /*data*/, sockaddr /*addr*/ = {}) override + { + writer.write_outbound(id, payload); + writer.close_socket(id); + } + + void send_data(std::vector&& /*data*/) override {} + void close_session() override {} + }; } TEST_CASE("TLS handshake and small round-trip") @@ -471,3 +495,26 @@ TEST_CASE("Listener binds IPv6 loopback when available") const std::vector msg = {'v', '6'}; REQUIRE(tls_echo_roundtrip("::1", s->port(), msg) == msg); } + +TEST_CASE("Graceful close flushes buffered response without truncation") +{ + auto [cert, key] = make_server_cert(); + const auto payload = random_bytes(4 * 1024 * 1024); + + OpenSSLSessionManager mgr( + cert, + key, + "127.0.0.1", + static_cast(0), + [&payload](::tcp::ConnID id, ccf::SessionWriter& w, std::vector) { + return std::make_shared(id, w, payload); + }); + mgr.start(); + + const std::vector req = {'g', 'o'}; + const auto resp = tls_client_exchange(mgr.port(), req, payload.size()); + REQUIRE(resp.size() == payload.size()); + REQUIRE(resp == payload); + + mgr.stop(); +} diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index 4550cbdda9a6..15caac4686ce 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -92,6 +92,9 @@ namespace asynchost // True when progress needs the socket to become writable (handshake // wants write, or there is buffered outbound data). bool want_write = false; + // A close was requested but is deferred until the buffered output has been + // fully written, so a response queued just before close is not truncated. + bool close_after_flush = false; }; SSL_CTX* ctx = nullptr; @@ -251,6 +254,21 @@ namespace asynchost epoll_ctl(epoll_fd, EPOLL_CTL_MOD, c.fd, &ev); } + // After writing, tear the connection down if a graceful close was requested + // and all buffered output has been flushed; otherwise update epoll + // interest (re-arming EPOLLOUT while output remains). + void finish_or_close(int fd, Conn& c) + { + if (c.close_after_flush && c.out_off >= c.outbuf.size()) + { + close_conn(fd); + } + else + { + update_interest(c); + } + } + static int alpn_select_cb( SSL* /*ssl*/, const unsigned char** out, @@ -587,7 +605,7 @@ namespace asynchost close_conn(fd); return; } - update_interest(c); + finish_or_close(fd, c); } void wake() const @@ -649,7 +667,22 @@ namespace asynchost const int fd = fit->second; if (item.close) { - close_conn(fd); + auto cit = conns.find(fd); + if (cit == conns.end()) + { + continue; + } + Conn& c = *cit->second; + // Graceful close: flush any buffered response before tearing the + // connection down, so a large response queued just before + // close_socket() is not truncated. + c.close_after_flush = true; + if (!do_write(c)) + { + close_conn(fd); + continue; + } + finish_or_close(fd, c); continue; } auto cit = conns.find(fd); @@ -664,7 +697,7 @@ namespace asynchost close_conn(fd); continue; } - update_interest(c); + finish_or_close(fd, c); } } From 8d8197958c3e972b8b80455f12994363d3960ef1 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Thu, 25 Jun 2026 13:26:25 +0000 Subject: [PATCH 08/59] Fix spurious connection closes: clear OpenSSL thread-local error queue (ERR_clear_error) before each SSL op so a stale error from one connection cannot poison SSL_get_error for another (root cause of cpp/cpp_cose_only 'server disconnected'). Add persistent-connection + peer-cert tests. --- src/host/test/openssl_server_test.cpp | 50 +++++++++++++++++++++++++++ src/host/tls/openssl_server.h | 12 +++++-- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp index d3454b5125f4..d741616d1cd2 100644 --- a/src/host/test/openssl_server_test.cpp +++ b/src/host/test/openssl_server_test.cpp @@ -518,3 +518,53 @@ TEST_CASE("Graceful close flushes buffered response without truncation") mgr.stop(); } + +// Multiple sequential requests on a single kept-alive TLS connection - the node +// must not drop the connection between requests (regression for the e2e +// "Server disconnected" after a few requests on an idle keep-alive connection). +TEST_CASE("Persistent connection survives many sequential round-trips") +{ + auto [cert, key] = make_server_cert(); + EchoServer s(cert, key); + REQUIRE(s.port() != 0); + + const int fd = ::socket(AF_INET, SOCK_STREAM, 0); + REQUIRE(fd >= 0); + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(s.port()); + REQUIRE(inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) == 1); + REQUIRE(::connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0); + + SSL_CTX* cctx = SSL_CTX_new(TLS_client_method()); + SSL* ssl = SSL_new(cctx); + REQUIRE(SSL_set_fd(ssl, fd) == 1); + SSL_set_connect_state(ssl); + REQUIRE(SSL_connect(ssl) == 1); + + for (int i = 0; i < 10; ++i) + { + const std::vector msg = { + 'r', static_cast('0' + (i % 10))}; + REQUIRE(SSL_write(ssl, msg.data(), static_cast(msg.size())) == 2); + + std::vector resp(msg.size()); + size_t off = 0; + while (off < resp.size()) + { + const int n = + SSL_read(ssl, resp.data() + off, static_cast(resp.size() - off)); + REQUIRE(n > 0); + off += static_cast(n); + } + REQUIRE(resp == msg); + + // Idle a moment between requests, as the e2e client does (sleep(0.5)). + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + + SSL_shutdown(ssl); + SSL_free(ssl); + SSL_CTX_free(cctx); + ::close(fd); +} diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index 15caac4686ce..8a063762226c 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -302,6 +302,11 @@ namespace asynchost // Returns false if the connection should be closed. bool do_handshake(Conn& c) { + // The error queue is thread-local and shared across every connection + // serviced by this loop, and SSL_get_error() consults it. Clear it before + // each SSL operation so a stale error from another connection cannot be + // misattributed (which would spuriously close healthy connections). + ERR_clear_error(); const int r = c.is_client ? SSL_connect(c.ssl) : SSL_accept(c.ssl); if (r == 1) { @@ -400,6 +405,7 @@ namespace asynchost for (;;) { uint8_t buf[read_chunk]; + ERR_clear_error(); const int n = SSL_read(c.ssl, buf, static_cast(sizeof(buf))); if (n > 0) { @@ -421,8 +427,8 @@ namespace asynchost c.want_write = true; return true; } - // SSL_ERROR_ZERO_RETURN (clean close) or a fatal error. - logf("conn %llu: read closed/err %d", (unsigned long long)c.id, e); + // SSL_ERROR_ZERO_RETURN (clean close), an unclean EOF from the peer + // (no close_notify), or a fatal error - in all cases close. return false; } } @@ -437,6 +443,7 @@ namespace asynchost } while (c.out_off < c.outbuf.size()) { + ERR_clear_error(); const int n = SSL_write( c.ssl, c.outbuf.data() + c.out_off, @@ -582,7 +589,6 @@ namespace asynchost return; } Conn& c = *it->second; - bool alive = true; if (c.state == Conn::Handshaking) { From 592fc5fed4aea7185d645854be8f722cd5e84b76 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Thu, 25 Jun 2026 16:34:06 +0000 Subject: [PATCH 09/59] Restore UDP echo via DatagramServer (UDP socket in epoll loop) + echo handler; branch udp interfaces to listen_udp. Clearly marked QUIC extension points (substrate for OpenSSL >=3.5 native QUIC). e2e_logging udp echo passes; full suite green. --- src/enclave/enclave.h | 6 +- src/host/datagram_server.h | 313 ++++++++++++++++++++++++++++++ src/host/rpc_connection_manager.h | 45 +++++ 3 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 src/host/datagram_server.h diff --git a/src/enclave/enclave.h b/src/enclave/enclave.h index ce9936558a4f..e99d8323b560 100644 --- a/src/enclave/enclave.h +++ b/src/enclave/enclave.h @@ -214,7 +214,11 @@ namespace ccf { const auto [host, port] = ccf::split_net_address(interface.bind_address); - const uint16_t bound = rpcsessions->listen(name, host, port); + // UDP interfaces use the datagram (echo) path; TCP interfaces the + // OpenSSL stream path. + const uint16_t bound = (interface.protocol == "udp") ? + rpcsessions->listen_udp(name, host, port) : + rpcsessions->listen(name, host, port); interface.bind_address = ccf::make_net_address(host, std::to_string(bound)); diff --git a/src/host/datagram_server.h b/src/host/datagram_server.h new file mode 100644 index 000000000000..3f7170a30cf1 --- /dev/null +++ b/src/host/datagram_server.h @@ -0,0 +1,313 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +// A minimal UDP datagram server: it owns a SOCK_DGRAM socket in its own epoll +// loop and delivers each received datagram to a handler, which may reply to the +// sender. This restores the pre-cutover plaintext "UDP echo" stub (the only +// goal here is behavioural compatibility, i.e. the existing udp echo test). +// +// =========================================================================== +// QUIC EXTENSION POINT +// --------------------------------------------------------------------------- +// This is deliberately the substrate a future OpenSSL-native QUIC server would +// build on. The UDP socket created and bound here is exactly the datagram +// socket OpenSSL QUIC operates on. The pieces that change for QUIC are marked +// "QUIC EXTENSION POINT" inline; the socket creation, binding, epoll loop and +// lifecycle below are unchanged by that switch. +// +// To become a QUIC server (needs OpenSSL >= 3.5, which adds SSL_new_listener / +// SSL_accept_connection / OSSL_QUIC_server_method - absent in the 3.3.x we +// build against today): +// * wrap `sock` with BIO_new_dgram()/SSL_set_fd() on a QUIC listener SSL +// (OSSL_QUIC_server_method + SSL_new_listener); +// * epoll the descriptor returned by SSL_get_rpoll_descriptor() (it is this +// same UDP fd) plus an SSL_get_event_timeout() timer, instead of `sock` +// directly; +// * on readability/timeout call SSL_handle_events(), then +// SSL_accept_connection()/SSL_accept_stream()/SSL_read_ex(), and reply with +// SSL_write_ex() on a stream rather than the raw sendto() below. +// The event-driven integration primitives (SSL_handle_events, +// SSL_get_rpoll_descriptor, SSL_get_event_timeout, BIO_new_dgram, +// SSL_set1_initial_peer_addr) already exist in 3.3.x - only the server-side +// listener/accept is missing. +// =========================================================================== + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace asynchost +{ + class DatagramServer + { + public: + // Reply to the sender of the datagram currently being handled. + using Reply = std::function; + + // Invoked on the loop thread for each received datagram. + using OnDatagram = + std::function; + + private: + // Max UDP payload (theoretical IPv4 limit); datagrams are read whole. + static constexpr size_t max_datagram = 65535; + + int sock = -1; + int epoll_fd = -1; + int stop_fd = -1; + uint16_t bound_port = 0; + OnDatagram on_datagram; + + std::thread loop_thread; + std::atomic running{false}; + + static bool set_nonblocking(int fd) + { + const int flags = fcntl(fd, F_GETFL, 0); + if (flags < 0) + { + return false; + } + return fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0; + } + + void drain() + { + for (;;) + { + uint8_t buf[max_datagram]; + sockaddr_storage peer{}; + socklen_t peerlen = sizeof(peer); + const ssize_t n = ::recvfrom( + sock, + buf, + sizeof(buf), + 0, + reinterpret_cast(&peer), + &peerlen); + if (n < 0) + { + if (errno == EAGAIN || errno == EWOULDBLOCK) + { + break; + } + if (errno == EINTR) + { + continue; + } + break; + } + + if (on_datagram) + { + // === QUIC EXTENSION POINT === + // A QUIC server would not reply with a raw sendto; it would feed the + // received bytes to OpenSSL (SSL_handle_events) and write responses + // with SSL_write_ex() on accepted streams. `peer` is the source + // address that SSL_set1_initial_peer_addr() consumes. + Reply reply = [this, &peer, peerlen](const uint8_t* d, size_t l) { + ::sendto( + sock, + d, + l, + 0, + reinterpret_cast(&peer), + peerlen); + }; + on_datagram(buf, static_cast(n), reply); + } + } + } + + void run() + { + constexpr int max_events = 8; + std::vector events(max_events); + while (running.load()) + { + const int n = epoll_wait(epoll_fd, events.data(), max_events, -1); + if (n < 0) + { + if (errno == EINTR) + { + continue; + } + break; + } + for (int i = 0; i < n; ++i) + { + const int fd = events[i].data.fd; + if (fd == stop_fd) + { + running.store(false); + break; + } + if (fd == sock) + { + // === QUIC EXTENSION POINT === + // For QUIC this becomes SSL_handle_events() on the listener. + drain(); + } + } + } + } + + public: + DatagramServer( + const std::string& host, uint16_t port, OnDatagram on_datagram_) : + on_datagram(std::move(on_datagram_)) + { + // Resolve + bind the datagram address (getaddrinfo supports hostnames and + // IPv6, matching the TCP listener). + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_DGRAM; + hints.ai_flags = AI_PASSIVE; + addrinfo* res = nullptr; + const std::string port_str = std::to_string(port); + if (getaddrinfo(host.c_str(), port_str.c_str(), &hints, &res) != 0) + { + throw std::runtime_error("getaddrinfo (udp) failed for " + host); + } + + const int one = 1; + bool bound = false; + for (addrinfo* ai = res; ai != nullptr; ai = ai->ai_next) + { + sock = ::socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); + if (sock < 0) + { + continue; + } + setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + setsockopt(sock, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)); + if (::bind(sock, ai->ai_addr, ai->ai_addrlen) == 0) + { + bound = true; + break; + } + ::close(sock); + sock = -1; + } + freeaddrinfo(res); + if (!bound) + { + cleanup(); + throw std::runtime_error("bind (udp) failed for " + host); + } + if (!set_nonblocking(sock)) + { + cleanup(); + throw std::runtime_error("set_nonblocking (udp) failed"); + } + + // Read back the actual bound port (supports ephemeral port 0, v4 and v6). + sockaddr_storage b{}; + socklen_t blen = sizeof(b); + if (getsockname(sock, reinterpret_cast(&b), &blen) == 0) + { + bound_port = (b.ss_family == AF_INET6) ? + ntohs(reinterpret_cast(&b)->sin6_port) : + ntohs(reinterpret_cast(&b)->sin_port); + } + + epoll_fd = epoll_create1(0); + if (epoll_fd < 0) + { + cleanup(); + throw std::runtime_error("epoll_create1 (udp) failed"); + } + stop_fd = eventfd(0, EFD_NONBLOCK); + if (stop_fd < 0) + { + cleanup(); + throw std::runtime_error("eventfd (udp) failed"); + } + + epoll_event ev{}; + ev.events = EPOLLIN; + ev.data.fd = sock; + epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sock, &ev); + ev.data.fd = stop_fd; + epoll_ctl(epoll_fd, EPOLL_CTL_ADD, stop_fd, &ev); + } + + DatagramServer(const DatagramServer&) = delete; + DatagramServer& operator=(const DatagramServer&) = delete; + DatagramServer(DatagramServer&&) = delete; + DatagramServer& operator=(DatagramServer&&) = delete; + + ~DatagramServer() + { + stop(); + cleanup(); + } + + void start() + { + running.store(true); + loop_thread = std::thread([this]() { run(); }); + } + + void stop() + { + if (!running.exchange(false)) + { + if (loop_thread.joinable()) + { + loop_thread.join(); + } + return; + } + if (stop_fd >= 0) + { + const uint64_t one = 1; + [[maybe_unused]] auto w = ::write(stop_fd, &one, sizeof(one)); + } + if (loop_thread.joinable()) + { + loop_thread.join(); + } + } + + uint16_t port() const + { + return bound_port; + } + + private: + void cleanup() + { + if (stop_fd >= 0) + { + ::close(stop_fd); + stop_fd = -1; + } + if (epoll_fd >= 0) + { + ::close(epoll_fd); + epoll_fd = -1; + } + if (sock >= 0) + { + ::close(sock); + sock = -1; + } + } + }; +} diff --git a/src/host/rpc_connection_manager.h b/src/host/rpc_connection_manager.h index 26336db9e5fc..2413f37a538f 100644 --- a/src/host/rpc_connection_manager.h +++ b/src/host/rpc_connection_manager.h @@ -23,6 +23,7 @@ #include "enclave/abstract_rpc_sessions.h" #include "enclave/no_more_sessions.h" #include "enclave/rpc_map.h" +#include "host/datagram_server.h" #include "host/tls/openssl_session_manager.h" #include "http/error_reporter.h" #include "http/http2_session.h" @@ -77,6 +78,11 @@ namespace ccf std::mutex interfaces_mutex; std::map> interfaces; + // Plaintext UDP echo servers, keyed by interface name. The pre-cutover UDP + // "QUIC" interface was a plaintext echo stub; this preserves that until + // OpenSSL-native QUIC is available (see host/datagram_server.h). + std::map> + udp_servers; // cert/key PEM per endorsement authority (for cert-deferred listening). std::map> certs; @@ -229,6 +235,10 @@ namespace ccf li->bridge->stop(); } } + for (auto& [name, server] : udp_servers) + { + server->stop(); + } } // Bind and start listening on `name` (which must have been configured via @@ -296,6 +306,35 @@ namespace ccf return li->bridge->port(); } + // Bind and start a plaintext UDP echo server for `name`. This preserves the + // pre-cutover UDP echo stub (interfaces with protocol "udp"). + // + // === QUIC EXTENSION POINT === + // A real QUIC interface would, instead of echoing, hand each datagram to an + // OpenSSL QUIC listener (OpenSSL >= 3.5). The DatagramServer below is the + // shared substrate for that (see host/datagram_server.h). + uint16_t listen_udp( + const std::string& name, + const std::string& host, + const std::string& port) + { + std::lock_guard guard(interfaces_mutex); + auto server = std::make_unique( + host, + static_cast(std::stoi(port)), + []( + const uint8_t* data, + size_t len, + const asynchost::DatagramServer::Reply& reply) { + // Echo the datagram back to its sender. + reply(data, len); + }); + server->start(); + const uint16_t bound = server->port(); + udp_servers.emplace(name, std::move(server)); + return bound; + } + // ----- AbstractRPCSessions / AbstractRPCResponder ----------------------- std::shared_ptr create_client( @@ -457,6 +496,12 @@ namespace ccf std::lock_guard guard(interfaces_mutex); for (const auto& [name, interface] : node_info.rpc_interfaces) { + // UDP interfaces use the datagram echo path (listen_udp), not the TCP + // session machinery. + if (interface.protocol == "udp") + { + continue; + } auto it = interfaces.find(name); if (it == interfaces.end()) { From d9f0a51f3a38bc152fccaf7e208bff425065f87d Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Fri, 26 Jun 2026 08:54:06 +0000 Subject: [PATCH 10/59] Format pass --- src/enclave/abstract_rpc_sessions.h | 4 +-- src/enclave/enclave.h | 2 +- src/host/datagram_server.h | 7 +--- src/host/rpc_connection_manager.h | 44 +++++++++++++------------- src/host/test/openssl_server_test.cpp | 31 ++++++++++-------- src/host/tls/openssl_server.h | 41 ++++++++++++------------ src/host/tls/openssl_session_manager.h | 10 +++--- src/node/node_state.h | 4 +-- 8 files changed, 70 insertions(+), 73 deletions(-) diff --git a/src/enclave/abstract_rpc_sessions.h b/src/enclave/abstract_rpc_sessions.h index 4babaf4519c2..3aa05c0a3075 100644 --- a/src/enclave/abstract_rpc_sessions.h +++ b/src/enclave/abstract_rpc_sessions.h @@ -24,8 +24,8 @@ namespace ccf // The slice of RPC session management that the node (NodeState, frontends, // Enclave, jwt refresh) depends on, independent of how connections are // actually serviced. Both the legacy RPCSessions (ringbuffer/host-split) and - // the new host-side RPCConnectionManager implement this, so node-side code can - // hold a reference without depending on the concrete networking backend. + // the new host-side RPCConnectionManager implement this, so node-side code + // can hold a reference without depending on the concrete networking backend. class AbstractRPCSessions : public AbstractRPCResponder { public: diff --git a/src/enclave/enclave.h b/src/enclave/enclave.h index e99d8323b560..76950b8cca82 100644 --- a/src/enclave/enclave.h +++ b/src/enclave/enclave.h @@ -11,6 +11,7 @@ #include "ds/oversized.h" #include "ds/work_beacon.h" #include "host/ledger.h" +#include "host/rpc_connection_manager.h" #include "indexing/enclave_lfs_access.h" #include "indexing/historical_transaction_fetcher.h" #include "interface.h" @@ -33,7 +34,6 @@ #include "node/rpc/node_operation.h" #include "node/rpc/user_frontend.h" #include "node/signature_cache_subsystem.h" -#include "host/rpc_connection_manager.h" #include "rpc_map.h" #include "tasks/worker.h" diff --git a/src/host/datagram_server.h b/src/host/datagram_server.h index 3f7170a30cf1..b5a63262a877 100644 --- a/src/host/datagram_server.h +++ b/src/host/datagram_server.h @@ -122,12 +122,7 @@ namespace asynchost // address that SSL_set1_initial_peer_addr() consumes. Reply reply = [this, &peer, peerlen](const uint8_t* d, size_t l) { ::sendto( - sock, - d, - l, - 0, - reinterpret_cast(&peer), - peerlen); + sock, d, l, 0, reinterpret_cast(&peer), peerlen); }; on_datagram(buf, static_cast(n), reply); } diff --git a/src/host/rpc_connection_manager.h b/src/host/rpc_connection_manager.h index 2413f37a538f..7333431540fb 100644 --- a/src/host/rpc_connection_manager.h +++ b/src/host/rpc_connection_manager.h @@ -99,7 +99,8 @@ namespace ccf } // Build the protocol session for a connection on `li`, applying caps. - // Returns nullptr to refuse (hard cap). Runs on the interface's loop thread. + // Returns nullptr to refuse (hard cap). Runs on the interface's loop + // thread. std::shared_ptr make_session( ListenInterface* li, ::tcp::ConnID conn_id, @@ -245,16 +246,14 @@ namespace ccf // update_listening_interface_options). Returns the bound port (supports // ephemeral port 0), or 0 on failure. uint16_t listen( - const std::string& name, - const std::string& host, - const std::string& port) + const std::string& name, const std::string& host, const std::string& port) { std::lock_guard guard(interfaces_mutex); auto it = interfaces.find(name); if (it == interfaces.end()) { - throw std::logic_error(fmt::format( - "Cannot listen on unconfigured interface '{}'", name)); + throw std::logic_error( + fmt::format("Cannot listen on unconfigured interface '{}'", name)); } auto* li = it->second.get(); @@ -277,9 +276,7 @@ namespace ccf auto factory = [this, li]( - ::tcp::ConnID cid, - ccf::SessionWriter& w, - std::vector pc) { + ::tcp::ConnID cid, ccf::SessionWriter& w, std::vector pc) { return make_session(li, cid, w, std::move(pc)); }; auto on_closed = [li](::tcp::ConnID) { @@ -314,9 +311,7 @@ namespace ccf // OpenSSL QUIC listener (OpenSSL >= 3.5). The DatagramServer below is the // shared substrate for that (see host/datagram_server.h). uint16_t listen_udp( - const std::string& name, - const std::string& host, - const std::string& port) + const std::string& name, const std::string& host, const std::string& port) { std::lock_guard guard(interfaces_mutex); auto server = std::make_unique( @@ -358,9 +353,13 @@ namespace ccf // optionally, this node's client certificate to present. It configures // the outbound SSL when the connection is opened. auto connect_cb = - [bridge, cert](int64_t cid, const std::string& h, const std::string& s) { + [bridge, + cert](int64_t cid, const std::string& h, const std::string& s) { bridge->connect( - static_cast<::tcp::ConnID>(cid), h, s, [cert](SSL* ssl, SSL_CTX* ctx) { + static_cast<::tcp::ConnID>(cid), + h, + s, + [cert](SSL* ssl, SSL_CTX* ctx) { if (cert != nullptr) { cert->use(ssl, ctx); @@ -372,15 +371,15 @@ namespace ccf std::shared_ptr as_session; if (app_protocol == "HTTP2") { - auto s = std::make_shared<::http::HTTP2ClientSession>( - id, *bridge, connect_cb); + auto s = + std::make_shared<::http::HTTP2ClientSession>(id, *bridge, connect_cb); session = s; as_session = s; } else { - auto s = std::make_shared<::http::HTTPClientSession>( - id, *bridge, connect_cb); + auto s = + std::make_shared<::http::HTTPClientSession>(id, *bridge, connect_cb); session = s; as_session = s; } @@ -390,8 +389,9 @@ namespace ccf } bool reply_async( - int64_t id, bool terminate_after_reply, std::vector&& data) - override + int64_t id, + bool terminate_after_reply, + std::vector&& data) override { std::vector bridges; { @@ -505,8 +505,8 @@ namespace ccf auto it = interfaces.find(name); if (it == interfaces.end()) { - it = interfaces.emplace(name, std::make_unique()) - .first; + it = + interfaces.emplace(name, std::make_unique()).first; it->second->name = name; } auto* li = it->second.get(); diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp index d741616d1cd2..c9902f9c77ff 100644 --- a/src/host/test/openssl_server_test.cpp +++ b/src/host/test/openssl_server_test.cpp @@ -6,11 +6,10 @@ // exercises handshake, plaintext round-trip, large transfers (backpressure // path) and concurrent connections. -#include "host/tls/openssl_server.h" - #include "ccf/crypto/ec_key_pair.h" #include "ccf/ds/x509_time_fmt.h" #include "crypto/certs.h" +#include "host/tls/openssl_server.h" #include "host/tls/openssl_session_manager.h" #define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN @@ -20,8 +19,8 @@ #include #include #include -#include #include +#include #include #include #include @@ -72,8 +71,8 @@ namespace REQUIRE(cctx != nullptr); if (!client_cert.empty()) { - BIO* cb = - BIO_new_mem_buf(client_cert.data(), static_cast(client_cert.size())); + BIO* cb = BIO_new_mem_buf( + client_cert.data(), static_cast(client_cert.size())); X509* xc = PEM_read_bio_X509(cb, nullptr, nullptr, nullptr); BIO_free(cb); REQUIRE(xc != nullptr); @@ -199,7 +198,9 @@ namespace LargeThenCloseSession( ::tcp::ConnID id_, ccf::SessionWriter& w, std::vector p) : - id(id_), writer(w), payload(std::move(p)) + id(id_), + writer(w), + payload(std::move(p)) {} void handle_incoming_data( @@ -251,8 +252,7 @@ TEST_CASE("Concurrent connections") for (int i = 0; i < num_clients; ++i) { clients.emplace_back([port, i, &ok]() { - const std::vector msg( - 64, static_cast('A' + (i % 26))); + const std::vector msg(64, static_cast('A' + (i % 26))); const auto resp = tls_client_exchange(port, msg, msg.size()); if (resp == msg) { @@ -298,9 +298,11 @@ TEST_CASE("Reply from a worker thread") }); OpenSSLServer server( - cert, key, "127.0.0.1", static_cast(0), [&]( - uint64_t id, - std::vector d) { + cert, + key, + "127.0.0.1", + static_cast(0), + [&](uint64_t id, std::vector d) { { std::lock_guard l(m); q.emplace_back(id, std::move(d)); @@ -403,7 +405,8 @@ TEST_CASE("Peer certificate is captured for inbound connections") namespace { - // Connect to host:port (resolved via getaddrinfo, any family), TLS round-trip. + // Connect to host:port (resolved via getaddrinfo, any family), TLS + // round-trip. std::vector tls_echo_roundtrip( const std::string& host, uint16_t port, const std::vector& msg) { @@ -451,8 +454,8 @@ namespace size_t roff = 0; while (roff < resp.size()) { - const int n = SSL_read( - ssl, resp.data() + roff, static_cast(resp.size() - roff)); + const int n = + SSL_read(ssl, resp.data() + roff, static_cast(resp.size() - roff)); if (n <= 0) { break; diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index 8a063762226c..ec983d31f375 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -19,7 +19,8 @@ // from another thread and wake the loop). // * Level-triggered epoll, for simplicity/correctness over raw throughput. // * No session caps / certs-per-interface / protocol handling - that policy -// is harvested separately. This proves transport + threading + backpressure. +// is harvested separately. This proves transport + threading + +// backpressure. #include #include @@ -84,7 +85,8 @@ namespace asynchost Handshaking, Ready } state = Handshaking; - // Outbound (client) connection: drives SSL_connect rather than SSL_accept. + // Outbound (client) connection: drives SSL_connect rather than + // SSL_accept. bool is_client = false; // Pending plaintext to be encrypted/written; out_off bytes already sent. std::vector outbuf; @@ -92,8 +94,9 @@ namespace asynchost // True when progress needs the socket to become writable (handshake // wants write, or there is buffered outbound data). bool want_write = false; - // A close was requested but is deferred until the buffered output has been - // fully written, so a response queued just before close is not truncated. + // A close was requested but is deferred until the buffered output has + // been fully written, so a response queued just before close is not + // truncated. bool close_after_flush = false; }; @@ -115,9 +118,9 @@ namespace asynchost std::unordered_map> conns; std::unordered_map id_to_fd; uint64_t next_id = 1; - // Optional shared id source so multiple servers (one per interface) allocate - // connection ids from a single global space - required for a global session - // registry and reply routing. + // Optional shared id source so multiple servers (one per interface) + // allocate connection ids from a single global space - required for a + // global session registry and reply routing. std::atomic* shared_next_id = nullptr; // Cross-thread outbound queue: send()/close_connection() append here from @@ -172,9 +175,7 @@ namespace asynchost } static bool load_cert_key( - SSL_CTX* ctx, - const std::string& cert_pem, - const std::string& key_pem) + SSL_CTX* ctx, const std::string& cert_pem, const std::string& key_pem) { BIO* cbio = BIO_new_mem_buf(cert_pem.data(), static_cast(cert_pem.size())); @@ -433,8 +434,9 @@ namespace asynchost } } - // Returns false if the connection should be closed. Implements backpressure: - // a WANT_WRITE leaves the remaining plaintext buffered and arms EPOLLOUT. + // Returns false if the connection should be closed. Implements + // backpressure: a WANT_WRITE leaves the remaining plaintext buffered and + // arms EPOLLOUT. bool do_write(Conn& c) { if (c.ssl == nullptr) @@ -506,10 +508,7 @@ namespace asynchost sockaddr_storage peer{}; socklen_t plen = sizeof(peer); const int cfd = accept4( - listen_fd, - reinterpret_cast(&peer), - &plen, - SOCK_NONBLOCK); + listen_fd, reinterpret_cast(&peer), &plen, SOCK_NONBLOCK); if (cfd < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) @@ -526,8 +525,8 @@ namespace asynchost auto c = std::make_unique(); c->fd = cfd; - c->id = - (shared_next_id != nullptr) ? shared_next_id->fetch_add(1) : next_id++; + c->id = (shared_next_id != nullptr) ? shared_next_id->fetch_add(1) : + next_id++; if (plaintext) { @@ -551,7 +550,8 @@ namespace asynchost } SSL_set_mode( ssl, - SSL_MODE_ENABLE_PARTIAL_WRITE | SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); + SSL_MODE_ENABLE_PARTIAL_WRITE | + SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); if (SSL_set_fd(ssl, cfd) != 1) { SSL_free(ssl); @@ -967,8 +967,7 @@ namespace asynchost sockaddr_storage bound{}; socklen_t blen = sizeof(bound); if ( - getsockname(listen_fd, reinterpret_cast(&bound), &blen) == - 0) + getsockname(listen_fd, reinterpret_cast(&bound), &blen) == 0) { if (bound.ss_family == AF_INET6) { diff --git a/src/host/tls/openssl_session_manager.h b/src/host/tls/openssl_session_manager.h index 5d7f901ea63e..9d87081717a3 100644 --- a/src/host/tls/openssl_session_manager.h +++ b/src/host/tls/openssl_session_manager.h @@ -7,7 +7,8 @@ // This is the seam the real HTTP/HTTP2 sessions plug into once TLS lives in the // connection layer: // * inbound plaintext from a connection -> ccf::Session::handle_incoming_data -// * ccf::Session output (via ccf::SessionWriter) -> OpenSSLServer::send, which +// * ccf::Session output (via ccf::SessionWriter) -> OpenSSLServer::send, +// which // encrypts + writes with backpressure // * connection teardown -> the owning session is dropped // @@ -50,8 +51,8 @@ namespace asynchost private: std::unique_ptr server; SessionFactory factory; - // Invoked (on the loop thread) when a connection's session is dropped, so an - // owner can update per-interface counters/metrics. + // Invoked (on the loop thread) when a connection's session is dropped, so + // an owner can update per-interface counters/metrics. std::function on_session_closed; std::mutex sessions_mutex; @@ -195,8 +196,7 @@ namespace asynchost std::span data, sockaddr /*addr*/ = {}) override { - server->send( - static_cast(id), data.data(), data.size()); + server->send(static_cast(id), data.data(), data.size()); } void close_socket(::tcp::ConnID id) override diff --git a/src/node/node_state.h b/src/node/node_state.h index 5abb91a95f03..37dc4ae39333 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -28,8 +28,6 @@ #include "ds/internal_logger.h" #include "ds/state_machine.h" #include "enclave/abstract_rpc_sessions.h" -#include "tls/ca.h" -#include "tls/cert.h" #include "encryptor.h" #include "history.h" #include "http/curl.h" @@ -61,6 +59,8 @@ #include "share_manager.h" #include "snapshots/fetch.h" #include "snapshots/filenames.h" +#include "tls/ca.h" +#include "tls/cert.h" #include "uvm_endorsements.h" #include From 396cfa8725cfc1d9f2fae8780b4cae3655f45eb0 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Fri, 26 Jun 2026 09:13:51 +0000 Subject: [PATCH 11/59] Cleanup: remove dead code from cutover - LoopExecutor (unused), orphaned quic_session.h/src/quic, udp.h + udp/msg_types.h + UDPImpl vestiges in run.cpp, dead RPC ringbuffer message enums (keep tcp::ConnID). Drop old-implementation comments. Fix build after cert.h use->configure_ssl rename + commit-callback include. --- CMakeLists.txt | 11 - src/enclave/abstract_rpc_sessions.h | 3 +- src/enclave/no_more_sessions.h | 2 - src/enclave/session_writer.h | 43 +-- src/host/datagram_server.h | 4 +- src/host/loop_executor.h | 82 ----- src/host/rpc_connection_manager.h | 25 +- src/host/run.cpp | 7 - src/host/test/loop_executor.cpp | 118 ------ src/host/tls/openssl_server.h | 7 +- src/host/udp.h | 543 ---------------------------- src/http/http_session.h | 1 + src/quic/quic_session.h | 452 ----------------------- src/quic/test/main.cpp | 19 - src/tcp/msg_types.h | 45 +-- src/udp/msg_types.h | 50 --- 16 files changed, 36 insertions(+), 1376 deletions(-) delete mode 100644 src/host/loop_executor.h delete mode 100644 src/host/test/loop_executor.cpp delete mode 100644 src/host/udp.h delete mode 100644 src/quic/quic_session.h delete mode 100644 src/quic/test/main.cpp delete mode 100644 src/udp/msg_types.h diff --git a/CMakeLists.txt b/CMakeLists.txt index fb533d3a662f..08a77b7880fe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -714,17 +714,6 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/src/host/test/files_cleanup_test.cpp ) - add_unit_test( - rpc_connections_test - ${CMAKE_CURRENT_SOURCE_DIR}/src/host/test/rpc_connections.cpp - ) - target_link_libraries(rpc_connections_test PRIVATE uv) - - add_unit_test( - loop_executor_test - ${CMAKE_CURRENT_SOURCE_DIR}/src/host/test/loop_executor.cpp - ) - add_unit_test( openssl_server_test ${CMAKE_CURRENT_SOURCE_DIR}/src/host/test/openssl_server_test.cpp diff --git a/src/enclave/abstract_rpc_sessions.h b/src/enclave/abstract_rpc_sessions.h index 3aa05c0a3075..0680583644a4 100644 --- a/src/enclave/abstract_rpc_sessions.h +++ b/src/enclave/abstract_rpc_sessions.h @@ -23,8 +23,7 @@ namespace ccf // The slice of RPC session management that the node (NodeState, frontends, // Enclave, jwt refresh) depends on, independent of how connections are - // actually serviced. Both the legacy RPCSessions (ringbuffer/host-split) and - // the new host-side RPCConnectionManager implement this, so node-side code + // actually serviced. RPCConnectionManager implements this, so node-side code // can hold a reference without depending on the concrete networking backend. class AbstractRPCSessions : public AbstractRPCResponder { diff --git a/src/enclave/no_more_sessions.h b/src/enclave/no_more_sessions.h index 12814baca241..a37a431b428a 100644 --- a/src/enclave/no_more_sessions.h +++ b/src/enclave/no_more_sessions.h @@ -12,8 +12,6 @@ namespace ccf // closes the connection. It is templated on the concrete server session type // (HTTPServerSession / HTTP2ServerSession) so it reuses that session's // response machinery. - // - // Previously nested inside RPCSessions; pulled out so it can be shared. template class NoMoreSessionsImpl : public Base { diff --git a/src/enclave/session_writer.h b/src/enclave/session_writer.h index add34aa41414..69ff77291d53 100644 --- a/src/enclave/session_writer.h +++ b/src/enclave/session_writer.h @@ -11,47 +11,34 @@ namespace ccf { - // Abstract output sink injected into Sessions. - // - // This replaces the per-session ringbuffer `to_host` writer that used to - // carry outbound bytes (tcp_outbound) and lifecycle signals (tcp_closed / - // tcp_stop) from the enclave back to the host. With the host/enclave split - // removed, sessions instead hold a reference to a SessionWriter implemented - // by the host-side RPCConnectionManager. + // Abstract output sink injected into Sessions: a Session hands its outbound + // bytes (and connection-teardown requests) to a SessionWriter, which is + // implemented by the host-side RPCConnectionManager. // // IMPORTANT: Sessions invoke these methods from worker threads (see - // ccf::ThreadedSession / OrderedTasks). Implementations MUST be thread-safe - // and must marshal any libuv socket operations onto the loop thread (e.g. via - // asynchost::LoopExecutorImpl), since libuv handles are not thread-safe. + // ccf::ThreadedSession / OrderedTasks), so implementations MUST be + // thread-safe and must marshal any socket operations onto their I/O thread. class SessionWriter { public: virtual ~SessionWriter() = default; - // Queue bytes (already encrypted by the session's TLS layer, or plaintext - // for unencrypted sessions) to be written to the socket associated with - // `id`. For datagram protocols, `addr` identifies the destination peer; it - // is ignored for stream (TCP) connections. The bytes are copied, so the - // caller's buffer can be reused immediately. + // Queue bytes to be written to the socket associated with `id`. For + // datagram protocols, `addr` identifies the destination peer; it is ignored + // for stream (TCP) connections. The bytes are copied, so the caller's + // buffer can be reused immediately. // - // Fire-and-forget: there is currently no backpressure signal. The old - // ringbuffer "buffer full" was not real network backpressure, so it is not - // reproduced here. + // Fire-and-forget: there is currently no backpressure signal. // - // FUTURE: to surface genuine TCP-layer backpressure (so that e.g. - // TLSSession::handle_send can return TLS_WRITING and let OpenSSL retry), an - // implementation should report when a connection's pending-write queue - // exceeds a watermark. The manager can track per-connection queued bytes - // (incremented on enqueue here, decremented once the uv write completes) - // and have this return a writable/would-block status. + // FUTURE: to surface genuine TCP-layer backpressure, an implementation + // should report when a connection's pending-write queue exceeds a watermark + // (tracking per-connection queued bytes) and return a writable/would-block + // status here. virtual void write_outbound( ::tcp::ConnID id, std::span data, sockaddr addr = {}) = 0; // Tear down the connection: stop the underlying socket and drop the - // session. This single call replaces the old two-phase tcp_stop + - // tcp_closed handshake, which existed only to reconcile the separate host - // and enclave bookkeeping across the ringbuffer. With a single owner there - // is no second party to notify, so one close is sufficient. + // session. virtual void close_socket(::tcp::ConnID id) = 0; }; } diff --git a/src/host/datagram_server.h b/src/host/datagram_server.h index b5a63262a877..7fa663475f41 100644 --- a/src/host/datagram_server.h +++ b/src/host/datagram_server.h @@ -4,8 +4,8 @@ // A minimal UDP datagram server: it owns a SOCK_DGRAM socket in its own epoll // loop and delivers each received datagram to a handler, which may reply to the -// sender. This restores the pre-cutover plaintext "UDP echo" stub (the only -// goal here is behavioural compatibility, i.e. the existing udp echo test). +// sender. It backs the plaintext "UDP echo" interface (the only goal here is +// behavioural compatibility, i.e. the existing udp echo test). // // =========================================================================== // QUIC EXTENSION POINT diff --git a/src/host/loop_executor.h b/src/host/loop_executor.h deleted file mode 100644 index 798923ab695c..000000000000 --- a/src/host/loop_executor.h +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the Apache 2.0 License. -#pragma once - -#include "ccf/pal/locking.h" - -#include -#include -#include -#include -#include - -namespace asynchost -{ - // A thread-safe queue of work items to be executed on the libuv loop thread. - // - // Any thread may enqueue work via enqueue(); the work is run later, on the - // loop thread, when flush() is called (typically driven by a libuv Timer via - // on_timer()). This mirrors the existing host pattern of draining the - // enclave->host ringbuffer on a periodic Timer, and provides a safe way to - // marshal operations that must run on the loop thread (e.g. libuv socket - // operations, which are not thread-safe) when they are requested from other - // threads (e.g. the enclave worker threads). - class LoopExecutorImpl - { - public: - using Work = std::function; - - private: - ccf::pal::Mutex lock; - std::vector pending; - - public: - // May be called from any thread. - void enqueue(Work work) - { - std::lock_guard guard(lock); - pending.emplace_back(std::move(work)); - } - - // Must be called on the loop thread. Runs all work that was queued at the - // point of the call, in the order it was enqueued. Work enqueued while - // flushing (including by the work items themselves) is left for a - // subsequent flush, so this never loops indefinitely. - void flush() - { - std::vector to_run; - { - std::lock_guard guard(lock); - std::swap(to_run, pending); - } - - for (auto& work : to_run) - { - work(); - } - } - - // Called by the driving Timer on the loop thread. - void on_timer() - { - flush(); - } - }; - - // Timer behaviour that drains a LoopExecutorImpl on the loop thread. Drive - // this with an asynchost::Timer at a small interval, e.g. - // proxy_ptr> t(1ms, executor); - struct LoopExecutorDrainer - { - std::shared_ptr executor; - - explicit LoopExecutorDrainer(std::shared_ptr executor_) : - executor(std::move(executor_)) - {} - - void on_timer() - { - executor->flush(); - } - }; -} diff --git a/src/host/rpc_connection_manager.h b/src/host/rpc_connection_manager.h index 7333431540fb..3db1f60403a1 100644 --- a/src/host/rpc_connection_manager.h +++ b/src/host/rpc_connection_manager.h @@ -4,13 +4,12 @@ // Host-side, OpenSSL-native RPC connection manager. // -// Replaces the SGX-era split of RPCSessions (enclave) + RPCConnectionsImpl -// (host) bridged over a ringbuffer. It owns one OpenSSL transport per listening -// interface (TLS terminated in the connection, see host/tls/openssl_server.h), -// creates the protocol session for each connection, applies per-interface -// session caps and certificates, and exposes outbound client creation. It -// implements ccf::AbstractRPCSessions so the node (NodeState/frontends) reaches -// it without depending on the transport backend. +// Owns one OpenSSL transport per listening interface (TLS terminated in the +// connection, see host/tls/openssl_server.h), creates the protocol session for +// each connection, applies per-interface session caps and certificates, and +// exposes outbound client creation. It implements ccf::AbstractRPCSessions so +// the node (NodeState/frontends) reaches it without depending on the transport +// backend. // // Cert-deferred listening: interfaces bind at startup even before their // certificate exists (a joining node receives the service cert later). A TLS @@ -78,9 +77,9 @@ namespace ccf std::mutex interfaces_mutex; std::map> interfaces; - // Plaintext UDP echo servers, keyed by interface name. The pre-cutover UDP - // "QUIC" interface was a plaintext echo stub; this preserves that until - // OpenSSL-native QUIC is available (see host/datagram_server.h). + // Plaintext UDP echo servers, keyed by interface name. UDP "QUIC" + // interfaces are served as a plaintext echo until OpenSSL-native QUIC is + // available (see host/datagram_server.h). std::map> udp_servers; // cert/key PEM per endorsement authority (for cert-deferred listening). @@ -303,8 +302,8 @@ namespace ccf return li->bridge->port(); } - // Bind and start a plaintext UDP echo server for `name`. This preserves the - // pre-cutover UDP echo stub (interfaces with protocol "udp"). + // Bind and start a plaintext UDP echo server for `name` (interfaces with + // protocol "udp"). // // === QUIC EXTENSION POINT === // A real QUIC interface would, instead of echoing, hand each datagram to an @@ -362,7 +361,7 @@ namespace ccf [cert](SSL* ssl, SSL_CTX* ctx) { if (cert != nullptr) { - cert->use(ssl, ctx); + cert->configure_ssl(ssl, ctx); } }); }; diff --git a/src/host/run.cpp b/src/host/run.cpp index ab91cd3c7d7b..9fd3ac2f564f 100644 --- a/src/host/run.cpp +++ b/src/host/run.cpp @@ -40,7 +40,6 @@ #include "tcp.h" #include "ticker.h" #include "time_bound_logger.h" -#include "udp.h" #include #include @@ -78,9 +77,6 @@ size_t asynchost::TCPImpl::remaining_read_quota = asynchost::TCPImpl::max_read_quota; bool asynchost::TCPImpl::alloc_quota_logged = false; -size_t asynchost::UDPImpl::remaining_read_quota = - asynchost::UDPImpl::max_read_quota; - void print_version(int64_t ignored) { (void)ignored; @@ -541,9 +537,6 @@ namespace ccf // reset the inbound-TCP processing quota each iteration const asynchost::ResetTCPReadQuota reset_tcp_quota; - // reset the inbound-UDP processing quota each iteration - const asynchost::ResetUDPReadQuota reset_udp_quota; - // handle outbound logging and admin messages from the enclave const asynchost::HandleRingbuffer handle_ringbuffer( 1ms, diff --git a/src/host/test/loop_executor.cpp b/src/host/test/loop_executor.cpp deleted file mode 100644 index a890e7adf9ca..000000000000 --- a/src/host/test/loop_executor.cpp +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the Apache 2.0 License. - -#include "host/loop_executor.h" - -#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN -#include -#include -#include -#include - -using namespace asynchost; - -TEST_CASE("LoopExecutor runs queued work on flush") -{ - LoopExecutorImpl executor; - - int counter = 0; - executor.enqueue([&]() { counter += 1; }); - executor.enqueue([&]() { counter += 10; }); - - // Nothing runs until flush() - REQUIRE(counter == 0); - - executor.flush(); - REQUIRE(counter == 11); - - // A second flush with no pending work is a no-op - executor.flush(); - REQUIRE(counter == 11); -} - -TEST_CASE("LoopExecutor preserves enqueue order") -{ - LoopExecutorImpl executor; - - std::vector order; - constexpr int n = 100; - for (int i = 0; i < n; ++i) - { - executor.enqueue([&order, i]() { order.push_back(i); }); - } - - executor.flush(); - - REQUIRE(order.size() == n); - for (int i = 0; i < n; ++i) - { - REQUIRE(order[i] == i); - } -} - -TEST_CASE("LoopExecutor defers work enqueued during flush") -{ - LoopExecutorImpl executor; - - int outer = 0; - int inner = 0; - executor.enqueue([&]() { - outer += 1; - // Work enqueued while flushing must not run during this same flush. - executor.enqueue([&]() { inner += 1; }); - }); - - executor.flush(); - REQUIRE(outer == 1); - REQUIRE(inner == 0); - - // The re-entrantly enqueued work runs on the next flush. - executor.flush(); - REQUIRE(outer == 1); - REQUIRE(inner == 1); -} - -TEST_CASE("LoopExecutor is safe under concurrent producers") -{ - LoopExecutorImpl executor; - - std::atomic executed{0}; - std::atomic draining{true}; - - constexpr int num_producers = 8; - constexpr int per_producer = 10000; - - // A single "loop thread" continuously draining the executor. - std::thread loop_thread([&]() { - while (draining.load()) - { - executor.flush(); - } - // Final drain to catch anything enqueued just before we stopped. - executor.flush(); - }); - - std::vector producers; - producers.reserve(num_producers); - for (int p = 0; p < num_producers; ++p) - { - producers.emplace_back([&]() { - for (int i = 0; i < per_producer; ++i) - { - executor.enqueue([&executed]() { executed.fetch_add(1); }); - } - }); - } - - for (auto& t : producers) - { - t.join(); - } - - // Stop draining once all work has been enqueued; the loop thread will do a - // final flush before exiting. - draining.store(false); - loop_thread.join(); - - REQUIRE(executed.load() == num_producers * per_producer); -} diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index ec983d31f375..df9e9c3d650b 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -231,8 +231,7 @@ namespace asynchost SSL_CTX_set_min_proto_version(c, TLS1_2_VERSION); // Request the client certificate during the handshake so it can be used // for application-level caller authentication (user/member cert auth). - // Verification is not enforced here - the application decides - mirroring - // the old Cert(auth_required = false) server behaviour. + // Verification is not enforced here - the application decides. SSL_CTX_set_verify( c, SSL_VERIFY_PEER, [](int, X509_STORE_CTX*) { return 1; }); if (!alpn_wire.empty()) @@ -537,8 +536,8 @@ namespace asynchost { if (ctx == nullptr) { - // No server certificate yet - refuse (mirrors the old "Session - // refused until cert present" behaviour). + // No server certificate yet - refuse the connection until one is + // supplied (see set_server_cert). ::close(cfd); continue; } diff --git a/src/host/udp.h b/src/host/udp.h deleted file mode 100644 index 134593f29927..000000000000 --- a/src/host/udp.h +++ /dev/null @@ -1,543 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the Apache 2.0 License. -#pragma once - -#include "before_io.h" -#include "ccf/pal/locking.h" -#include "dns.h" -#include "ds/internal_logger.h" -#include "ds/pending_io.h" -#include "proxy.h" -#include "socket.h" - -#include - -namespace asynchost -{ - // NOLINTBEGIN(cppcoreguidelines-virtual-class-destructor) - class UDPImpl; - using UDP = proxy_ptr; - - /// For now this is server only, as we have no immediate plans to - /// create node-to-node UDP channels or use UDP for REST between nodes - class UDPImpl : public with_uv_handle - { - private: - friend class close_ptr; - - static constexpr int backlog = 128; - static constexpr size_t max_read_size = 16384; - - // Each uv iteration, read only a capped amount from all sockets. - static constexpr auto max_read_quota = max_read_size * 4; - static size_t remaining_read_quota; - - // This is a simplified version of the state machine for QUIC that - // mostly follows plain UDP state. We should add more when we need - // for QUIC, not predict complexity prematurely. - enum Status : uint8_t - { - // Starting state + failure recovery (if any) - FRESH, - // DNS::resolve - RESOLVING, - RESOLVING_FAILED, - // uv_udp_recv_start <-> on_read - READING, - READING_FAILED, - // uv_udp_send has no state (it's synchronous) - WRITING_FAILED, - // There is no connected/reconnect/disconnect - }; - - /// Current status - Status status{FRESH}; - /// Callback behaviour from user - std::unique_ptr> behaviour; - - using PendingWrites = std::vector>; - /// Writes sent before writing socket is read - PendingWrites pending_writes; - - /// Host to bind the server - std::string host; - /// Port to bind the server - std::string port; - /// Listening name - std::optional listen_name = std::nullopt; - - /// Base address (head of linked list) - addrinfo* addr_base = nullptr; - /// Current address (node in the list that resolved first) - addrinfo* addr_current = nullptr; - - [[nodiscard]] bool port_assigned() const - { - return port != "0"; - } - - [[nodiscard]] std::string get_address_name() const - { - const std::string port_suffix = - port_assigned() ? fmt::format(":{}", port) : ""; - - if (addr_current != nullptr && addr_current->ai_family == AF_INET6) - { - return fmt::format("[{}]{}", host, port_suffix); - } - - return fmt::format("{}{}", host, port_suffix); - } - - UDPImpl() - { - if (!init()) - { - throw std::logic_error("uv UDP initialization failed"); - } - - uv_handle.data = this; - } - - ~UDPImpl() override - { - { - std::unique_lock guard(pending_resolve_requests_mtx); - for (const auto& req : pending_resolve_requests) - { - // The UV request objects can stay, but if there are any references - // to `this` left, we need to remove them. - if (req->data == this) - { - req->data = nullptr; - } - } - } - if (addr_base != nullptr) - { - uv_freeaddrinfo(addr_base); - } - } - - public: - static void reset_read_quota() - { - remaining_read_quota = max_read_quota; - } - - void set_behaviour(std::unique_ptr> b) - { - behaviour = std::move(b); - } - - [[nodiscard]] std::string get_host() const - { - return host; - } - - [[nodiscard]] std::string get_port() const - { - return port; - } - - [[nodiscard]] std::optional get_listen_name() const - { - return listen_name; - } - - /// Listen to packets on host:port - bool listen( - const std::string& host_, - const std::string& port_, - const std::optional& name = std::nullopt) - { - listen_name = name; - auto name_str = name.has_value() ? name.value() : ""; - LOG_TRACE_FMT("UDP listen on {}:{} [{}]", host_, port_, name_str); - return resolve(host_, port_, false); - } - - /// Start the service via behaviour (register on ringbuffer, etc) - void start(int64_t id) - { - behaviour->on_start(id); - } - - bool connect(const std::string& /*host_*/, const std::string& /*port_*/) - { - LOG_TRACE_FMT("UDP dummy connect to {}:{}", host, port); - return true; - } - - bool write(size_t len, const uint8_t* data, sockaddr addr) - { - auto* req = new uv_udp_send_t; // NOLINT(cppcoreguidelines-owning-memory) - auto* copy = new char[len]; // NOLINT(cppcoreguidelines-owning-memory) - if (data != nullptr) - { - memcpy(copy, data, len); - } - req->data = copy; - - switch (status) - { - // Handles unbound or in unknown state - case RESOLVING: - case RESOLVING_FAILED: - case READING_FAILED: - case WRITING_FAILED: - { - pending_writes.emplace_back(req, len, addr, free_write); - break; - } - - // Both read and write handles have been bound here - case READING: - { - auto [h, p] = addr_to_str(&addr); - LOG_TRACE_FMT("UDP write addr: {}:{}", h, p); - return send_write(req, len, &addr); - } - - case FRESH: - default: - { - free_write(req); - throw std::logic_error( - fmt::format("Unexpected status during write: {}", status)); - } - } - - return true; - } - - private: - /// Initializes both handles (recv/send) - bool init() - { - assert_status(FRESH, FRESH); - - int rc = 0; - LOG_TRACE_FMT("UDP init"); - if ((rc = uv_udp_init(uv_default_loop(), &uv_handle)) < 0) - { - LOG_FAIL_FMT("uv_udp_init failed on recv handle: {}", uv_strerror(rc)); - return false; - } - - return true; - } - - bool send_write(uv_udp_send_t* req, size_t len, const struct sockaddr* addr) - { - auto* copy = static_cast(req->data); - - uv_buf_t buf; - buf.base = copy; - buf.len = len; - - int rc = 0; - - auto [h, p] = addr_to_str(addr); - LOG_TRACE_FMT("UDP send_write addr: {}:{}", h, p); - std::string data(copy, len); - LOG_TRACE_FMT("UDP send_write [{}]", data); - if ((rc = uv_udp_send(req, &uv_handle, &buf, 1, addr, on_write)) < 0) - { - free_write(req); - LOG_FAIL_FMT("uv_write failed: {}", uv_strerror(rc)); - status = WRITING_FAILED; - behaviour->on_disconnect(); - return false; - } - - return true; - } - - void update_resolved_address(int address_family, sockaddr* sa) - { - auto [h, p] = addr_to_str(sa, address_family); - host = h; - port = p; - LOG_TRACE_FMT("UDP update address to {}:{}", host, port); - } - - void resolved() - { - int rc = 0; - - LOG_TRACE_FMT("UDP bind to {}:{}", host, port); - while (addr_current != nullptr) - { - update_resolved_address(addr_current->ai_family, addr_current->ai_addr); - - if ((rc = uv_udp_bind(&uv_handle, addr_current->ai_addr, 0)) < 0) - { - addr_current = addr_current->ai_next; - LOG_FAIL_FMT( - "uv_udp_bind failed on {}: {}", - get_address_name(), - uv_strerror(rc)); - continue; - } - - // If bound on port 0 (ie - asking the OS to assign a port), then we - // need to call uv_udp_getsockname to retrieve the bound port - // (addr_current will not contain it) - if (!port_assigned()) - { - sockaddr_storage sa_storage{}; - auto* const sa = reinterpret_cast(&sa_storage); - int sa_len = sizeof(sa_storage); - if ((rc = uv_udp_getsockname(&uv_handle, sa, &sa_len)) != 0) - { - LOG_FAIL_FMT("uv_udp_getsockname failed: {}", uv_strerror(rc)); - } - update_resolved_address(addr_current->ai_family, sa); - } - - LOG_TRACE_FMT("UDP to call on_listening"); - - behaviour->on_listening(host, port); - - assert_status(RESOLVING, READING); - read_start(); - return; - } - - status = RESOLVING_FAILED; - - // This should show even when verbose logs are off - LOG_INFO_FMT( - "Unable to connect: all resolved addresses failed: {}:{}", host, port); - } - - void assert_status(Status from, Status to) - { - if (status != from) - { - throw std::logic_error(fmt::format( - "Trying to transition from {} to {} but current status is {}", - from, - to, - status)); - } - - status = to; - } - - bool resolve( - const std::string& host_, const std::string& port_, bool async = true) - { - host = host_; - port = port_; - - LOG_TRACE_FMT("UDP resolve {}:{}", host, port); - if (addr_base != nullptr) - { - uv_freeaddrinfo(addr_base); - addr_base = nullptr; - addr_current = nullptr; - } - - assert_status(FRESH, RESOLVING); - - if (!DNS::resolve(host, port, this, on_resolved, async)) - { - LOG_DEBUG_FMT("Resolving '{}' failed", host); - status = RESOLVING_FAILED; - return false; - } - - return true; - } - - static void on_resolved(uv_getaddrinfo_t* req, int rc, struct addrinfo* res) - { - std::unique_lock guard(pending_resolve_requests_mtx); - pending_resolve_requests.erase(req); - - LOG_TRACE_FMT("UDP on_resolve static"); - if (req->data != nullptr) - { - static_cast(req->data)->on_resolved(req, rc); - } - else - { - // The UDPImpl that submitted the request has been destroyed, but we - // need to clean up the request object. - uv_freeaddrinfo(res); - delete req; // NOLINT(cppcoreguidelines-owning-memory) - } - } - - void on_resolved(uv_getaddrinfo_t* req, int rc) - { - LOG_TRACE_FMT("UDP on_resolve dynamic"); - // It is possible that on_resolved is triggered after there has been a - // request to close uv_handle. In this scenario, we should not try to - // do anything with the handle and return immediately (otherwise, - // uv_close cb will abort). - if (uv_is_closing(reinterpret_cast(&uv_handle)) != 0) - { - LOG_DEBUG_FMT("on_resolved: closing"); - uv_freeaddrinfo(req->addrinfo); - delete req; // NOLINT(cppcoreguidelines-owning-memory) - return; - } - - if (rc < 0) - { - status = RESOLVING_FAILED; - LOG_DEBUG_FMT("UDP resolve failed: {}", uv_strerror(rc)); - behaviour->on_resolve_failed(); - } - else - { - addr_base = req->addrinfo; - addr_current = addr_base; - - LOG_TRACE_FMT("UDP to call resolved"); - resolved(); - } - - delete req; // NOLINT(cppcoreguidelines-owning-memory) - } - - void push_pending_writes() - { - for (auto& w : pending_writes) - { - auto [h, p] = addr_to_str(&w.addr); - LOG_TRACE_FMT("UDP pending_writes addr: {}:{}", h, p); - send_write(w.req, w.len, &w.addr); - w.req = nullptr; - } - - PendingWrites().swap(pending_writes); - } - - void read_start() - { - int rc = 0; - - LOG_TRACE_FMT("UDP read start"); - if ((rc = uv_udp_recv_start(&uv_handle, on_alloc, on_read)) < 0) - { - status = READING_FAILED; - LOG_FAIL_FMT("uv_udp_read_start failed: {}", uv_strerror(rc)); - behaviour->on_disconnect(); - } - } - - static void on_alloc( - uv_handle_t* handle, size_t suggested_size, uv_buf_t* buf) - { - static_cast(handle->data)->on_alloc(suggested_size, buf); - } - - void on_alloc(size_t suggested_size, uv_buf_t* buf) - { - auto alloc_size = std::min(suggested_size, max_read_size); - - alloc_size = std::min(alloc_size, remaining_read_quota); - remaining_read_quota -= alloc_size; - LOG_TRACE_FMT( - "Allocating {} bytes for UDP read ({} of quota remaining)", - alloc_size, - remaining_read_quota); - - // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - buf->base = new char[alloc_size]; - buf->len = alloc_size; - } - - void on_free(const uv_buf_t* buf) - { - delete[] buf->base; // NOLINT(cppcoreguidelines-owning-memory) - } - - static void on_read( - uv_udp_t* handle, - ssize_t sz, - const uv_buf_t* buf, - const struct sockaddr* addr, - unsigned flags) - { - static_cast(handle->data)->on_read(sz, buf, addr, flags); - } - - void on_read( - ssize_t sz, - const uv_buf_t* buf, - const struct sockaddr* addr, - unsigned /*flags*/) - { - if (sz == 0) - { - on_free(buf); - return; - } - - if (sz == UV_ENOBUFS) - { - LOG_DEBUG_FMT("UDP on_read reached allocation quota"); - on_free(buf); - return; - } - - if (sz < 0) - { - on_free(buf); - LOG_DEBUG_FMT("UDP on_read: {}", uv_strerror(static_cast(sz))); - behaviour->on_disconnect(); - return; - } - - auto [h, p] = addr_to_str(addr); - LOG_TRACE_FMT("UDP on_read addr: {}:{}", h, p); - - auto* b = reinterpret_cast(buf->base); - std::string data(reinterpret_cast(b), sz); - LOG_TRACE_FMT("UDP on_read [{}]", data); - behaviour->on_read(static_cast(sz), b, *addr); - - if (b != nullptr) - { - on_free(buf); - } - } - - static void on_write(uv_udp_send_t* req, int /*status*/) - { - free_write(req); - } - - static void free_write(uv_udp_send_t* req) - { - if (req == nullptr) - { - return; - } - - auto* copy = static_cast(req->data); - delete[] copy; // NOLINT(cppcoreguidelines-owning-memory) - delete req; // NOLINT(cppcoreguidelines-owning-memory) - } - }; - - // NOLINTEND(cppcoreguidelines-virtual-class-destructor) - - class ResetUDPReadQuotaImpl - { - public: - ResetUDPReadQuotaImpl() = default; - - void before_io() - { - UDPImpl::reset_read_quota(); - } - }; - - using ResetUDPReadQuota = proxy_ptr>; -} diff --git a/src/http/http_session.h b/src/http/http_session.h index f38c2c82da7a..c31c53ca2d74 100644 --- a/src/http/http_session.h +++ b/src/http/http_session.h @@ -9,6 +9,7 @@ #include "http_parser.h" #include "http_responder.h" #include "http_rpc_context.h" +#include "node/commit_callback_subsystem.h" namespace http { diff --git a/src/quic/quic_session.h b/src/quic/quic_session.h deleted file mode 100644 index 5f1042193711..000000000000 --- a/src/quic/quic_session.h +++ /dev/null @@ -1,452 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the Apache 2.0 License. -#pragma once - -#include "ds/internal_logger.h" -#include "ds/messaging.h" -#include "ds/pending_io.h" -#include "ds/ring_buffer.h" -#include "enclave/session.h" -#include "udp/msg_types.h" - -#include - -namespace quic -{ - class QUICSession : public ccf::Session, - public std::enable_shared_from_this - { - protected: - ringbuffer::WriterPtr to_host; - ccf::tls::ConnID session_id; - - std::shared_ptr task_scheduler; - - enum Status : std::uint8_t - { - handshake, - ready, - closed, - authfail, - error - }; - - Status get_status() const - { - return status; - } - - using PendingBuffer = PendingIO; - using PendingList = std::vector; - PendingList pending_writes; - PendingList pending_reads; - - private: - // Decrypted data - std::vector read_buffer; - - Status status = handshake; - - public: - QUICSession( - int64_t session_id_, ringbuffer::AbstractWriterFactory& writer_factory_) : - to_host(writer_factory_.create_writer_to_outside()), - session_id(session_id_) - { - task_scheduler = ccf::tasks::OrderedTasks::create( - ccf::tasks::get_main_job_board(), - fmt::format("Session {}", session_id)); - } - - ~QUICSession() override - { - task_scheduler->cancel_task(); - // RINGBUFFER_WRITE_MESSAGE(quic::quic_closed, to_host, session_id); - } - - std::string hostname() - { - return {}; - } - - std::vector peer_cert() - { - return {}; - } - - // Returns count N of bytes read, which will be the first N bytes of data, - // up to a maximum of size. If exact is true, will only return either size - // or 0 (when size bytes are not currently available). data may be accessed - // beyond N during operation, up to size, but only the first N should be - // used by caller. - size_t read(uint8_t* data, size_t size, sockaddr addr, bool exact = false) - { - LOG_TRACE_FMT("Requesting up to {} bytes", size); - - // This will return empty if the connection isn't - // ready, but it will not block on the handshake. - do_handshake(); - - if (status != ready) - { - return 0; - } - - // Send pending writes. - flush(); - - size_t offset = 0; - - if (!read_buffer.empty()) - { - LOG_TRACE_FMT( - "Have existing read_buffer of size: {}", read_buffer.size()); - offset = std::min(size, read_buffer.size()); - ::memcpy(data, read_buffer.data(), offset); - - if (offset < read_buffer.size()) - { - read_buffer.erase(read_buffer.begin(), read_buffer.begin() + offset); - } - else - { - read_buffer.clear(); - } - - if (offset == size) - { - return size; - } - - // NB: If we continue past here, read_buffer is empty - } - - // This will need to be handled by the actual QUIC stack - auto r = handle_recv(data + offset, size - offset, addr); - LOG_TRACE_FMT("quic read returned: {}", r); - - if (r < 0) - { - LOG_TRACE_FMT("QUIC {} error on read", session_id); - stop(error); - return 0; - } - - auto total = r + offset; - - // We read _some_ data but not enough, and didn't get - // WANT_READ. Probably hit an internal size limit - try - // again - if (exact && (total < size)) - { - LOG_TRACE_FMT( - "Asked for exactly {}, received {}, retrying", size, total); - read_buffer.insert(read_buffer.end(), data, data + total); - return read(data, size, addr, exact); - } - - return total; - } - - void recv_buffered(const uint8_t* data, size_t size, sockaddr addr) - { - LOG_TRACE_FMT("QUIC Session recv_buffered with {} bytes", size); - pending_reads.emplace_back(const_cast(data), size, addr); - do_handshake(); - } - - struct SessionDataTask : public ccf::tasks::ITaskAction - { - std::shared_ptr self; - std::vector data; - sockaddr addr{}; - - SessionDataTask( - std::shared_ptr s, - std::span d, - sockaddr sa) : - self(std::move(s)), - addr(sa) - { - data.assign(d.begin(), d.end()); - } - }; - - struct SendDataTask : public SessionDataTask - { - using SessionDataTask::SessionDataTask; - - void do_action() override - { - self->send_raw_thread(data, addr); - } - - [[nodiscard]] const std::string& get_name() const override - { - static const std::string name = "quic::SendDataTask"; - return name; - } - }; - - struct RecvDataTask : public SessionDataTask - { - using SessionDataTask::SessionDataTask; - - void do_action() override - { - self->recv(data.data(), data.size(), addr); - } - - [[nodiscard]] const std::string& get_name() const override - { - static const std::string name = "quic::RecvDataTask"; - return name; - } - }; - - void send_raw(const uint8_t* data, size_t size, sockaddr addr) - { - task_scheduler->add_action(std::make_shared( - shared_from_this(), std::span{data, size}, addr)); - } - - void send_raw_thread(const std::vector& data, sockaddr addr) - { - // Writes as much of the data as possible. If the data cannot all - // be written now, we store the remainder. We - // will try to send pending writes again whenever write() is called. - do_handshake(); - - if (status == handshake) - { - pending_writes.emplace_back( - const_cast(data.data()), data.size(), addr); - return; - } - - if (status != ready) - { - return; - } - - pending_writes.emplace_back( - const_cast(data.data()), data.size(), addr); - - flush(); - } - - void send_buffered(const std::vector& data, sockaddr addr) - { - pending_writes.emplace_back( - const_cast(data.data()), data.size(), addr); - } - - void handle_incoming_data( - std::span data, sockaddr addr) override - { - task_scheduler->add_action( - std::make_shared(shared_from_this(), data, addr)); - } - - virtual void recv(const uint8_t* data_, size_t size_, sockaddr addr_) = 0; - - void flush() - { - do_handshake(); - - if (status != ready) - { - return; - } - - for (auto& write : pending_writes) - { - LOG_TRACE_FMT("QUIC write_some {} bytes", write.len); - - // This will need to be handled by the actual QUIC stack - int rc = handle_send(write.req, write.len, write.addr); - if (rc < 0) - { - LOG_TRACE_FMT("QUIC {} error on flush", session_id); - stop(error); - return; - } - - // Mark for deletion (avoiding invalidating iterator) - write.clear = true; - } - - // Clear all marked for deletion - PendingBuffer::clear_empty(pending_writes); - } - - void close_session() override - { - auto self = shared_from_this(); - task_scheduler->add_action( - ccf::tasks::make_basic_action([self]() { self->close_thread(); })); - } - - void close_thread() - { - switch (status) - { - case handshake: - { - LOG_TRACE_FMT("QUIC {} closed during handshake", session_id); - stop(closed); - break; - } - - case ready: - { - LOG_TRACE_FMT("QUIC {} closed", session_id); - stop(closed); - break; - } - - case closed: - case authfail: - case error: - default: - { - } - } - } - - private: - void do_handshake() - { - // This should be called when additional data is written to the - // input buffer, until the handshake is complete. - if (status != handshake) - { - return; - } - - // This will need to be handled by the actual QUIC stack - LOG_TRACE_FMT("QUIC do_handshake unimplemented"); - status = ready; - } - - void stop(Status status_) - { - switch (status) - { - case closed: - case authfail: - case error: - return; - - case handshake: - case ready: - default: - { - } - } - - status = status_; - } - - int handle_send(const uint8_t* buf, size_t len, sockaddr addr) - { - auto [addr_family, addr_data] = udp::sockaddr_encode(addr); - - // Either write all of the data or none of it. - auto wrote = RINGBUFFER_TRY_WRITE_MESSAGE( - udp::udp_outbound, - to_host, - session_id, - addr_family, - addr_data, - serializer::ByteRange{buf, len}); - - if (!wrote) - { - return -1; - } - - return (int)len; - } - - int handle_recv(uint8_t* buf, size_t len, sockaddr addr) - { - size_t len_read = 0; - for (auto& read : pending_reads) - { - // Only handle pending reads that belong to the same address - if (memcmp((void*)&addr, (void*)&read.addr, sizeof(addr)) != 0) - { - continue; - } - - size_t rd = std::min(len, read.len); - ::memcpy(buf, read.req, rd); - read.clear = true; - - // UDP packets are datagrams, so it's either whole or nothing - len_read += rd; - if (len_read >= len) - { - break; - } - } - - // Clear all marked for deletion - PendingBuffer::clear_empty(pending_reads); - - if (len_read > 0) - { - return len_read; - } - return -1; - } - }; - - // This is a wrapper for the QUICSession so we can use in rpc_sessions - // Ultimately, this needs to be an HTTP3ServerSession : HTTP3Session : - // QUICSession - class QUICEchoSession : public QUICSession - { - std::shared_ptr rpc_map; - std::shared_ptr handler; - std::shared_ptr session_ctx; - ccf::ListenInterfaceID interface_id; - sockaddr addr; - - /// Move all reads into the writes and push back to the client - void echo() - { - pending_reads.swap(pending_writes); - flush(); - } - - public: - QUICEchoSession( - std::shared_ptr rpc_map_, - int64_t session_id_, - ccf::ListenInterfaceID interface_id_, - ringbuffer::AbstractWriterFactory& writer_factory) : - QUICSession(session_id_, writer_factory), - rpc_map(std::move(rpc_map_)), - interface_id(std::move(interface_id_)), - addr{} - {} - - void send_data(std::vector&& data) override - { - send_raw(data.data(), data.size(), addr); - } - - void recv(const uint8_t* data_, size_t size_, sockaddr addr_) override - { - recv_buffered(data_, size_, addr_); - addr = addr_; - - LOG_TRACE_FMT("recv called with {} bytes", size_); - - // ECHO SERVER - echo(); - } - }; -} diff --git a/src/quic/test/main.cpp b/src/quic/test/main.cpp deleted file mode 100644 index 6d248d1ee62e..000000000000 --- a/src/quic/test/main.cpp +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the Apache 2.0 License. - -#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN -#include "tls/tls.h" - -#include -#include - -using namespace std; -using namespace ccf::crypto; - -TEST_CASE("check QUIC OpenSSL library call") -{ - OpenSSL::Unique_SSL_CTX cfg(TLS_client_method()); - OpenSSL::Unique_SSL ssl(cfg); - SSL_QUIC_METHOD* quic; - SSL_set_quic_method(ssl, quic); -} \ No newline at end of file diff --git a/src/tcp/msg_types.h b/src/tcp/msg_types.h index de46467e328c..5fca13c17b44 100644 --- a/src/tcp/msg_types.h +++ b/src/tcp/msg_types.h @@ -2,51 +2,10 @@ // Licensed under the Apache 2.0 License. #pragma once -#include "ds/ring_buffer_types.h" +#include namespace tcp { + // Connection identifier used throughout the RPC session/transport layer. using ConnID = int64_t; - - /// TCP-related ringbuffer messages - /// The body of each message will begin with a connection ID - enum : ringbuffer::Message - { - /// New connection has been opened. This will always be the first message - /// sent regarding a connection. Host -> Enclave - DEFINE_RINGBUFFER_MSG_TYPE(tcp_start), - - /// Request for a new connection to a remote peer. Enclave -> Host - DEFINE_RINGBUFFER_MSG_TYPE(tcp_connect), - - /// Data read from socket, to be read inside enclave. Host -> Enclave - DEFINE_RINGBUFFER_MSG_TYPE(tcp_inbound), - - /// Data sent from the enclave, to be written to socket. Enclave -> Host - DEFINE_RINGBUFFER_MSG_TYPE(tcp_outbound), - - /// While processing data, the enclave decided this connection is stopped. - /// Enclave -> Host - DEFINE_RINGBUFFER_MSG_TYPE(tcp_stop), - - /// Connection has been invalidated. No more messages will be sent regarding - /// this connection. Host -> Enclave - DEFINE_RINGBUFFER_MSG_TYPE(tcp_close), - - /// Enclave session has been deleted. Host can now safely remove the - /// corresponding connection. Enclave -> Host - DEFINE_RINGBUFFER_MSG_TYPE(tcp_closed), - }; } - -DECLARE_RINGBUFFER_MESSAGE_PAYLOAD( - ::tcp::tcp_start, ::tcp::ConnID, std::string); -DECLARE_RINGBUFFER_MESSAGE_PAYLOAD( - ::tcp::tcp_connect, ::tcp::ConnID, std::string, std::string); -DECLARE_RINGBUFFER_MESSAGE_PAYLOAD( - ::tcp::tcp_inbound, ::tcp::ConnID, serializer::ByteRange); -DECLARE_RINGBUFFER_MESSAGE_PAYLOAD( - ::tcp::tcp_outbound, ::tcp::ConnID, serializer::ByteRange); -DECLARE_RINGBUFFER_MESSAGE_PAYLOAD(::tcp::tcp_stop, ::tcp::ConnID, std::string); -DECLARE_RINGBUFFER_MESSAGE_PAYLOAD(::tcp::tcp_close, ::tcp::ConnID); -DECLARE_RINGBUFFER_MESSAGE_PAYLOAD(::tcp::tcp_closed, ::tcp::ConnID); diff --git a/src/udp/msg_types.h b/src/udp/msg_types.h deleted file mode 100644 index b27fdf25fe27..000000000000 --- a/src/udp/msg_types.h +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the Apache 2.0 License. -#pragma once - -#include "ds/ring_buffer_types.h" - -#include - -namespace udp -{ - using ConnID = int64_t; - - enum : ringbuffer::Message - { - DEFINE_RINGBUFFER_MSG_TYPE(udp_start), - DEFINE_RINGBUFFER_MSG_TYPE(udp_inbound), - DEFINE_RINGBUFFER_MSG_TYPE(udp_outbound), - }; - - static std::tuple> sockaddr_encode(sockaddr& addr) - { - short family = addr.sa_family; - std::vector data(14, '\0'); - memcpy(data.data(), &addr.sa_data, 14); - return std::make_pair(family, data); - } - - static sockaddr sockaddr_decode( - short family, const std::vector& data) - { - sockaddr addr{}; - addr.sa_family = family; - memcpy(&addr.sa_data, data.data(), 14); - return addr; - } -} - -DECLARE_RINGBUFFER_MESSAGE_PAYLOAD(udp::udp_start, udp::ConnID, std::string); -DECLARE_RINGBUFFER_MESSAGE_PAYLOAD( - udp::udp_inbound, - int64_t, - short, - std::vector, - serializer::ByteRange); -DECLARE_RINGBUFFER_MESSAGE_PAYLOAD( - udp::udp_outbound, - int64_t, - short, - std::vector, - serializer::ByteRange); From 50c47b79a3bae138b3daa877d6aa826229f82ee5 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Tue, 30 Jun 2026 13:24:08 +0000 Subject: [PATCH 12/59] Removed unused SNI --- src/enclave/session.h | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/enclave/session.h b/src/enclave/session.h index 1e01e672038e..ff4a8d0abab3 100644 --- a/src/enclave/session.h +++ b/src/enclave/session.h @@ -132,18 +132,15 @@ namespace ccf ::tcp::ConnID session_id; ccf::SessionWriter& session_writer; std::vector peer_cert_; - std::string sni_; PlaintextSession( ::tcp::ConnID session_id_, ccf::SessionWriter& writer, - std::vector peer_cert = {}, - std::string sni = {}) : + std::vector peer_cert = {}) : ThreadedSession(session_id_), session_id(session_id_), session_writer(writer), - peer_cert_(std::move(peer_cert)), - sni_(std::move(sni)) + peer_cert_(std::move(peer_cert)) {} public: @@ -152,11 +149,6 @@ namespace ccf return peer_cert_; } - const std::string& hostname() const - { - return sni_; - } - void send_data_thread(std::vector&& data) override { session_writer.write_outbound(session_id, {data.data(), data.size()}); From d2a3b8abd147d727808c73f2c46f8855d7b072e4 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Tue, 30 Jun 2026 13:24:37 +0000 Subject: [PATCH 13/59] Remove unused function --- src/host/run.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/host/run.cpp b/src/host/run.cpp index 9fd3ac2f564f..e027b224894b 100644 --- a/src/host/run.cpp +++ b/src/host/run.cpp @@ -185,8 +185,6 @@ namespace ccf {} }; - void setup_rpc_interfaces_REMOVED() {} - void configure_snp_attestation(ccf::StartupConfig& startup_config) { if (ccf::pal::platform != ccf::pal::Platform::SNP) From 55422b6b4b74bc1b1dee78b0765d8d5db6ae73dd Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Tue, 30 Jun 2026 13:46:11 +0000 Subject: [PATCH 14/59] Idle connection timeout: move idle_connection_timeout HostConfig->CCFConfig (so the enclave-side RPC manager receives it); track per-connection last_active in OpenSSLServer and sweep idle connections off the epoll timeout. Plumbed manager->bridge->server. idletimeout e2e passes. --- include/ccf/node/startup_config.h | 6 +++ src/common/configuration.h | 3 +- src/enclave/enclave.h | 9 ++++ src/host/configuration.h | 3 -- src/host/rpc_connection_manager.h | 18 +++++++- src/host/tls/openssl_server.h | 62 ++++++++++++++++++++++++-- src/host/tls/openssl_session_manager.h | 8 +++- 7 files changed, 98 insertions(+), 11 deletions(-) diff --git a/include/ccf/node/startup_config.h b/include/ccf/node/startup_config.h index 3d6cef413798..1dd33d0dfe32 100644 --- a/include/ccf/node/startup_config.h +++ b/include/ccf/node/startup_config.h @@ -29,6 +29,12 @@ namespace ccf ccf::ds::SizeString historical_cache_soft_limit = {"512MB"}; + // How long an idle RPC (client TLS) connection is kept before it is closed. + // std::nullopt disables idle closure (connections are never closed for + // being idle). + std::optional idle_connection_timeout = + ccf::ds::TimeString("60s"); + ccf::consensus::Configuration consensus = {}; ccf::NodeInfoNetwork network; diff --git a/src/common/configuration.h b/src/common/configuration.h index d11b775b26e6..05d47975f0ae 100644 --- a/src/common/configuration.h +++ b/src/common/configuration.h @@ -143,7 +143,8 @@ namespace ccf files_cleanup, node_to_node_message_limit, historical_cache_soft_limit, - identity_history_fetch); + identity_history_fetch, + idle_connection_timeout); DECLARE_JSON_TYPE_WITH_OPTIONAL_FIELDS(RecoveryDecisionProtocolConfig); DECLARE_JSON_REQUIRED_FIELDS( diff --git a/src/enclave/enclave.h b/src/enclave/enclave.h index 76950b8cca82..fc4a7f9986c9 100644 --- a/src/enclave/enclave.h +++ b/src/enclave/enclave.h @@ -202,6 +202,15 @@ namespace ccf rpcsessions->update_listening_interface_options(ccf_config_.network); + // Idle RPC connections are closed after this period (nullopt = never). + std::optional rpc_idle_timeout; + if (ccf_config_.idle_connection_timeout.has_value()) + { + rpc_idle_timeout = std::chrono::milliseconds( + ccf_config_.idle_connection_timeout->count_ms()); + } + rpcsessions->set_idle_connection_timeout(rpc_idle_timeout); + // Bind and start listening on each configured RPC interface. TLS is now // terminated in the connection: an interface whose certificate is not yet // available refuses connections until set_*_cert provides one (a joining diff --git a/src/host/configuration.h b/src/host/configuration.h index e19e5bcdfad8..5e702684948d 100644 --- a/src/host/configuration.h +++ b/src/host/configuration.h @@ -44,8 +44,6 @@ namespace host ccf::ds::TimeString slow_io_logging_threshold = {"10ms"}; std::optional node_client_interface = std::nullopt; ccf::ds::TimeString client_connection_timeout = {"2000ms"}; - std::optional idle_connection_timeout = - ccf::ds::TimeString("60s"); std::optional node_data_json_file = std::nullopt; std::optional service_data_json_file = std::nullopt; bool ignore_first_sigterm = false; @@ -187,7 +185,6 @@ namespace host slow_io_logging_threshold, node_client_interface, client_connection_timeout, - idle_connection_timeout, node_data_json_file, service_data_json_file, ignore_first_sigterm, diff --git a/src/host/rpc_connection_manager.h b/src/host/rpc_connection_manager.h index 3db1f60403a1..971549381ff4 100644 --- a/src/host/rpc_connection_manager.h +++ b/src/host/rpc_connection_manager.h @@ -31,9 +31,11 @@ #include "tls/cert.h" #include +#include #include #include #include +#include #include #include #include @@ -92,6 +94,10 @@ namespace ccf // convention relied upon by forwarding. std::atomic next_client_id{-1}; + // How long an idle connection is kept before being closed (nullopt = + // never). Applied to each interface transport at listen() time. + std::optional idle_connection_timeout; + std::shared_ptr<::http::ErrorReporter> error_reporter() { return shared_from_this(); @@ -297,7 +303,8 @@ namespace ccf plaintext, false, &shared_conn_id, - on_closed); + on_closed, + idle_connection_timeout); li->bridge->start(); return li->bridge->port(); } @@ -489,6 +496,15 @@ namespace ccf } } + // Set the idle-connection timeout applied to interfaces bound after this + // call (nullopt disables idle closure). Call before listen(). + void set_idle_connection_timeout( + std::optional timeout) + { + std::lock_guard guard(interfaces_mutex); + idle_connection_timeout = timeout; + } + void update_listening_interface_options( const ccf::NodeInfoNetwork& node_info) override { diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index df9e9c3d650b..43968667980f 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -39,6 +40,7 @@ #include #include #include +#include #include #include #include @@ -98,6 +100,9 @@ namespace asynchost // been fully written, so a response queued just before close is not // truncated. bool close_after_flush = false; + // Last time any I/O happened on this connection; used for idle timeout. + std::chrono::steady_clock::time_point last_active = + std::chrono::steady_clock::now(); }; SSL_CTX* ctx = nullptr; @@ -123,6 +128,13 @@ namespace asynchost // global session registry and reply routing. std::atomic* shared_next_id = nullptr; + // Close a connection after this much inactivity (no I/O); nullopt disables + // idle closure. The loop wakes every idle_sweep_interval_ms to check. + static constexpr int idle_sweep_interval_ms = 1000; + std::optional idle_timeout; + std::chrono::steady_clock::time_point last_idle_sweep = + std::chrono::steady_clock::now(); + // Cross-thread outbound queue: send()/close_connection() append here from // any thread and wake the loop, which drains it on the epoll thread. struct OutItem @@ -588,6 +600,7 @@ namespace asynchost return; } Conn& c = *it->second; + c.last_active = std::chrono::steady_clock::now(); bool alive = true; if (c.state == Conn::Handshaking) { @@ -697,6 +710,7 @@ namespace asynchost } Conn& c = *cit->second; c.outbuf.insert(c.outbuf.end(), item.data.begin(), item.data.end()); + c.last_active = std::chrono::steady_clock::now(); if (!do_write(c)) { close_conn(fd); @@ -826,14 +840,40 @@ namespace asynchost id_to_fd.emplace(id, cfd); } + // Close connections idle longer than idle_timeout (loop thread). + void sweep_idle() + { + if (!idle_timeout.has_value()) + { + return; + } + const auto now = std::chrono::steady_clock::now(); + std::vector to_close; + for (const auto& [fd, c] : conns) + { + if (now - c->last_active > *idle_timeout) + { + to_close.push_back(fd); + } + } + for (const int fd : to_close) + { + logf("closing idle connection on fd %d", fd); + close_conn(fd); + } + } + void run() { constexpr int max_events = 64; + // With an idle timeout configured, wake periodically to sweep idle + // connections; otherwise block until there is work. + const int wait_ms = + idle_timeout.has_value() ? idle_sweep_interval_ms : -1; std::vector events(max_events); while (running.load()) { - const int n = - epoll_wait(epoll_fd, events.data(), max_events, /*timeout*/ -1); + const int n = epoll_wait(epoll_fd, events.data(), max_events, wait_ms); if (n < 0) { if (errno == EINTR) @@ -864,6 +904,18 @@ namespace asynchost } on_conn_event(fd, events[i].events); } + + if (idle_timeout.has_value()) + { + const auto now = std::chrono::steady_clock::now(); + if ( + now - last_idle_sweep >= + std::chrono::milliseconds(idle_sweep_interval_ms)) + { + last_idle_sweep = now; + sweep_idle(); + } + } } // Tear down all live connections on the loop thread. @@ -884,12 +936,14 @@ namespace asynchost const std::string& alpn = "", bool plaintext_ = false, bool verbose_ = false, - std::atomic* shared_next_id_ = nullptr) : + std::atomic* shared_next_id_ = nullptr, + std::optional idle_timeout_ = std::nullopt) : plaintext(plaintext_), on_data(std::move(on_data_)), on_close(std::move(on_close_)), verbose(verbose_), - shared_next_id(shared_next_id_) + shared_next_id(shared_next_id_), + idle_timeout(idle_timeout_) { if (!alpn.empty()) { diff --git a/src/host/tls/openssl_session_manager.h b/src/host/tls/openssl_session_manager.h index 9d87081717a3..17e62ec05040 100644 --- a/src/host/tls/openssl_session_manager.h +++ b/src/host/tls/openssl_session_manager.h @@ -27,10 +27,12 @@ #include "host/tls/openssl_server.h" #include +#include #include #include #include #include +#include #include #include @@ -118,7 +120,8 @@ namespace asynchost bool plaintext = false, bool verbose = false, std::atomic* shared_next_id = nullptr, - std::function on_session_closed_ = {}) : + std::function on_session_closed_ = {}, + std::optional idle_timeout = std::nullopt) : factory(std::move(factory_)), on_session_closed(std::move(on_session_closed_)) { @@ -134,7 +137,8 @@ namespace asynchost alpn, plaintext, verbose, - shared_next_id); + shared_next_id, + idle_timeout); } // The session for `id`, or nullptr. Thread-safe. From 6591f98eb185551bbd3b9282e5da79a5c69408bd Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Tue, 30 Jun 2026 16:15:48 +0000 Subject: [PATCH 15/59] Restore custom protocol sessions on OpenSSL RPC path --- .../custom_protocol_subsystem_interface.h | 7 +- src/enclave/session_writer.h | 12 +- src/host/datagram_echo_session.h | 41 +++ src/host/datagram_server.h | 43 ++-- src/host/rpc_connection_manager.h | 236 ++++++++++++++++-- src/node/rpc/custom_protocol_subsystem.h | 4 +- 6 files changed, 291 insertions(+), 52 deletions(-) create mode 100644 src/host/datagram_echo_session.h diff --git a/include/ccf/research/custom_protocol_subsystem_interface.h b/include/ccf/research/custom_protocol_subsystem_interface.h index 53092b6340b2..57069e74deb8 100644 --- a/include/ccf/research/custom_protocol_subsystem_interface.h +++ b/include/ccf/research/custom_protocol_subsystem_interface.h @@ -14,9 +14,10 @@ namespace ccf { + class SessionWriter; + namespace tls { - class Context; using ConnID = int64_t; } @@ -24,7 +25,7 @@ namespace ccf { public: using CreateSessionFn = std::function( - ccf::tls::ConnID, const std::unique_ptr&&)>; + ccf::tls::ConnID, ccf::SessionWriter&)>; ~CustomProtocolSubsystemInterface() override = default; @@ -41,7 +42,7 @@ namespace ccf virtual std::shared_ptr create_session( const std::string& protocol_name, ccf::tls::ConnID conn_id, - const std::unique_ptr&& ctx) = 0; + ccf::SessionWriter& writer) = 0; struct Essentials { diff --git a/src/enclave/session_writer.h b/src/enclave/session_writer.h index 69ff77291d53..1b78d02097f4 100644 --- a/src/enclave/session_writer.h +++ b/src/enclave/session_writer.h @@ -13,11 +13,11 @@ namespace ccf { // Abstract output sink injected into Sessions: a Session hands its outbound // bytes (and connection-teardown requests) to a SessionWriter, which is - // implemented by the host-side RPCConnectionManager. + // implemented by the RPC transport. // - // IMPORTANT: Sessions invoke these methods from worker threads (see - // ccf::ThreadedSession / OrderedTasks), so implementations MUST be - // thread-safe and must marshal any socket operations onto their I/O thread. + // IMPORTANT: Sessions may invoke these methods from worker threads, so + // implementations MUST be thread-safe and must marshal any socket operations + // onto their I/O thread if required. class SessionWriter { public: @@ -35,7 +35,9 @@ namespace ccf // (tracking per-connection queued bytes) and return a writable/would-block // status here. virtual void write_outbound( - ::tcp::ConnID id, std::span data, sockaddr addr = {}) = 0; + ::tcp::ConnID id, + std::span data, + sockaddr addr = {}) = 0; // Tear down the connection: stop the underlying socket and drop the // session. diff --git a/src/host/datagram_echo_session.h b/src/host/datagram_echo_session.h new file mode 100644 index 000000000000..e6d227f59d20 --- /dev/null +++ b/src/host/datagram_echo_session.h @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +#include "ccf/node/session.h" +#include "enclave/session_writer.h" + +#include +#include + +namespace ccf +{ + class DatagramEchoSession : public Session + { + private: + ::tcp::ConnID session_id; + ccf::SessionWriter& writer; + + public: + DatagramEchoSession(::tcp::ConnID session_id_, ccf::SessionWriter& writer_) : + session_id(session_id_), + writer(writer_) + {} + + void handle_incoming_data( + std::span data, sockaddr addr = {}) override + { + writer.write_outbound(session_id, data, addr); + } + + void send_data(std::vector&& data) override + { + writer.write_outbound(session_id, {data.data(), data.size()}); + } + + void close_session() override + { + writer.close_socket(session_id); + } + }; +} \ No newline at end of file diff --git a/src/host/datagram_server.h b/src/host/datagram_server.h index 7fa663475f41..8ac06780dcde 100644 --- a/src/host/datagram_server.h +++ b/src/host/datagram_server.h @@ -3,9 +3,8 @@ #pragma once // A minimal UDP datagram server: it owns a SOCK_DGRAM socket in its own epoll -// loop and delivers each received datagram to a handler, which may reply to the -// sender. It backs the plaintext "UDP echo" interface (the only goal here is -// behavioural compatibility, i.e. the existing udp echo test). +// loop and delivers each received datagram to a handler. It backs UDP +// interfaces, leaving protocol behaviour to its handler. // // =========================================================================== // QUIC EXTENSION POINT @@ -56,12 +55,12 @@ namespace asynchost class DatagramServer { public: - // Reply to the sender of the datagram currently being handled. - using Reply = std::function; - // Invoked on the loop thread for each received datagram. - using OnDatagram = - std::function; + using OnDatagram = std::function; private: // Max UDP payload (theoretical IPv4 limit); datagrams are read whole. @@ -116,15 +115,10 @@ namespace asynchost if (on_datagram) { // === QUIC EXTENSION POINT === - // A QUIC server would not reply with a raw sendto; it would feed the - // received bytes to OpenSSL (SSL_handle_events) and write responses - // with SSL_write_ex() on accepted streams. `peer` is the source - // address that SSL_set1_initial_peer_addr() consumes. - Reply reply = [this, &peer, peerlen](const uint8_t* d, size_t l) { - ::sendto( - sock, d, l, 0, reinterpret_cast(&peer), peerlen); - }; - on_datagram(buf, static_cast(n), reply); + // A QUIC server would feed the received bytes to OpenSSL + // (SSL_handle_events). `peer` is the source address that + // SSL_set1_initial_peer_addr() consumes. + on_datagram(buf, static_cast(n), peer, peerlen); } } } @@ -285,6 +279,21 @@ namespace asynchost return bound_port; } + void send_to( + const sockaddr_storage& peer, + socklen_t peerlen, + const uint8_t* data, + size_t len) + { + ::sendto( + sock, + data, + len, + 0, + reinterpret_cast(&peer), + peerlen); + } + private: void cleanup() { diff --git a/src/host/rpc_connection_manager.h b/src/host/rpc_connection_manager.h index 971549381ff4..32555eb40d0c 100644 --- a/src/host/rpc_connection_manager.h +++ b/src/host/rpc_connection_manager.h @@ -22,16 +22,21 @@ #include "enclave/abstract_rpc_sessions.h" #include "enclave/no_more_sessions.h" #include "enclave/rpc_map.h" +#include "host/datagram_echo_session.h" #include "host/datagram_server.h" #include "host/tls/openssl_session_manager.h" #include "http/error_reporter.h" #include "http/http2_session.h" #include "http/http_session.h" +#include "node/rpc/custom_protocol_subsystem.h" #include "node/session_metrics.h" #include "tls/cert.h" +#include #include #include +#include +#include #include #include #include @@ -73,17 +78,52 @@ namespace ccf std::unique_ptr bridge; }; + class DatagramSessionWriter : public ccf::SessionWriter + { + private: + std::function)> write; + std::function close; + + public: + DatagramSessionWriter( + std::function)> write_, + std::function close_) : + write(std::move(write_)), + close(std::move(close_)) + {} + + void write_outbound( + ::tcp::ConnID id, + std::span data, + sockaddr /*addr*/ = {}) override + { + write(id, data); + } + + void close_socket(::tcp::ConnID id) override + { + close(id); + } + }; + + struct DatagramInterface + { + std::unique_ptr server; + std::unique_ptr writer; + std::map> sessions_by_peer; + std::map<::tcp::ConnID, std::string> peer_by_id; + }; + std::shared_ptr rpc_map; std::shared_ptr custom_protocol_subsystem; std::shared_ptr commit_callbacks_subsystem; std::mutex interfaces_mutex; std::map> interfaces; - // Plaintext UDP echo servers, keyed by interface name. UDP "QUIC" - // interfaces are served as a plaintext echo until OpenSSL-native QUIC is - // available (see host/datagram_server.h). - std::map> - udp_servers; + // UDP interface state, keyed by interface name. UDP "QUIC" interfaces use + // a built-in datagram echo session until OpenSSL-native QUIC is available; + // other UDP protocols are routed to custom sessions, one session per peer. + std::map> udp_interfaces; // cert/key PEM per endorsement authority (for cert-deferred listening). std::map> certs; @@ -103,6 +143,14 @@ namespace ccf return shared_from_this(); } + static std::string peer_key( + const sockaddr_storage& peer, socklen_t peerlen) + { + return std::string( + reinterpret_cast(&peer), + std::min(peerlen, sizeof(peer))); + } + // Build the protocol session for a connection on `li`, applying caps. // Returns nullptr to refuse (hard cap). Runs on the interface's loop // thread. @@ -175,9 +223,14 @@ namespace ccf error_reporter(), commit_callbacks_subsystem); } + if (custom_protocol_subsystem != nullptr) + { + return custom_protocol_subsystem->create_session( + li->app_protocol, conn_id, writer); + } throw std::runtime_error(fmt::format( - "Unsupported application protocol '{}' (custom protocols are not " - "supported on the OpenSSL RPC path)", + "Unknown application protocol '{}' and custom protocol subsystem " + "missing", li->app_protocol)); } @@ -221,6 +274,119 @@ namespace ccf return nullptr; } + void send_udp_reply( + const std::string& name, + ::tcp::ConnID id, + std::span data) + { + std::lock_guard guard(interfaces_mutex); + auto it = udp_interfaces.find(name); + if (it == udp_interfaces.end()) + { + return; + } + + auto kit = it->second->peer_by_id.find(id); + if (kit == it->second->peer_by_id.end()) + { + return; + } + + const auto& key = kit->second; + const auto peerlen = static_cast(key.size()); + sockaddr_storage peer{}; + std::memcpy(&peer, key.data(), std::min(key.size(), sizeof(peer))); + + it->second->server->send_to(peer, peerlen, data.data(), data.size()); + } + + void close_udp_session( + const std::string& name, ListenInterface* li, ::tcp::ConnID id) + { + std::lock_guard guard(interfaces_mutex); + auto it = udp_interfaces.find(name); + if (it == udp_interfaces.end()) + { + return; + } + + auto kit = it->second->peer_by_id.find(id); + if (kit == it->second->peer_by_id.end()) + { + return; + } + + it->second->sessions_by_peer.erase(kit->second); + it->second->peer_by_id.erase(kit); + size_t expected = li->open_sessions.load(); + while (expected > 0 && + !li->open_sessions.compare_exchange_weak(expected, expected - 1)) + {} + } + + std::shared_ptr get_or_create_udp_session( + ListenInterface* li, + DatagramInterface* udp, + ccf::SessionWriter& writer, + const sockaddr_storage& peer, + socklen_t peerlen) + { + std::lock_guard guard(interfaces_mutex); + const auto key = peer_key(peer, peerlen); + auto sit = udp->sessions_by_peer.find(key); + if (sit != udp->sessions_by_peer.end()) + { + return sit->second; + } + + if (li->app_protocol != "QUIC" && custom_protocol_subsystem == nullptr) + { + LOG_DEBUG_FMT( + "Unknown UDP protocol '{}' and custom protocol subsystem missing", + li->app_protocol); + return nullptr; + } + + const size_t open = li->open_sessions.load(); + if (open >= li->max_open_sessions_hard) + { + LOG_INFO_FMT( + "Refusing UDP session on interface {} - {} open, hard limit {}", + li->name, + open, + li->max_open_sessions_hard); + return nullptr; + } + + const auto conn_id = + static_cast<::tcp::ConnID>(shared_conn_id.fetch_add(1)); + std::shared_ptr session; + if (li->app_protocol == "QUIC") + { + session = std::make_shared(conn_id, writer); + } + else + { + session = custom_protocol_subsystem->create_session( + li->app_protocol, conn_id, writer); + } + + if (session == nullptr) + { + return nullptr; + } + + const size_t now_open = ++li->open_sessions; + size_t prev_peak = li->peak_sessions.load(); + while (now_open > prev_peak && + !li->peak_sessions.compare_exchange_weak(prev_peak, now_open)) + {} + + udp->peer_by_id.emplace(conn_id, key); + udp->sessions_by_peer.emplace(key, session); + return session; + } + public: explicit RPCConnectionManager(std::shared_ptr rpc_map_) : rpc_map(std::move(rpc_map_)) @@ -241,9 +407,9 @@ namespace ccf li->bridge->stop(); } } - for (auto& [name, server] : udp_servers) + for (auto& [name, interface] : udp_interfaces) { - server->stop(); + interface->server->stop(); } } @@ -309,8 +475,8 @@ namespace ccf return li->bridge->port(); } - // Bind and start a plaintext UDP echo server for `name` (interfaces with - // protocol "udp"). + // Bind and start a UDP listener for `name` (interfaces with protocol + // "udp"). Incoming datagrams are routed to a per-peer session. // // === QUIC EXTENSION POINT === // A real QUIC interface would, instead of echoing, hand each datagram to an @@ -320,19 +486,45 @@ namespace ccf const std::string& name, const std::string& host, const std::string& port) { std::lock_guard guard(interfaces_mutex); - auto server = std::make_unique( + auto li_it = interfaces.find(name); + if (li_it == interfaces.end()) + { + throw std::logic_error(fmt::format( + "Cannot listen on unconfigured UDP interface '{}'", name)); + } + auto* li = li_it->second.get(); + + auto udp = std::make_unique(); + auto* udp_ptr = udp.get(); + udp->writer = std::make_unique( + [this, name](::tcp::ConnID id, std::span data) { + send_udp_reply(name, id, data); + }, + [this, name, li](::tcp::ConnID id) { + close_udp_session(name, li, id); + }); + auto* writer = udp->writer.get(); + + udp->server = std::make_unique( host, static_cast(std::stoi(port)), - []( + [this, li, udp_ptr, writer]( const uint8_t* data, size_t len, - const asynchost::DatagramServer::Reply& reply) { - // Echo the datagram back to its sender. - reply(data, len); + const sockaddr_storage& peer, + socklen_t peerlen) { + auto session = + get_or_create_udp_session(li, udp_ptr, *writer, peer, peerlen); + if (session == nullptr) + { + return; + } + session->handle_incoming_data( + {data, len}, *reinterpret_cast(&peer)); }); - server->start(); - const uint16_t bound = server->port(); - udp_servers.emplace(name, std::move(server)); + udp->server->start(); + const uint16_t bound = udp->server->port(); + udp_interfaces.emplace(name, std::move(udp)); return bound; } @@ -511,12 +703,6 @@ namespace ccf std::lock_guard guard(interfaces_mutex); for (const auto& [name, interface] : node_info.rpc_interfaces) { - // UDP interfaces use the datagram echo path (listen_udp), not the TCP - // session machinery. - if (interface.protocol == "udp") - { - continue; - } auto it = interfaces.find(name); if (it == interfaces.end()) { diff --git a/src/node/rpc/custom_protocol_subsystem.h b/src/node/rpc/custom_protocol_subsystem.h index ef93a25d1d5c..892552026f4a 100644 --- a/src/node/rpc/custom_protocol_subsystem.h +++ b/src/node/rpc/custom_protocol_subsystem.h @@ -40,12 +40,12 @@ namespace ccf std::shared_ptr create_session( const std::string& protocol_name, ccf::tls::ConnID conn_id, - const std::unique_ptr&& ctx) override + ccf::SessionWriter& writer) override { auto it = session_creation_functions.find(protocol_name); if (it != session_creation_functions.end()) { - return it->second(conn_id, std::move(ctx)); + return it->second(conn_id, writer); } throw std::logic_error(fmt::format( "Session creation function for protocol '{}' has not been installed", From a0a4972bd41f0383a54e89e0962e03ab8b008917 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Tue, 30 Jun 2026 16:23:02 +0000 Subject: [PATCH 16/59] Format --- src/enclave/session_writer.h | 4 +--- src/host/datagram_echo_session.h | 3 ++- src/host/datagram_server.h | 7 +------ src/host/rpc_connection_manager.h | 13 ++++++------- 4 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/enclave/session_writer.h b/src/enclave/session_writer.h index 1b78d02097f4..2075a4d65aa3 100644 --- a/src/enclave/session_writer.h +++ b/src/enclave/session_writer.h @@ -35,9 +35,7 @@ namespace ccf // (tracking per-connection queued bytes) and return a writable/would-block // status here. virtual void write_outbound( - ::tcp::ConnID id, - std::span data, - sockaddr addr = {}) = 0; + ::tcp::ConnID id, std::span data, sockaddr addr = {}) = 0; // Tear down the connection: stop the underlying socket and drop the // session. diff --git a/src/host/datagram_echo_session.h b/src/host/datagram_echo_session.h index e6d227f59d20..f729ea77b804 100644 --- a/src/host/datagram_echo_session.h +++ b/src/host/datagram_echo_session.h @@ -17,7 +17,8 @@ namespace ccf ccf::SessionWriter& writer; public: - DatagramEchoSession(::tcp::ConnID session_id_, ccf::SessionWriter& writer_) : + DatagramEchoSession( + ::tcp::ConnID session_id_, ccf::SessionWriter& writer_) : session_id(session_id_), writer(writer_) {} diff --git a/src/host/datagram_server.h b/src/host/datagram_server.h index 8ac06780dcde..926ee318e7a3 100644 --- a/src/host/datagram_server.h +++ b/src/host/datagram_server.h @@ -286,12 +286,7 @@ namespace asynchost size_t len) { ::sendto( - sock, - data, - len, - 0, - reinterpret_cast(&peer), - peerlen); + sock, data, len, 0, reinterpret_cast(&peer), peerlen); } private: diff --git a/src/host/rpc_connection_manager.h b/src/host/rpc_connection_manager.h index 32555eb40d0c..346955853a7b 100644 --- a/src/host/rpc_connection_manager.h +++ b/src/host/rpc_connection_manager.h @@ -143,8 +143,7 @@ namespace ccf return shared_from_this(); } - static std::string peer_key( - const sockaddr_storage& peer, socklen_t peerlen) + static std::string peer_key(const sockaddr_storage& peer, socklen_t peerlen) { return std::string( reinterpret_cast(&peer), @@ -275,9 +274,7 @@ namespace ccf } void send_udp_reply( - const std::string& name, - ::tcp::ConnID id, - std::span data) + const std::string& name, ::tcp::ConnID id, std::span data) { std::lock_guard guard(interfaces_mutex); auto it = udp_interfaces.find(name); @@ -321,7 +318,8 @@ namespace ccf size_t expected = li->open_sessions.load(); while (expected > 0 && !li->open_sessions.compare_exchange_weak(expected, expected - 1)) - {} + { + } } std::shared_ptr get_or_create_udp_session( @@ -380,7 +378,8 @@ namespace ccf size_t prev_peak = li->peak_sessions.load(); while (now_open > prev_peak && !li->peak_sessions.compare_exchange_weak(prev_peak, now_open)) - {} + { + } udp->peer_by_id.emplace(conn_id, key); udp->sessions_by_peer.emplace(key, session); From 61be04be6a42b823ba2ebf80a9b2ab52f3445632 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Tue, 30 Jun 2026 16:47:39 +0000 Subject: [PATCH 17/59] Fix RPC session metrics for session-initiated closes --- src/host/rpc_connection_manager.h | 34 ++++++++++++++++++++------ src/host/tls/openssl_session_manager.h | 7 +++++- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/host/rpc_connection_manager.h b/src/host/rpc_connection_manager.h index 346955853a7b..b3751417ace6 100644 --- a/src/host/rpc_connection_manager.h +++ b/src/host/rpc_connection_manager.h @@ -130,6 +130,8 @@ namespace ccf // Global connection-id source shared by all interface transports, so the // session registry / reply routing have a single id space. std::atomic shared_conn_id{1}; + std::atomic active_sessions{0}; + std::atomic peak_sessions{0}; // Outbound client sessions use the negative range, matching the historical // convention relied upon by forwarding. std::atomic next_client_id{-1}; @@ -150,6 +152,23 @@ namespace ccf std::min(peerlen, sizeof(peer))); } + void increment_active_sessions() + { + const size_t now_active = ++active_sessions; + size_t prev_peak = peak_sessions.load(); + while (now_active > prev_peak && + !peak_sessions.compare_exchange_weak(prev_peak, now_active)) + {} + } + + void decrement_active_sessions() + { + size_t expected = active_sessions.load(); + while (expected > 0 && + !active_sessions.compare_exchange_weak(expected, expected - 1)) + {} + } + // Build the protocol session for a connection on `li`, applying caps. // Returns nullptr to refuse (hard cap). Runs on the interface's loop // thread. @@ -177,6 +196,7 @@ namespace ccf !li->peak_sessions.compare_exchange_weak(prev_peak, now_open)) { } + increment_active_sessions(); if (open >= li->max_open_sessions_soft) { @@ -320,6 +340,7 @@ namespace ccf !li->open_sessions.compare_exchange_weak(expected, expected - 1)) { } + decrement_active_sessions(); } std::shared_ptr get_or_create_udp_session( @@ -380,6 +401,7 @@ namespace ccf !li->peak_sessions.compare_exchange_weak(prev_peak, now_open)) { } + increment_active_sessions(); udp->peer_by_id.emplace(conn_id, key); udp->sessions_by_peer.emplace(key, session); @@ -449,7 +471,8 @@ namespace ccf ::tcp::ConnID cid, ccf::SessionWriter& w, std::vector pc) { return make_session(li, cid, w, std::move(pc)); }; - auto on_closed = [li](::tcp::ConnID) { + auto on_closed = [this, li](::tcp::ConnID) { + decrement_active_sessions(); size_t expected = li->open_sessions.load(); while (expected > 0 && !li->open_sessions.compare_exchange_weak(expected, expected - 1)) @@ -635,8 +658,6 @@ namespace ccf { ccf::SessionMetrics sm; std::lock_guard guard(interfaces_mutex); - size_t active = 0; - size_t peak = 0; for (auto& [name, li] : interfaces) { ccf::SessionMetrics::Errors errs; @@ -650,12 +671,9 @@ namespace ccf li->max_open_sessions_soft, li->max_open_sessions_hard, errs}; - - active += li->open_sessions.load(); - peak += li->peak_sessions.load(); } - sm.active = active; - sm.peak = peak; + sm.active = active_sessions.load(); + sm.peak = peak_sessions.load(); return sm; } diff --git a/src/host/tls/openssl_session_manager.h b/src/host/tls/openssl_session_manager.h index 17e62ec05040..7a643b7deebf 100644 --- a/src/host/tls/openssl_session_manager.h +++ b/src/host/tls/openssl_session_manager.h @@ -205,9 +205,14 @@ namespace asynchost void close_socket(::tcp::ConnID id) override { + bool had_session = false; { std::lock_guard guard(sessions_mutex); - sessions.erase(id); + had_session = sessions.erase(id) > 0; + } + if (had_session && on_session_closed) + { + on_session_closed(id); } server->close_connection(static_cast(id)); } From 3d5b4c2b08c133c7916879cb1841763b81cbfa13 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Wed, 1 Jul 2026 09:37:09 +0000 Subject: [PATCH 18/59] Address RPC transport review feedback --- src/enclave/abstract_rpc_sessions.h | 2 +- src/host/datagram_server.h | 35 ++++++-- src/host/rpc_connection_manager.h | 98 +++++++++++++--------- src/host/tls/openssl_server.h | 108 ++++++++++++++++++------- src/host/tls/openssl_session_manager.h | 23 +++--- 5 files changed, 178 insertions(+), 88 deletions(-) diff --git a/src/enclave/abstract_rpc_sessions.h b/src/enclave/abstract_rpc_sessions.h index 0680583644a4..90e25ac5933c 100644 --- a/src/enclave/abstract_rpc_sessions.h +++ b/src/enclave/abstract_rpc_sessions.h @@ -35,7 +35,7 @@ namespace ccf const std::shared_ptr<::tls::Cert>& cert, const std::string& app_protocol = "HTTP1") = 0; - virtual ccf::ApplicationProtocol get_app_protocol_main_interface() + [[nodiscard]] virtual ccf::ApplicationProtocol get_app_protocol_main_interface() const = 0; virtual ccf::SessionMetrics get_session_metrics() = 0; diff --git a/src/host/datagram_server.h b/src/host/datagram_server.h index 926ee318e7a3..20a691d7a618 100644 --- a/src/host/datagram_server.h +++ b/src/host/datagram_server.h @@ -183,8 +183,18 @@ namespace asynchost { continue; } - setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); - setsockopt(sock, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)); + if (setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)) != 0) + { + ::close(sock); + sock = -1; + continue; + } + if (setsockopt(sock, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)) != 0) + { + ::close(sock); + sock = -1; + continue; + } if (::bind(sock, ai->ai_addr, ai->ai_addrlen) == 0) { bound = true; @@ -231,9 +241,17 @@ namespace asynchost epoll_event ev{}; ev.events = EPOLLIN; ev.data.fd = sock; - epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sock, &ev); + if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sock, &ev) != 0) + { + cleanup(); + throw std::runtime_error("epoll_ctl(sock udp) failed"); + } ev.data.fd = stop_fd; - epoll_ctl(epoll_fd, EPOLL_CTL_ADD, stop_fd, &ev); + if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, stop_fd, &ev) != 0) + { + cleanup(); + throw std::runtime_error("epoll_ctl(stop udp) failed"); + } } DatagramServer(const DatagramServer&) = delete; @@ -274,19 +292,20 @@ namespace asynchost } } - uint16_t port() const + [[nodiscard]] uint16_t port() const { return bound_port; } - void send_to( + [[nodiscard]] bool send_to( const sockaddr_storage& peer, socklen_t peerlen, const uint8_t* data, - size_t len) + size_t len) const { - ::sendto( + const auto rc = ::sendto( sock, data, len, 0, reinterpret_cast(&peer), peerlen); + return rc >= 0; } private: diff --git a/src/host/rpc_connection_manager.h b/src/host/rpc_connection_manager.h index b3751417ace6..7ca38cbbf2ea 100644 --- a/src/host/rpc_connection_manager.h +++ b/src/host/rpc_connection_manager.h @@ -129,7 +129,7 @@ namespace ccf // Global connection-id source shared by all interface transports, so the // session registry / reply routing have a single id space. - std::atomic shared_conn_id{1}; + std::atomic<::tcp::ConnID> shared_conn_id{1}; std::atomic active_sessions{0}; std::atomic peak_sessions{0}; // Outbound client sessions use the negative range, matching the historical @@ -147,9 +147,9 @@ namespace ccf static std::string peer_key(const sockaddr_storage& peer, socklen_t peerlen) { - return std::string( + return { reinterpret_cast(&peer), - std::min(peerlen, sizeof(peer))); + std::min(peerlen, sizeof(peer))}; } void increment_active_sessions() @@ -169,6 +169,22 @@ namespace ccf {} } + void increment_interface_peak(ListenInterface* li, size_t now_open) + { + size_t prev_peak = li->peak_sessions.load(); + while (now_open > prev_peak && + !li->peak_sessions.compare_exchange_weak(prev_peak, now_open)) + {} + } + + void decrement_interface_sessions(ListenInterface* li) + { + size_t expected = li->open_sessions.load(); + while (expected > 0 && + !li->open_sessions.compare_exchange_weak(expected, expected - 1)) + {} + } + // Build the protocol session for a connection on `li`, applying caps. // Returns nullptr to refuse (hard cap). Runs on the interface's loop // thread. @@ -178,9 +194,10 @@ namespace ccf ccf::SessionWriter& writer, std::vector peer_cert) { - const size_t open = li->open_sessions.load(); + const size_t open = li->open_sessions.fetch_add(1); if (open >= li->max_open_sessions_hard) { + decrement_interface_sessions(li); LOG_INFO_FMT( "Refusing session {} on interface {} - {} open, hard limit {}", conn_id, @@ -190,13 +207,9 @@ namespace ccf return nullptr; } - const size_t now_open = ++li->open_sessions; - size_t prev_peak = li->peak_sessions.load(); - while (now_open > prev_peak && - !li->peak_sessions.compare_exchange_weak(prev_peak, now_open)) - { - } - increment_active_sessions(); + const size_t now_open = open + 1; + increment_interface_peak(li, now_open); + increment_active_sessions(); if (open >= li->max_open_sessions_soft) { @@ -210,7 +223,16 @@ namespace ccf return make_capped_session(li, conn_id, writer, std::move(peer_cert)); } - return make_server_session(li, conn_id, writer, std::move(peer_cert)); + try + { + return make_server_session(li, conn_id, writer, std::move(peer_cert)); + } + catch (...) + { + decrement_interface_sessions(li); + decrement_active_sessions(); + throw; + } } std::shared_ptr make_server_session( @@ -314,7 +336,10 @@ namespace ccf sockaddr_storage peer{}; std::memcpy(&peer, key.data(), std::min(key.size(), sizeof(peer))); - it->second->server->send_to(peer, peerlen, data.data(), data.size()); + if (!it->second->server->send_to(peer, peerlen, data.data(), data.size())) + { + LOG_DEBUG_FMT("Failed to send UDP reply on interface {}", name); + } } void close_udp_session( @@ -335,12 +360,8 @@ namespace ccf it->second->sessions_by_peer.erase(kit->second); it->second->peer_by_id.erase(kit); - size_t expected = li->open_sessions.load(); - while (expected > 0 && - !li->open_sessions.compare_exchange_weak(expected, expected - 1)) - { - } - decrement_active_sessions(); + decrement_interface_sessions(li); + decrement_active_sessions(); } std::shared_ptr get_or_create_udp_session( @@ -366,9 +387,10 @@ namespace ccf return nullptr; } - const size_t open = li->open_sessions.load(); + const size_t open = li->open_sessions.fetch_add(1); if (open >= li->max_open_sessions_hard) { + decrement_interface_sessions(li); LOG_INFO_FMT( "Refusing UDP session on interface {} - {} open, hard limit {}", li->name, @@ -376,6 +398,9 @@ namespace ccf li->max_open_sessions_hard); return nullptr; } + const size_t now_open = open + 1; + increment_interface_peak(li, now_open); + increment_active_sessions(); const auto conn_id = static_cast<::tcp::ConnID>(shared_conn_id.fetch_add(1)); @@ -386,23 +411,26 @@ namespace ccf } else { - session = custom_protocol_subsystem->create_session( - li->app_protocol, conn_id, writer); + try + { + session = custom_protocol_subsystem->create_session( + li->app_protocol, conn_id, writer); + } + catch (...) + { + decrement_interface_sessions(li); + decrement_active_sessions(); + throw; + } } if (session == nullptr) { + decrement_interface_sessions(li); + decrement_active_sessions(); return nullptr; } - const size_t now_open = ++li->open_sessions; - size_t prev_peak = li->peak_sessions.load(); - while (now_open > prev_peak && - !li->peak_sessions.compare_exchange_weak(prev_peak, now_open)) - { - } - increment_active_sessions(); - udp->peer_by_id.emplace(conn_id, key); udp->sessions_by_peer.emplace(key, session); return session; @@ -473,14 +501,10 @@ namespace ccf }; auto on_closed = [this, li](::tcp::ConnID) { decrement_active_sessions(); - size_t expected = li->open_sessions.load(); - while (expected > 0 && - !li->open_sessions.compare_exchange_weak(expected, expected - 1)) - { - } + decrement_interface_sessions(li); }; - const uint16_t port_num = static_cast(std::stoi(port)); + const auto port_num = static_cast(std::stoi(port)); li->bridge = std::make_unique( cert_pem, key_pem, @@ -660,7 +684,7 @@ namespace ccf std::lock_guard guard(interfaces_mutex); for (auto& [name, li] : interfaces) { - ccf::SessionMetrics::Errors errs; + ccf::SessionMetrics::Errors errs{}; errs.parsing = li->err_parsing.load(); errs.request_payload_too_large = li->err_payload_too_large.load(); errs.request_header_too_large = li->err_header_too_large.load(); diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index 43968667980f..af2e0ba1055e 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -43,9 +43,11 @@ #include #include #include +#include #include #include #include +#include "tcp/msg_types.h" #include #include #include @@ -62,12 +64,12 @@ namespace asynchost // (e.g. OrderedTasks) and later calls send()/close_connection() from that // thread - both are thread-safe and wake the loop. using OnData = - std::function data)>; + std::function data)>; // Invoked on the epoll thread when a connection is torn down (peer // disconnect, error, or close_connection()). Lets an owner drop per- // connection state. - using OnClose = std::function; + using OnClose = std::function; // Configures an outbound client SSL/SSL_CTX (peer CA verification, the // client certificate to present, SNI). Supplied per-connect so each client @@ -81,7 +83,7 @@ namespace asynchost { int fd = -1; SSL* ssl = nullptr; - uint64_t id = 0; + ::tcp::ConnID id = 0; enum State : uint8_t { Handshaking, @@ -121,12 +123,12 @@ namespace asynchost bool verbose = false; std::unordered_map> conns; - std::unordered_map id_to_fd; - uint64_t next_id = 1; + std::unordered_map<::tcp::ConnID, int> id_to_fd; + ::tcp::ConnID next_id = 1; // Optional shared id source so multiple servers (one per interface) // allocate connection ids from a single global space - required for a // global session registry and reply routing. - std::atomic* shared_next_id = nullptr; + std::atomic<::tcp::ConnID>* shared_next_id = nullptr; // Close a connection after this much inactivity (no I/O); nullopt disables // idle closure. The loop wakes every idle_sweep_interval_ms to check. @@ -139,7 +141,7 @@ namespace asynchost // any thread and wake the loop, which drains it on the epoll thread. struct OutItem { - uint64_t id = 0; + ::tcp::ConnID id = 0; std::vector data; bool close = false; }; @@ -149,7 +151,7 @@ namespace asynchost // Cross-thread outbound connect requests (for client sessions). struct ConnectReq { - int64_t id = 0; + ::tcp::ConnID id = 0; std::string host; std::string port; ConfigureClientSSL configure; @@ -171,8 +173,8 @@ namespace asynchost } va_list args; // NOLINT va_start(args, fmt); - std::vfprintf(stderr, fmt, args); - std::fputc('\n', stderr); + (void)std::vfprintf(stderr, fmt, args); + (void)std::fputc('\n', stderr); va_end(args); } @@ -240,7 +242,11 @@ namespace asynchost { return nullptr; } - SSL_CTX_set_min_proto_version(c, TLS1_2_VERSION); + if (SSL_CTX_set_min_proto_version(c, TLS1_2_VERSION) != 1) + { + SSL_CTX_free(c); + return nullptr; + } // Request the client certificate during the handshake so it can be used // for application-level caller authentication (user/member cert auth). // Verification is not enforced here - the application decides. @@ -263,7 +269,13 @@ namespace asynchost epoll_event ev{}; ev.data.fd = c.fd; ev.events = EPOLLIN | (c.want_write ? EPOLLOUT : 0); - epoll_ctl(epoll_fd, EPOLL_CTL_MOD, c.fd, &ev); + if (epoll_ctl(epoll_fd, EPOLL_CTL_MOD, c.fd, &ev) != 0) + { + const auto err = errno; + logf( + "epoll_ctl MOD error: %s", + std::generic_category().message(err).c_str()); + } } // After writing, tear the connection down if a graceful close was requested @@ -530,7 +542,10 @@ namespace asynchost { continue; } - logf("accept error: %s", std::strerror(errno)); + const auto err = errno; + logf( + "accept error: %s", + std::generic_category().message(err).c_str()); break; } @@ -585,7 +600,7 @@ namespace asynchost ::close(cfd); continue; } - const uint64_t cid = c->id; + const auto cid = c->id; conns.emplace(cfd, std::move(c)); id_to_fd.emplace(cid, cfd); logf("accepted conn on fd %d", cfd); @@ -723,7 +738,7 @@ namespace asynchost // Open an outbound client connection for `id` (loop thread). TLS client // handshake is driven by the normal epoll state machine (is_client). void do_connect( - int64_t id, + ::tcp::ConnID id, const std::string& host, const std::string& port, const ConfigureClientSSL& configure) @@ -731,7 +746,7 @@ namespace asynchost auto fail = [&]() { if (on_close) { - on_close(static_cast(id)); + on_close(id); } }; @@ -772,7 +787,13 @@ namespace asynchost fail(); return; } - SSL_CTX_set_min_proto_version(cctx, TLS1_2_VERSION); + if (SSL_CTX_set_min_proto_version(cctx, TLS1_2_VERSION) != 1) + { + SSL_CTX_free(cctx); + ::close(cfd); + fail(); + return; + } SSL* ssl = SSL_new(cctx); if (ssl == nullptr) @@ -823,7 +844,7 @@ namespace asynchost auto c = std::make_unique(); c->fd = cfd; c->ssl = ssl; - c->id = static_cast(id); + c->id = id; c->is_client = true; epoll_event ev{}; @@ -880,7 +901,10 @@ namespace asynchost { continue; } - logf("epoll_wait error: %s", std::strerror(errno)); + const auto err = errno; + logf( + "epoll_wait error: %s", + std::generic_category().message(err).c_str()); break; } @@ -936,7 +960,7 @@ namespace asynchost const std::string& alpn = "", bool plaintext_ = false, bool verbose_ = false, - std::atomic* shared_next_id_ = nullptr, + std::atomic<::tcp::ConnID>* shared_next_id_ = nullptr, std::optional idle_timeout_ = std::nullopt) : plaintext(plaintext_), on_data(std::move(on_data_)), @@ -987,10 +1011,24 @@ namespace asynchost { continue; } - setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + if ( + setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)) != + 0) + { + ::close(listen_fd); + listen_fd = -1; + continue; + } // SO_REUSEPORT is the idiom that will let each worker run its own // listening socket + epoll loop in the production design. - setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)); + if ( + setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)) != + 0) + { + ::close(listen_fd); + listen_fd = -1; + continue; + } if (bind(listen_fd, ai->ai_addr, ai->ai_addrlen) == 0) { bound_ok = true; @@ -1055,11 +1093,23 @@ namespace asynchost epoll_event ev{}; ev.data.fd = listen_fd; ev.events = EPOLLIN; - epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev); + if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev) != 0) + { + cleanup(); + throw std::runtime_error("epoll_ctl(listen) failed"); + } ev.data.fd = stop_fd; - epoll_ctl(epoll_fd, EPOLL_CTL_ADD, stop_fd, &ev); + if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, stop_fd, &ev) != 0) + { + cleanup(); + throw std::runtime_error("epoll_ctl(stop) failed"); + } ev.data.fd = wake_fd; - epoll_ctl(epoll_fd, EPOLL_CTL_ADD, wake_fd, &ev); + if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, wake_fd, &ev) != 0) + { + cleanup(); + throw std::runtime_error("epoll_ctl(wake) failed"); + } } OpenSSLServer(const OpenSSLServer&) = delete; @@ -1106,7 +1156,7 @@ namespace asynchost } // Thread-safe. Queue plaintext to be encrypted and written to `conn_id`. - void send(uint64_t conn_id, const uint8_t* data, size_t len) + void send(::tcp::ConnID conn_id, const uint8_t* data, size_t len) { { std::lock_guard g(out_mutex); @@ -1117,7 +1167,7 @@ namespace asynchost } // Thread-safe. Request that `conn_id` be torn down. - void close_connection(uint64_t conn_id) + void close_connection(::tcp::ConnID conn_id) { { std::lock_guard g(out_mutex); @@ -1130,7 +1180,7 @@ namespace asynchost // `configure` sets up peer verification / client certificate on the new // connection (see ConfigureClientSSL). void connect( - int64_t id, + ::tcp::ConnID id, const std::string& host, const std::string& port, ConfigureClientSSL configure = {}) @@ -1157,7 +1207,7 @@ namespace asynchost // Peer certificate (DER) for `conn_id`, or empty. MUST be called on the // loop thread (e.g. synchronously from within the OnData callback). - std::vector get_peer_cert(uint64_t conn_id) + std::vector get_peer_cert(::tcp::ConnID conn_id) { auto fit = id_to_fd.find(conn_id); if (fit == id_to_fd.end()) diff --git a/src/host/tls/openssl_session_manager.h b/src/host/tls/openssl_session_manager.h index 7a643b7deebf..4093c86fcc15 100644 --- a/src/host/tls/openssl_session_manager.h +++ b/src/host/tls/openssl_session_manager.h @@ -60,9 +60,8 @@ namespace asynchost std::mutex sessions_mutex; std::unordered_map<::tcp::ConnID, std::shared_ptr> sessions; - void on_data(uint64_t id, std::vector data) + void on_data(::tcp::ConnID conn_id, std::vector data) { - const auto conn_id = static_cast<::tcp::ConnID>(id); std::shared_ptr session; { std::lock_guard guard(sessions_mutex); @@ -72,13 +71,13 @@ namespace asynchost // Lazily create the session for a newly accepted connection. The // peer certificate is fetched here (on the loop thread) from the // handshaken connection. - auto peer_cert = server->get_peer_cert(id); + auto peer_cert = server->get_peer_cert(conn_id); session = factory(conn_id, *this, std::move(peer_cert)); if (session == nullptr) { // Factory refused (e.g. hard session cap) - tear the connection // down. - server->close_connection(id); + server->close_connection(conn_id); return; } sessions.emplace(conn_id, session); @@ -95,9 +94,8 @@ namespace asynchost } } - void on_close(uint64_t id) + void on_close(::tcp::ConnID conn_id) { - const auto conn_id = static_cast<::tcp::ConnID>(id); bool had_session = false; { std::lock_guard guard(sessions_mutex); @@ -119,7 +117,7 @@ namespace asynchost const std::string& alpn = "", bool plaintext = false, bool verbose = false, - std::atomic* shared_next_id = nullptr, + std::atomic<::tcp::ConnID>* shared_next_id = nullptr, std::function on_session_closed_ = {}, std::optional idle_timeout = std::nullopt) : factory(std::move(factory_)), @@ -130,10 +128,10 @@ namespace asynchost key_pem, host, port, - [this](uint64_t id, std::vector data) { + [this](::tcp::ConnID id, std::vector data) { on_data(id, std::move(data)); }, - [this](uint64_t id) { on_close(id); }, + [this](::tcp::ConnID id) { on_close(id); }, alpn, plaintext, verbose, @@ -174,8 +172,7 @@ namespace asynchost const std::string& service, OpenSSLServer::ConfigureClientSSL configure = {}) { - server->connect( - static_cast(id), host, service, std::move(configure)); + server->connect(id, host, service, std::move(configure)); } void start() @@ -200,7 +197,7 @@ namespace asynchost std::span data, sockaddr /*addr*/ = {}) override { - server->send(static_cast(id), data.data(), data.size()); + server->send(id, data.data(), data.size()); } void close_socket(::tcp::ConnID id) override @@ -214,7 +211,7 @@ namespace asynchost { on_session_closed(id); } - server->close_connection(static_cast(id)); + server->close_connection(id); } }; } From 66fbfb5059dc85a4a715c161af7862e31befca2b Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Wed, 1 Jul 2026 09:50:29 +0000 Subject: [PATCH 19/59] Format --- src/enclave/abstract_rpc_sessions.h | 4 ++-- src/host/rpc_connection_manager.h | 12 ++++++++---- src/host/tls/openssl_server.h | 8 ++++---- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/enclave/abstract_rpc_sessions.h b/src/enclave/abstract_rpc_sessions.h index 90e25ac5933c..a6350c64b422 100644 --- a/src/enclave/abstract_rpc_sessions.h +++ b/src/enclave/abstract_rpc_sessions.h @@ -35,8 +35,8 @@ namespace ccf const std::shared_ptr<::tls::Cert>& cert, const std::string& app_protocol = "HTTP1") = 0; - [[nodiscard]] virtual ccf::ApplicationProtocol get_app_protocol_main_interface() - const = 0; + [[nodiscard]] virtual ccf::ApplicationProtocol + get_app_protocol_main_interface() const = 0; virtual ccf::SessionMetrics get_session_metrics() = 0; diff --git a/src/host/rpc_connection_manager.h b/src/host/rpc_connection_manager.h index 7ca38cbbf2ea..b7da95eff472 100644 --- a/src/host/rpc_connection_manager.h +++ b/src/host/rpc_connection_manager.h @@ -158,7 +158,8 @@ namespace ccf size_t prev_peak = peak_sessions.load(); while (now_active > prev_peak && !peak_sessions.compare_exchange_weak(prev_peak, now_active)) - {} + { + } } void decrement_active_sessions() @@ -166,7 +167,8 @@ namespace ccf size_t expected = active_sessions.load(); while (expected > 0 && !active_sessions.compare_exchange_weak(expected, expected - 1)) - {} + { + } } void increment_interface_peak(ListenInterface* li, size_t now_open) @@ -174,7 +176,8 @@ namespace ccf size_t prev_peak = li->peak_sessions.load(); while (now_open > prev_peak && !li->peak_sessions.compare_exchange_weak(prev_peak, now_open)) - {} + { + } } void decrement_interface_sessions(ListenInterface* li) @@ -182,7 +185,8 @@ namespace ccf size_t expected = li->open_sessions.load(); while (expected > 0 && !li->open_sessions.compare_exchange_weak(expected, expected - 1)) - {} + { + } } // Build the protocol session for a connection on `li`, applying caps. diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index af2e0ba1055e..6162011fb865 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -22,6 +22,8 @@ // is harvested separately. This proves transport + threading + // backpressure. +#include "tcp/msg_types.h" + #include #include #include @@ -43,11 +45,10 @@ #include #include #include -#include #include #include #include -#include "tcp/msg_types.h" +#include #include #include #include @@ -544,8 +545,7 @@ namespace asynchost } const auto err = errno; logf( - "accept error: %s", - std::generic_category().message(err).c_str()); + "accept error: %s", std::generic_category().message(err).c_str()); break; } From 6f38be38e9e704c41003a12d5cab029ce35f998b Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Wed, 1 Jul 2026 09:54:09 +0000 Subject: [PATCH 20/59] Update comments --- src/host/test/openssl_server_test.cpp | 8 +++----- src/host/tls/openssl_server.h | 25 ++++--------------------- 2 files changed, 7 insertions(+), 26 deletions(-) diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp index c9902f9c77ff..986288f0480a 100644 --- a/src/host/test/openssl_server_test.cpp +++ b/src/host/test/openssl_server_test.cpp @@ -1,10 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the Apache 2.0 License. -// Vertical slice for the OpenSSL-native RPC transport: drives the epoll + -// SSL_set_fd server (src/host/tls/openssl_server.h) with a real TLS client and -// exercises handshake, plaintext round-trip, large transfers (backpressure -// path) and concurrent connections. +// Unit tests for the OpenSSL-native RPC transport and SessionWriter bridge. #include "ccf/crypto/ec_key_pair.h" #include "ccf/ds/x509_time_fmt.h" @@ -49,7 +46,8 @@ namespace } // Blocking TLS client: connects, sends `req` in full, reads exactly - // `expected_resp` bytes. Verification is disabled (self-signed slice cert). + // `expected_resp` bytes. Verification is disabled for the self-signed test + // certificate. std::vector tls_client_exchange( uint16_t port, const std::vector& req, diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index 6162011fb865..e953ea008068 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -2,25 +2,9 @@ // Licensed under the Apache 2.0 License. #pragma once -// Vertical-slice OpenSSL-native TLS server, validating the model for the RPC -// stack rewrite: -// * OpenSSL owns the socket fd directly (SSL_set_fd on a non-blocking fd) - -// no memory-BIO indirection, no libuv. -// * Our own epoll loop drives readiness; the handshake and I/O run as a -// non-blocking state machine. -// * Real TCP backpressure falls out: a non-blocking SSL_write that returns -// WANT_WRITE leaves the unsent plaintext buffered and arms EPOLLOUT. -// -// Scope/limits of this slice (deliberately minimal): -// * Single epoll thread; the on_data callback is invoked synchronously on -// that thread and replies by appending to the connection's outbound buffer. -// The production target is SO_REUSEPORT + one epoll per worker, with -// callbacks dispatched to the OrderedTasks pool (so replies would arrive -// from another thread and wake the loop). -// * Level-triggered epoll, for simplicity/correctness over raw throughput. -// * No session caps / certs-per-interface / protocol handling - that policy -// is harvested separately. This proves transport + threading + -// backpressure. +// OpenSSL-native TLS/plaintext TCP server for RPC interfaces. OpenSSL owns the +// socket fd directly, while a local epoll loop drives non-blocking handshake, +// reads, writes, graceful close, outbound connects, and idle connection cleanup. #include "tcp/msg_types.h" @@ -1019,8 +1003,7 @@ namespace asynchost listen_fd = -1; continue; } - // SO_REUSEPORT is the idiom that will let each worker run its own - // listening socket + epoll loop in the production design. + // Allow multiple listeners to bind the same address. if ( setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)) != 0) From e15f4c48939b3f123aa1215328ed5e6c29110a9b Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Wed, 1 Jul 2026 10:21:05 +0000 Subject: [PATCH 21/59] buh --- src/host/tls/openssl_server.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index e953ea008068..4f716310d4a6 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -4,7 +4,8 @@ // OpenSSL-native TLS/plaintext TCP server for RPC interfaces. OpenSSL owns the // socket fd directly, while a local epoll loop drives non-blocking handshake, -// reads, writes, graceful close, outbound connects, and idle connection cleanup. +// reads, writes, graceful close, outbound connects, and idle connection +// cleanup. #include "tcp/msg_types.h" From 5ca441357cd39fa166fdfb95a4146d806bc6ac5a Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Wed, 1 Jul 2026 10:29:25 +0000 Subject: [PATCH 22/59] Handle open ledger chunks in SNP recovery checks --- tests/e2e_operations.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/e2e_operations.py b/tests/e2e_operations.py index 7e9af8090362..7c3d774de89c 100644 --- a/tests/e2e_operations.py +++ b/tests/e2e_operations.py @@ -2590,7 +2590,9 @@ def run_initial_uvm_descriptor_checks(const_args): ) for chunk in ledger: _, chunk_end_seqno = chunk.get_seqnos() - if chunk_end_seqno < recovery_seqno: + # Open chunks have no end seqno, so they may contain the + # recovery transaction. Only skip chunks known to end earlier. + if chunk_end_seqno is not None and chunk_end_seqno < recovery_seqno: continue for tx in chunk: tables = tx.get_public_domain().get_tables() @@ -2673,7 +2675,9 @@ def run_initial_tcb_version_checks(const_args): ) for chunk in ledger: _, chunk_end_seqno = chunk.get_seqnos() - if chunk_end_seqno < recovery_seqno: + # Open chunks have no end seqno, so they may contain the + # recovery transaction. Only skip chunks known to end earlier. + if chunk_end_seqno is not None and chunk_end_seqno < recovery_seqno: continue for tx in chunk: tables = tx.get_public_domain().get_tables() From ebf2bd81f93504f44ed0d7c20d5e21fd7143e691 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Wed, 1 Jul 2026 14:22:37 +0000 Subject: [PATCH 23/59] Fix JWT tests - prefer to bind on IPv4 --- src/host/tls/openssl_server.h | 41 +++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index 4f716310d4a6..47ac004bf3b2 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -9,6 +9,7 @@ #include "tcp/msg_types.h" +#include #include #include #include @@ -746,19 +747,41 @@ namespace asynchost return; } - const int cfd = - socket(res->ai_family, SOCK_STREAM | SOCK_NONBLOCK, res->ai_protocol); - if (cfd < 0) + std::vector addresses; + for (auto* ai = res; ai != nullptr; ai = ai->ai_next) { - freeaddrinfo(res); - fail(); - return; + addresses.push_back(ai); } - const int rc = ::connect(cfd, res->ai_addr, res->ai_addrlen); - freeaddrinfo(res); - if (rc != 0 && errno != EINPROGRESS) + // Prefer IPv4 when both IPv4 and IPv6 addresses are available. Some + // local test servers bind only IPv4 while localhost resolves to ::1 + // first, and this async connect path cannot fall through after EINPROGRESS. + std::stable_sort( + addresses.begin(), addresses.end(), [](auto* a, auto* b) { + return a->ai_family == AF_INET && b->ai_family != AF_INET; + }); + + int cfd = -1; + for (auto* ai : addresses) { + cfd = socket( + ai->ai_family, SOCK_STREAM | SOCK_NONBLOCK, ai->ai_protocol); + if (cfd < 0) + { + continue; + } + + const int rc = ::connect(cfd, ai->ai_addr, ai->ai_addrlen); + if (rc == 0 || errno == EINPROGRESS) + { + break; + } + ::close(cfd); + cfd = -1; + } + freeaddrinfo(res); + if (cfd < 0) + { fail(); return; } From d0dbfdfd281c3efc0be2459c954ad248164b080c Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Wed, 1 Jul 2026 14:23:16 +0000 Subject: [PATCH 24/59] Format --- src/host/tls/openssl_server.h | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index 47ac004bf3b2..ca134437d471 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -754,7 +754,8 @@ namespace asynchost } // Prefer IPv4 when both IPv4 and IPv6 addresses are available. Some // local test servers bind only IPv4 while localhost resolves to ::1 - // first, and this async connect path cannot fall through after EINPROGRESS. + // first, and this async connect path cannot fall through after + // EINPROGRESS. std::stable_sort( addresses.begin(), addresses.end(), [](auto* a, auto* b) { return a->ai_family == AF_INET && b->ai_family != AF_INET; @@ -763,8 +764,8 @@ namespace asynchost int cfd = -1; for (auto* ai : addresses) { - cfd = socket( - ai->ai_family, SOCK_STREAM | SOCK_NONBLOCK, ai->ai_protocol); + cfd = + socket(ai->ai_family, SOCK_STREAM | SOCK_NONBLOCK, ai->ai_protocol); if (cfd < 0) { continue; From eb982cf9e963927c73174adbba1967778b405ac2 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Wed, 1 Jul 2026 15:05:07 +0000 Subject: [PATCH 25/59] Stop RPC transports before host shutdown --- src/enclave/enclave.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/enclave/enclave.h b/src/enclave/enclave.h index fc4a7f9986c9..59aac7a58ffe 100644 --- a/src/enclave/enclave.h +++ b/src/enclave/enclave.h @@ -480,6 +480,9 @@ namespace ccf } } + LOG_INFO_FMT("Stopping RPC transports"); + rpcsessions->stop(); + LOG_INFO_FMT("Enclave stopped successfully. Stopping host..."); RINGBUFFER_WRITE_MESSAGE(AdminMessage::stopped, to_host); From c41e1c3eea7f716fef04996c4b004203de4d0ff6 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Thu, 2 Jul 2026 09:49:36 +0000 Subject: [PATCH 26/59] Report outbound client connection closes --- CMakeLists.txt | 1 + src/enclave/client_session.h | 20 ++++++++++++++-- src/host/tls/openssl_session_manager.h | 33 ++++++++++++++++++++++---- src/http/http2_session.h | 1 + src/http/http_session.h | 1 + 5 files changed, 50 insertions(+), 6 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 08a77b7880fe..57a83ae80b92 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -718,6 +718,7 @@ if(BUILD_TESTS) openssl_server_test ${CMAKE_CURRENT_SOURCE_DIR}/src/host/test/openssl_server_test.cpp ) + target_link_libraries(openssl_server_test PRIVATE ccf_tasks) add_unit_test( raft_test diff --git a/src/enclave/client_session.h b/src/enclave/client_session.h index a7dd074c1efd..d225b8447f94 100644 --- a/src/enclave/client_session.h +++ b/src/enclave/client_session.h @@ -5,6 +5,7 @@ #include "http/http_builder.h" #include "tcp/msg_types.h" +#include #include namespace ccf @@ -30,6 +31,7 @@ namespace ccf protected: HandleDataCallback handle_data_cb; HandleErrorCallback handle_error_cb; + std::atomic completed = false; private: int64_t client_session_id; @@ -49,12 +51,26 @@ namespace ccf const HandleDataCallback f, const HandleErrorCallback e = nullptr) { + handle_data_cb = f; + handle_error_cb = e; + completed.store(false); if (connect_cb) { connect_cb(client_session_id, hostname, service); } - handle_data_cb = f; - handle_error_cb = e; + } + + void mark_completed() + { + completed.store(true); + } + + virtual void handle_error(const std::string& error_msg) + { + if (!completed.exchange(true) && handle_error_cb) + { + handle_error_cb(error_msg); + } } }; } diff --git a/src/host/tls/openssl_session_manager.h b/src/host/tls/openssl_session_manager.h index 4093c86fcc15..f4ef75ffeeb3 100644 --- a/src/host/tls/openssl_session_manager.h +++ b/src/host/tls/openssl_session_manager.h @@ -23,8 +23,11 @@ // thread. The sessions map is guarded by a mutex. #include "ccf/node/session.h" +#include "enclave/client_session.h" #include "enclave/session_writer.h" #include "host/tls/openssl_server.h" +#include "tasks/basic_task.h" +#include "tasks/task_system.h" #include #include @@ -96,12 +99,34 @@ namespace asynchost void on_close(::tcp::ConnID conn_id) { - bool had_session = false; + std::shared_ptr session; { std::lock_guard guard(sessions_mutex); - had_session = sessions.erase(conn_id) > 0; + auto it = sessions.find(conn_id); + if (it != sessions.end()) + { + session = it->second; + sessions.erase(it); + } + } + + if (session == nullptr) + { + return; + } + + if (conn_id < 0) + { + auto client_session = + std::dynamic_pointer_cast(session); + if (client_session != nullptr) + { + ccf::tasks::add_task(ccf::tasks::make_basic_task([client_session]() { + client_session->handle_error("Connection closed before response"); + })); + } } - if (had_session && on_session_closed) + else if (on_session_closed) { on_session_closed(conn_id); } @@ -207,7 +232,7 @@ namespace asynchost std::lock_guard guard(sessions_mutex); had_session = sessions.erase(id) > 0; } - if (had_session && on_session_closed) + if (had_session && id >= 0 && on_session_closed) { on_session_closed(id); } diff --git a/src/http/http2_session.h b/src/http/http2_session.h index fdc3cc74ef44..50049b9d2532 100644 --- a/src/http/http2_session.h +++ b/src/http/http2_session.h @@ -495,6 +495,7 @@ namespace http ccf::http::HeaderMap&& headers, std::vector&& body) override { + mark_completed(); handle_data_cb(status, std::move(headers), std::move(body)); LOG_TRACE_FMT("Closing connection, message handled"); diff --git a/src/http/http_session.h b/src/http/http_session.h index c31c53ca2d74..66d4c8f8d824 100644 --- a/src/http/http_session.h +++ b/src/http/http_session.h @@ -356,6 +356,7 @@ namespace http ccf::http::HeaderMap&& headers, std::vector&& body) override { + mark_completed(); handle_data_cb(status, std::move(headers), std::move(body)); LOG_TRACE_FMT("Closing connection, message handled"); From 59da3e7ccdd9b7ee546690a5044565594067bce6 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Mon, 6 Jul 2026 13:55:10 +0000 Subject: [PATCH 27/59] Add some debug logging --- tests/infra/network.py | 2 ++ tests/infra/node.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/tests/infra/network.py b/tests/infra/network.py index fcaacd66e8c1..4f89088f1423 100644 --- a/tests/infra/network.py +++ b/tests/infra/network.py @@ -1160,6 +1160,8 @@ def pred(f, allow_uncommitted, allow_recovery): key=lambda x: ccf.ledger.get_range_from_file(x)[0], ) + LOG.warning(f"TEMP DEBUG Files: {files}") + # Trace contiguous chunks after the startup snapshot. Chunks wholly # before the snapshot may have been copied from another node, and # are covered by the network-wide committed-history check below. diff --git a/tests/infra/node.py b/tests/infra/node.py index e4672dac053e..aa1c639bd678 100644 --- a/tests/infra/node.py +++ b/tests/infra/node.py @@ -582,6 +582,8 @@ def get_ledger(self): infra.path.create_dir(committed_ledger_dir) for f in os.listdir(main_ledger_dir): + LOG.warning(f"TEMP DEBUG Copying {f} from {main_ledger_dir}") + infra.path.copy_dir( os.path.join(main_ledger_dir, f), committed_ledger_dir if is_file_committed(f) else current_ledger_dir, @@ -591,6 +593,7 @@ def get_ledger(self): for f in os.listdir(ro_dir): # Uncommitted ledger files from r/o ledger directory are ignored by CCF if is_file_committed(f): + LOG.warning(f"TEMP DEBUG Copying {f} from (read-only) {ro_dir}") infra.path.copy_dir(os.path.join(ro_dir, f), committed_ledger_dir) return current_ledger_dir, [committed_ledger_dir] From eb18b7d02c613995f3c203acada208c2acd172f3 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Thu, 23 Jul 2026 15:58:38 +0000 Subject: [PATCH 28/59] Remove debug --- tests/infra/network.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/infra/network.py b/tests/infra/network.py index 4f89088f1423..fcaacd66e8c1 100644 --- a/tests/infra/network.py +++ b/tests/infra/network.py @@ -1160,8 +1160,6 @@ def pred(f, allow_uncommitted, allow_recovery): key=lambda x: ccf.ledger.get_range_from_file(x)[0], ) - LOG.warning(f"TEMP DEBUG Files: {files}") - # Trace contiguous chunks after the startup snapshot. Chunks wholly # before the snapshot may have been copied from another node, and # are covered by the network-wide committed-history check below. From 0a4203615cbc772b19b0a9ebd68bc5fb9594c05c Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Fri, 24 Jul 2026 10:49:14 +0000 Subject: [PATCH 29/59] Remove more TEMP debug logging --- tests/infra/node.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/infra/node.py b/tests/infra/node.py index aa1c639bd678..e4672dac053e 100644 --- a/tests/infra/node.py +++ b/tests/infra/node.py @@ -582,8 +582,6 @@ def get_ledger(self): infra.path.create_dir(committed_ledger_dir) for f in os.listdir(main_ledger_dir): - LOG.warning(f"TEMP DEBUG Copying {f} from {main_ledger_dir}") - infra.path.copy_dir( os.path.join(main_ledger_dir, f), committed_ledger_dir if is_file_committed(f) else current_ledger_dir, @@ -593,7 +591,6 @@ def get_ledger(self): for f in os.listdir(ro_dir): # Uncommitted ledger files from r/o ledger directory are ignored by CCF if is_file_committed(f): - LOG.warning(f"TEMP DEBUG Copying {f} from (read-only) {ro_dir}") infra.path.copy_dir(os.path.join(ro_dir, f), committed_ledger_dir) return current_ledger_dir, [committed_ledger_dir] From b539ff485bb105e70a66739f5f79278060672c6e Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Fri, 24 Jul 2026 14:01:35 +0000 Subject: [PATCH 30/59] Add CURLE_SSL_CONNECT_ERROR to transient transport error classifications --- src/http/curl.h | 15 +++++++++------ src/http/test/curl_test.cpp | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/http/curl.h b/src/http/curl.h index ba5af07efcfd..000119138dc1 100644 --- a/src/http/curl.h +++ b/src/http/curl.h @@ -65,8 +65,10 @@ namespace ccf::curl // 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. + // distinguish retryable transport failures from fatal certificate or + // application errors. CURLE_SSL_CONNECT_ERROR is retryable because it also + // reports a peer disappearing during the TLS handshake; certificate + // verification failures have distinct error codes. // // This deliberately excludes CURLE_WRITE_ERROR: that indicates our own write // callback rejected the response (e.g. it exceeded the caller's size cap), @@ -76,10 +78,11 @@ namespace ccf::curl { 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; + code == CURLE_OPERATION_TIMEDOUT || code == CURLE_SSL_CONNECT_ERROR || + 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 diff --git a/src/http/test/curl_test.cpp b/src/http/test/curl_test.cpp index f9ef7993880b..c0bd4862674e 100644 --- a/src/http/test/curl_test.cpp +++ b/src/http/test/curl_test.cpp @@ -44,6 +44,7 @@ TEST_CASE("is_transient_transport_error classifies curl errors") CURLE_COULDNT_RESOLVE_HOST, CURLE_COULDNT_CONNECT, CURLE_OPERATION_TIMEDOUT, + CURLE_SSL_CONNECT_ERROR, CURLE_GOT_NOTHING, CURLE_RECV_ERROR, CURLE_SEND_ERROR, @@ -66,7 +67,6 @@ TEST_CASE("is_transient_transport_error classifies curl errors") CURLE_OK, CURLE_PEER_FAILED_VERIFICATION, CURLE_SSL_CACERT_BADFILE, - CURLE_SSL_CONNECT_ERROR, CURLE_SSL_CERTPROBLEM, CURLE_USE_SSL_FAILED, CURLE_WRITE_ERROR, From 05da0bc397b48fb547bdc0131583c621905f152e Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Thu, 30 Jul 2026 14:37:03 +0000 Subject: [PATCH 31/59] Merge cleanup N --- src/enclave/abstract_rpc_sessions.h | 11 -- src/enclave/client_session.h | 76 ---------- src/enclave/enclave.h | 1 - src/host/rpc_connection_manager.h | 69 --------- src/host/run.cpp | 1 - src/host/test/rpc_connections.cpp | 40 ----- src/host/tls/openssl_server.h | 196 +------------------------ src/host/tls/openssl_session_manager.h | 35 +---- src/http/http2_session.h | 68 --------- src/http/http_session.h | 124 ---------------- 10 files changed, 3 insertions(+), 618 deletions(-) delete mode 100644 src/enclave/client_session.h delete mode 100644 src/host/test/rpc_connections.cpp diff --git a/src/enclave/abstract_rpc_sessions.h b/src/enclave/abstract_rpc_sessions.h index a6350c64b422..f8af9a9c8479 100644 --- a/src/enclave/abstract_rpc_sessions.h +++ b/src/enclave/abstract_rpc_sessions.h @@ -4,18 +4,12 @@ #include "ccf/crypto/pem.h" #include "ccf/service/node_info_network.h" -#include "enclave/client_session.h" #include "forwarder_types.h" #include "node/session_metrics.h" #include #include -namespace tls -{ - class Cert; -} - namespace ccf { class CustomProtocolSubsystem; @@ -30,11 +24,6 @@ namespace ccf public: ~AbstractRPCSessions() override = default; - // Outbound client sessions (join, JWT refresh, redirects). - virtual std::shared_ptr create_client( - const std::shared_ptr<::tls::Cert>& cert, - const std::string& app_protocol = "HTTP1") = 0; - [[nodiscard]] virtual ccf::ApplicationProtocol get_app_protocol_main_interface() const = 0; diff --git a/src/enclave/client_session.h b/src/enclave/client_session.h deleted file mode 100644 index d225b8447f94..000000000000 --- a/src/enclave/client_session.h +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the Apache 2.0 License. -#pragma once - -#include "http/http_builder.h" -#include "tcp/msg_types.h" - -#include -#include - -namespace ccf -{ - class ClientSession - { - public: - virtual ~ClientSession() = default; - - using HandleDataCallback = std::function&& body)>; - - using HandleErrorCallback = - std::function; - - // Opens an outbound transport connection for `id` to host:service. Supplied - // by the connection manager when it creates the client session. - using ConnectCallback = std::function; - - protected: - HandleDataCallback handle_data_cb; - HandleErrorCallback handle_error_cb; - std::atomic completed = false; - - private: - int64_t client_session_id; - ConnectCallback connect_cb; - - public: - ClientSession(int64_t client_session_id_, ConnectCallback connect_cb_) : - client_session_id(client_session_id_), - connect_cb(std::move(connect_cb_)) - {} - - virtual void send_request(::http::Request&& request) = 0; - - virtual void connect( - const std::string& hostname, - const std::string& service, - const HandleDataCallback f, - const HandleErrorCallback e = nullptr) - { - handle_data_cb = f; - handle_error_cb = e; - completed.store(false); - if (connect_cb) - { - connect_cb(client_session_id, hostname, service); - } - } - - void mark_completed() - { - completed.store(true); - } - - virtual void handle_error(const std::string& error_msg) - { - if (!completed.exchange(true) && handle_error_cb) - { - handle_error_cb(error_msg); - } - } - }; -} diff --git a/src/enclave/enclave.h b/src/enclave/enclave.h index 59aac7a58ffe..8ec2fa5bcdaf 100644 --- a/src/enclave/enclave.h +++ b/src/enclave/enclave.h @@ -442,7 +442,6 @@ namespace ccf } }); - // Maximum number of inbound ringbuffer messages which will be // processed in a single iteration static constexpr size_t max_messages = 256; diff --git a/src/host/rpc_connection_manager.h b/src/host/rpc_connection_manager.h index b7da95eff472..e5e955cfce78 100644 --- a/src/host/rpc_connection_manager.h +++ b/src/host/rpc_connection_manager.h @@ -134,7 +134,6 @@ namespace ccf std::atomic peak_sessions{0}; // Outbound client sessions use the negative range, matching the historical // convention relied upon by forwarding. - std::atomic next_client_id{-1}; // How long an idle connection is kept before being closed (nullopt = // never). Applied to each interface transport at listen() time. @@ -307,18 +306,6 @@ namespace ccf commit_callbacks_subsystem); } - asynchost::OpenSSLSessionManager* primary_bridge() - { - for (auto& [name, li] : interfaces) - { - if (li->bridge != nullptr) - { - return li->bridge.get(); - } - } - return nullptr; - } - void send_udp_reply( const std::string& name, ::tcp::ConnID id, std::span data) { @@ -580,62 +567,6 @@ namespace ccf // ----- AbstractRPCSessions / AbstractRPCResponder ----------------------- - std::shared_ptr create_client( - const std::shared_ptr<::tls::Cert>& cert, - const std::string& app_protocol = "HTTP1") override - { - const int64_t id = next_client_id.fetch_sub(1); - - asynchost::OpenSSLSessionManager* bridge = nullptr; - { - std::lock_guard guard(interfaces_mutex); - bridge = primary_bridge(); - } - if (bridge == nullptr) - { - throw std::runtime_error( - "Cannot create outbound client: no listening interface"); - } - - // The tls::Cert carries the peer CA (for server verification) and, - // optionally, this node's client certificate to present. It configures - // the outbound SSL when the connection is opened. - auto connect_cb = - [bridge, - cert](int64_t cid, const std::string& h, const std::string& s) { - bridge->connect( - static_cast<::tcp::ConnID>(cid), - h, - s, - [cert](SSL* ssl, SSL_CTX* ctx) { - if (cert != nullptr) - { - cert->configure_ssl(ssl, ctx); - } - }); - }; - - std::shared_ptr session; - std::shared_ptr as_session; - if (app_protocol == "HTTP2") - { - auto s = - std::make_shared<::http::HTTP2ClientSession>(id, *bridge, connect_cb); - session = s; - as_session = s; - } - else - { - auto s = - std::make_shared<::http::HTTPClientSession>(id, *bridge, connect_cb); - session = s; - as_session = s; - } - - bridge->register_session(static_cast<::tcp::ConnID>(id), as_session); - return session; - } - bool reply_async( int64_t id, bool terminate_after_reply, diff --git a/src/host/run.cpp b/src/host/run.cpp index e027b224894b..77deb1ee1554 100644 --- a/src/host/run.cpp +++ b/src/host/run.cpp @@ -560,7 +560,6 @@ namespace ccf "snapshots.read_only_directory is deprecated and will be removed in a " "future release"); } - std::optional files_cleanup; if ( (config.files_cleanup.max_snapshots.has_value() || diff --git a/src/host/test/rpc_connections.cpp b/src/host/test/rpc_connections.cpp deleted file mode 100644 index 8a6544a4053c..000000000000 --- a/src/host/test/rpc_connections.cpp +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the Apache 2.0 License. - -#include "host/rpc_connections.h" - -#include "ds/ring_buffer.h" - -#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN -#include -#include -#include -#include - -using namespace std::chrono_literals; - -TEST_CASE("RPC connections retain their ID generator until UV close") -{ - constexpr size_t ringbuffer_size = 4096; - ringbuffer::TestBuffer to_inside(ringbuffer_size); - ringbuffer::TestBuffer from_inside(ringbuffer_size); - ringbuffer::Circuit circuit(to_inside.bd, from_inside.bd); - ringbuffer::WriterFactory writer_factory(circuit); - - auto id_gen = std::make_shared(); - std::weak_ptr weak_id_gen = id_gen; - - { - asynchost::RPCConnections rpc(1s, writer_factory, id_gen); - asynchost::RPCConnections rpc_udp( - 1s, writer_factory, id_gen); - } - - // Destroying the proxies only schedules their timer close callbacks. - id_gen.reset(); - CHECK_FALSE(weak_id_gen.expired()); - - CHECK(uv_run(uv_default_loop(), UV_RUN_DEFAULT) == 0); - CHECK(weak_id_gen.expired()); - CHECK(uv_loop_close(uv_default_loop()) == 0); -} diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index ca134437d471..708bfcab1420 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -4,12 +4,10 @@ // OpenSSL-native TLS/plaintext TCP server for RPC interfaces. OpenSSL owns the // socket fd directly, while a local epoll loop drives non-blocking handshake, -// reads, writes, graceful close, outbound connects, and idle connection -// cleanup. +// reads, writes, graceful close, and idle connection cleanup. #include "tcp/msg_types.h" -#include #include #include #include @@ -58,11 +56,6 @@ namespace asynchost // connection state. using OnClose = std::function; - // Configures an outbound client SSL/SSL_CTX (peer CA verification, the - // client certificate to present, SNI). Supplied per-connect so each client - // session can use its own certificate. Invoked on the loop thread. - using ConfigureClientSSL = std::function; - private: static constexpr size_t read_chunk = 16384; @@ -76,9 +69,6 @@ namespace asynchost Handshaking, Ready } state = Handshaking; - // Outbound (client) connection: drives SSL_connect rather than - // SSL_accept. - bool is_client = false; // Pending plaintext to be encrypted/written; out_off bytes already sent. std::vector outbuf; size_t out_off = 0; @@ -135,16 +125,6 @@ namespace asynchost std::mutex out_mutex; std::vector pending_out; - // Cross-thread outbound connect requests (for client sessions). - struct ConnectReq - { - ::tcp::ConnID id = 0; - std::string host; - std::string port; - ConfigureClientSSL configure; - }; - std::vector pending_connects; - // Cross-thread server-cert (re)load requests (deferred cert / rotation), // applied on the loop thread so `ctx` is only ever touched there. std::vector> pending_certs; @@ -318,7 +298,7 @@ namespace asynchost // each SSL operation so a stale error from another connection cannot be // misattributed (which would spuriously close healthy connections). ERR_clear_error(); - const int r = c.is_client ? SSL_connect(c.ssl) : SSL_accept(c.ssl); + const int r = SSL_accept(c.ssl); if (r == 1) { c.state = Conn::Ready; @@ -647,12 +627,10 @@ namespace asynchost } std::vector items; - std::vector connects; std::vector> certs; { std::lock_guard g(out_mutex); std::swap(items, pending_out); - std::swap(connects, pending_connects); std::swap(certs, pending_certs); } @@ -671,11 +649,6 @@ namespace asynchost ctx = nc; } - for (auto& req : connects) - { - do_connect(req.id, req.host, req.port, req.configure); - } - for (auto& item : items) { auto fit = id_to_fd.find(item.id); @@ -721,155 +694,6 @@ namespace asynchost } } - // Open an outbound client connection for `id` (loop thread). TLS client - // handshake is driven by the normal epoll state machine (is_client). - void do_connect( - ::tcp::ConnID id, - const std::string& host, - const std::string& port, - const ConfigureClientSSL& configure) - { - auto fail = [&]() { - if (on_close) - { - on_close(id); - } - }; - - addrinfo hints{}; - hints.ai_family = AF_UNSPEC; - hints.ai_socktype = SOCK_STREAM; - addrinfo* res = nullptr; - if (getaddrinfo(host.c_str(), port.c_str(), &hints, &res) != 0) - { - logf("getaddrinfo(%s:%s) failed", host.c_str(), port.c_str()); - fail(); - return; - } - - std::vector addresses; - for (auto* ai = res; ai != nullptr; ai = ai->ai_next) - { - addresses.push_back(ai); - } - // Prefer IPv4 when both IPv4 and IPv6 addresses are available. Some - // local test servers bind only IPv4 while localhost resolves to ::1 - // first, and this async connect path cannot fall through after - // EINPROGRESS. - std::stable_sort( - addresses.begin(), addresses.end(), [](auto* a, auto* b) { - return a->ai_family == AF_INET && b->ai_family != AF_INET; - }); - - int cfd = -1; - for (auto* ai : addresses) - { - cfd = - socket(ai->ai_family, SOCK_STREAM | SOCK_NONBLOCK, ai->ai_protocol); - if (cfd < 0) - { - continue; - } - - const int rc = ::connect(cfd, ai->ai_addr, ai->ai_addrlen); - if (rc == 0 || errno == EINPROGRESS) - { - break; - } - - ::close(cfd); - cfd = -1; - } - freeaddrinfo(res); - if (cfd < 0) - { - fail(); - return; - } - - // Per-connection client context so each client session can present its - // own certificate and trust its own CA. - SSL_CTX* cctx = SSL_CTX_new(TLS_client_method()); - if (cctx == nullptr) - { - ::close(cfd); - fail(); - return; - } - if (SSL_CTX_set_min_proto_version(cctx, TLS1_2_VERSION) != 1) - { - SSL_CTX_free(cctx); - ::close(cfd); - fail(); - return; - } - - SSL* ssl = SSL_new(cctx); - if (ssl == nullptr) - { - SSL_CTX_free(cctx); - ::close(cfd); - fail(); - return; - } - SSL_set_mode( - ssl, - SSL_MODE_ENABLE_PARTIAL_WRITE | SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); - SSL_set_connect_state(ssl); - - if (configure) - { - try - { - configure(ssl, cctx); - } - catch (const std::exception& e) - { - logf("client TLS configuration failed: %s", e.what()); - SSL_free(ssl); - SSL_CTX_free(cctx); - ::close(cfd); - fail(); - return; - } - } - else - { - SSL_CTX_set_verify(cctx, SSL_VERIFY_NONE, nullptr); - } - - if (SSL_set_fd(ssl, cfd) != 1) - { - SSL_free(ssl); - SSL_CTX_free(cctx); - ::close(cfd); - fail(); - return; - } - // The SSL holds a reference to the context, so releasing our handle now - // is safe; the context is freed when the SSL is. - SSL_CTX_free(cctx); - - auto c = std::make_unique(); - c->fd = cfd; - c->ssl = ssl; - c->id = id; - c->is_client = true; - - epoll_event ev{}; - ev.data.fd = cfd; - ev.events = EPOLLIN | EPOLLOUT; - if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, cfd, &ev) != 0) - { - SSL_free(ssl); - ::close(cfd); - fail(); - return; - } - conns.emplace(cfd, std::move(c)); - id_to_fd.emplace(id, cfd); - } - // Close connections idle longer than idle_timeout (loop thread). void sweep_idle() { @@ -1184,22 +1008,6 @@ namespace asynchost wake(); } - // Thread-safe. Open an outbound client (TLS) connection bound to `id`. - // `configure` sets up peer verification / client certificate on the new - // connection (see ConfigureClientSSL). - void connect( - ::tcp::ConnID id, - const std::string& host, - const std::string& port, - ConfigureClientSSL configure = {}) - { - { - std::lock_guard g(out_mutex); - pending_connects.push_back({id, host, port, std::move(configure)}); - } - wake(); - } - // Thread-safe. (Re)load the server certificate/key. Used for deferred cert // (a node that learns the service cert after binding) and rotation; applies // to connections accepted after it takes effect on the loop thread. diff --git a/src/host/tls/openssl_session_manager.h b/src/host/tls/openssl_session_manager.h index f4ef75ffeeb3..f3f4cea7ba6f 100644 --- a/src/host/tls/openssl_session_manager.h +++ b/src/host/tls/openssl_session_manager.h @@ -23,7 +23,6 @@ // thread. The sessions map is guarded by a mutex. #include "ccf/node/session.h" -#include "enclave/client_session.h" #include "enclave/session_writer.h" #include "host/tls/openssl_server.h" #include "tasks/basic_task.h" @@ -115,18 +114,7 @@ namespace asynchost return; } - if (conn_id < 0) - { - auto client_session = - std::dynamic_pointer_cast(session); - if (client_session != nullptr) - { - ccf::tasks::add_task(ccf::tasks::make_basic_task([client_session]() { - client_session->handle_error("Connection closed before response"); - })); - } - } - else if (on_session_closed) + if (on_session_closed) { on_session_closed(conn_id); } @@ -179,27 +167,6 @@ namespace asynchost server->set_server_cert(cert_pem, key_pem); } - // Register a pre-built session (used for outbound client sessions, whose - // session is created before the connection is opened). - void register_session( - ::tcp::ConnID id, std::shared_ptr session) - { - std::lock_guard guard(sessions_mutex); - sessions.emplace(id, std::move(session)); - } - - // Open an outbound client connection bound to `id` (thread-safe). - // `configure` sets up TLS verification / client certificate on the - // connection. - void connect( - ::tcp::ConnID id, - const std::string& host, - const std::string& service, - OpenSSLServer::ConfigureClientSSL configure = {}) - { - server->connect(id, host, service, std::move(configure)); - } - void start() { server->start(); diff --git a/src/http/http2_session.h b/src/http/http2_session.h index 50049b9d2532..2b6b7b7749a8 100644 --- a/src/http/http2_session.h +++ b/src/http/http2_session.h @@ -434,72 +434,4 @@ namespace http } }; - class HTTP2ClientSession : public HTTP2Session, - public ccf::ClientSession, - public ::http::ResponseProcessor - { - private: - http2::ClientParser client_parser; - - public: - HTTP2ClientSession( - int64_t session_id_, - ccf::SessionWriter& writer, - ccf::ClientSession::ConnectCallback connect_cb) : - HTTP2Session(session_id_, writer), - ccf::ClientSession(session_id_, std::move(connect_cb)), - client_parser(*this) - { - client_parser.set_outgoing_data_handler( - [this](std::span data) { - send_data(std::vector(data.begin(), data.end())); - }); - } - - bool parse(std::span 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(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&& body) override - { - mark_completed(); - handle_data_cb(status, std::move(headers), std::move(body)); - - LOG_TRACE_FMT("Closing connection, message handled"); - close_session(); - } - }; } diff --git a/src/http/http_session.h b/src/http/http_session.h index 66d4c8f8d824..5e4c37c97c2d 100644 --- a/src/http/http_session.h +++ b/src/http/http_session.h @@ -304,130 +304,6 @@ namespace http } }; - class HTTPClientSession : public HTTPSession, - public ccf::ClientSession, - public ::http::ResponseProcessor - { - private: - ::http::ResponseParser response_parser; - - public: - HTTPClientSession( - ::tcp::ConnID session_id_, - ccf::SessionWriter& writer, - ccf::ClientSession::ConnectCallback connect_cb) : - HTTPSession(session_id_, writer), - ClientSession(session_id_, std::move(connect_cb)), - response_parser(*this) - {} - - bool parse(std::span data) override - { - // Catch response parsing errors and log them - try - { - response_parser.execute(data.data(), data.size()); - - return true; - } - catch (const std::exception& e) - { - LOG_FAIL_FMT("Error parsing HTTP response on session {}", session_id); - LOG_DEBUG_FMT("Error parsing HTTP response: {}", e.what()); - LOG_DEBUG_FMT( - "Error occurred while parsing fragment {} byte fragment:\n{}", - data.size(), - std::string_view( - reinterpret_cast(data.data()), data.size())); - - close_session(); - } - return false; - } - - void send_request(http::Request&& request) override - { - auto data = request.build_request(); - send_data(std::move(data)); - } - - void handle_response( - ccf::http_status status, - ccf::http::HeaderMap&& headers, - std::vector&& body) override - { - mark_completed(); - handle_data_cb(status, std::move(headers), std::move(body)); - - LOG_TRACE_FMT("Closing connection, message handled"); - close_session(); - } - }; - using UnencryptedHTTPSession = ccf::PlaintextSession; - class UnencryptedHTTPClientSession : public UnencryptedHTTPSession, - public ccf::ClientSession, - public ::http::ResponseProcessor - { - private: - ::http::ResponseParser response_parser; - - public: - UnencryptedHTTPClientSession( - ::tcp::ConnID session_id_, - ccf::SessionWriter& writer, - ccf::ClientSession::ConnectCallback connect_cb) : - UnencryptedHTTPSession(session_id_, writer), - ClientSession(session_id_, std::move(connect_cb)), - response_parser(*this) - {} - - bool parse(std::span data) override - { - try - { - response_parser.execute(data.data(), data.size()); - return true; - } - catch (const std::exception& e) - { - LOG_FAIL_FMT("Error parsing HTTP response on session {}", session_id); - LOG_DEBUG_FMT("Error parsing HTTP response: {}", e.what()); - LOG_DEBUG_FMT( - "Error occurred while parsing fragment {} byte fragment:\n{}", - data.size(), - std::string_view( - reinterpret_cast(data.data()), data.size())); - - close_session(); - } - return false; - } - - void send_request(http::Request&& request) override - { - auto data = request.build_request(); - send_data(std::move(data)); - } - - void connect( - const std::string& hostname, - const std::string& service, - const HandleDataCallback f, - const HandleErrorCallback e) override - { - ccf::ClientSession::connect(hostname, service, f, e); - } - - void handle_response( - ccf::http_status status, - ccf::http::HeaderMap&& headers, - std::vector&& body) override - { - handle_data_cb(status, std::move(headers), std::move(body)); - LOG_TRACE_FMT("Closing connection, message handled"); - close_session(); - } - }; } From 8866b0568ee9acd0bad1c9747ae6d1ca463b5925 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Thu, 30 Jul 2026 15:00:11 +0000 Subject: [PATCH 32/59] Restore explicit-port-before-ephemeral RPC interface bind ordering The cutover moved RPC listening out of the host's setup_rpc_interfaces and into Enclave::create_new_node, but the new loop iterates rpc_interfaces in map (name) order and so lost the ordering introduced in #8053. Multiple interfaces can share a host address (for example the sole ::1 IPv6 loopback). If an interface requesting an ephemeral port is bound first, the OS may assign it the exact port another interface is configured to bind, making that later bind fail with "address already in use". Bind explicit-port interfaces first, as before. Adapted to ccf::split_net_address, which returns an empty port (rather than "0") when none is specified, so both forms count as ephemeral. --- src/enclave/enclave.h | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/enclave/enclave.h b/src/enclave/enclave.h index 8ec2fa5bcdaf..790ebc30de2f 100644 --- a/src/enclave/enclave.h +++ b/src/enclave/enclave.h @@ -219,8 +219,40 @@ namespace ccf // (which writes the rpc addresses file). { nlohmann::json resolved_rpc_addresses; - for (auto& [name, interface] : ccf_config_.network.rpc_interfaces) + + // Bind interfaces with an explicit (non-zero) port before those + // requesting an ephemeral port (port 0 or unspecified). Multiple + // interfaces on a node can share a single host address (for example + // the sole ::1 IPv6 loopback), and if an ephemeral interface is bound + // first the OS may assign it the exact port that another interface is + // configured to bind, making that later bind fail with "address + // already in use". + const auto is_ephemeral = [](const auto& interface) { + const auto port = + ccf::split_net_address(interface.bind_address).second; + return port.empty() || port == "0"; + }; + std::vector ordered_interface_names; + ordered_interface_names.reserve( + ccf_config_.network.rpc_interfaces.size()); + for (const auto& [name, interface] : ccf_config_.network.rpc_interfaces) + { + if (!is_ephemeral(interface)) + { + ordered_interface_names.push_back(name); + } + } + for (const auto& [name, interface] : ccf_config_.network.rpc_interfaces) + { + if (is_ephemeral(interface)) + { + ordered_interface_names.push_back(name); + } + } + + for (const auto& name : ordered_interface_names) { + auto& interface = ccf_config_.network.rpc_interfaces.at(name); const auto [host, port] = ccf::split_net_address(interface.bind_address); // UDP interfaces use the datagram (echo) path; TCP interfaces the From f7ee1ff07fa9566e3aee9d8c13f23148da2d7dd8 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Tue, 4 Aug 2026 12:09:13 +0000 Subject: [PATCH 33/59] Better context management - an AL4 fix --- tests/connections.py | 6 ++++-- tests/infra/clients.py | 12 ++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/connections.py b/tests/connections.py index 4e8c8529c1ac..16bee0ec7e47 100644 --- a/tests/connections.py +++ b/tests/connections.py @@ -340,8 +340,10 @@ def node_tcp_socket(node): interface = node.n2n_interface s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((interface.host, interface.port)) - yield s - s.close() + try: + yield s + finally: + s.close() # NB: This does rudimentary smoke testing. See fuzzing.py for more thorough test diff --git a/tests/infra/clients.py b/tests/infra/clients.py index da13ea0c7917..e57380a3fbbf 100644 --- a/tests/infra/clients.py +++ b/tests/infra/clients.py @@ -1310,8 +1310,10 @@ def close(self): @contextlib.contextmanager def client(*args, **kwargs): c = CCFClient(*args, **kwargs) - yield c - c.close() + try: + yield c + finally: + c.close() class APIVersionedCCFClient(CCFClient): @@ -1344,5 +1346,7 @@ def call(self, path: str, *args, **kwargs): @contextlib.contextmanager def api_versioned_client(*args, api_version=None, **kwargs): c = APIVersionedCCFClient(*args, api_version=api_version, **kwargs) - yield c - c.close() + try: + yield c + finally: + c.close() From 97ca67481738f6c0055c2d3af9a7a0bc417aac08 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Tue, 4 Aug 2026 14:24:59 +0000 Subject: [PATCH 34/59] Refactor HTTP session classes to directly inherit from ccf::PlaintextSession --- src/http/http2_session.h | 6 ++---- src/http/http_session.h | 9 ++------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/http/http2_session.h b/src/http/http2_session.h index 2b6b7b7749a8..71e542314d09 100644 --- a/src/http/http2_session.h +++ b/src/http/http2_session.h @@ -12,8 +12,6 @@ namespace http { - using HTTP2Session = ccf::PlaintextSession; - struct HTTP2SessionContext : public ccf::SessionContext { int32_t stream_id; @@ -172,7 +170,7 @@ namespace http } }; - class HTTP2ServerSession : public HTTP2Session, + class HTTP2ServerSession : public ccf::PlaintextSession, public http::RequestProcessor, public ccf::http::HTTPResponder { @@ -245,7 +243,7 @@ namespace http std::vector peer_cert, const ccf::http::ParserConfiguration& configuration, const std::shared_ptr& error_reporter_) : - HTTP2Session(session_id_, writer, std::move(peer_cert)), + ccf::PlaintextSession(session_id_, writer, std::move(peer_cert)), server_parser( std::make_shared(*this, configuration)), rpc_map(std::move(rpc_map_)), diff --git a/src/http/http_session.h b/src/http/http_session.h index 5e4c37c97c2d..6a1da6a72171 100644 --- a/src/http/http_session.h +++ b/src/http/http_session.h @@ -13,9 +13,7 @@ namespace http { - using HTTPSession = ccf::PlaintextSession; - - class HTTPServerSession : public HTTPSession, + class HTTPServerSession : public ccf::PlaintextSession, public http::RequestProcessor, public ccf::http::HTTPResponder { @@ -39,7 +37,7 @@ namespace http const ccf::http::ParserConfiguration& configuration, const std::shared_ptr& error_reporter_, const std::shared_ptr& commit_callbacks_) : - HTTPSession(session_id_, writer, std::move(peer_cert)), + ccf::PlaintextSession(session_id_, writer, std::move(peer_cert)), request_parser(*this, configuration), rpc_map(std::move(rpc_map_)), error_reporter(error_reporter_), @@ -303,7 +301,4 @@ namespace http std::move(body)); } }; - - using UnencryptedHTTPSession = ccf::PlaintextSession; - } From 3de7b3f13d4a3acd4ad861caa094eba93cce71e0 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Tue, 4 Aug 2026 15:07:08 +0000 Subject: [PATCH 35/59] Rewrite docs, audit compat with previous --- CMakeLists.txt | 4 + doc/architecture/tls_internals.rst | 147 ++++++++++++-------- doc/contribute/onboarding.rst | 9 +- src/host/datagram_server.h | 6 - src/host/test/openssl_server_test.cpp | 189 ++++++++++++++++++++++++++ src/host/tls/openssl_server.h | 70 ++++++++-- tests/tls_groups.py | 3 +- 7 files changed, 350 insertions(+), 78 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 57a83ae80b92..d15207120376 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -719,6 +719,10 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/src/host/test/openssl_server_test.cpp ) target_link_libraries(openssl_server_test PRIVATE ccf_tasks) + target_compile_definitions( + openssl_server_test + PRIVATE TEST_HYBRID_TLS_GROUPS=$ + ) add_unit_test( raft_test diff --git a/doc/architecture/tls_internals.rst b/doc/architecture/tls_internals.rst index e7acbd4802af..946d28a8d642 100644 --- a/doc/architecture/tls_internals.rst +++ b/doc/architecture/tls_internals.rst @@ -4,106 +4,137 @@ TLS Internals Overview ~~~~~~~~ -In CCF, the :term:`TLS` layer is implemented using OpenSSL 3.3. However, the original implementation was using OE's MbedTLS library, which the current implementation replaced. During the transition period, the OpenSSL implementation had to emulate the previous MbedTLS one and the remaining code isn't particularly suited to OpenSSL. +In CCF, the :term:`TLS` layer is implemented using OpenSSL (3.3 or later). -This document describes how that works, to facilitate further changes. +TLS is terminated in the **connection layer**: OpenSSL owns the socket file descriptor directly, and a local ``epoll`` loop drives the non-blocking handshake, reads, writes, graceful close and idle connection cleanup. Everything above the connection layer, including HTTP parsing and endpoint dispatch, only ever sees plaintext. -Enclave Connections -~~~~~~~~~~~~~~~~~~~ +This document describes the connection layer and the seam between it and the session layer, to facilitate further changes. -CCF handles RPC requests by managing a collection of HTTPS sessions on each node. The HTTP session receives raw TCP bytes from the :term:`ring buffer` and passes it to the TLS implementation that tries to decrypt on read (after a successful handshake). +Layers +~~~~~~ -If there isn't enough information to decrypt on read, the TLS layer responds with a 'WANTS_READ' message, meaning more packets are needed to complete the message, so the endpoint tried to get more data from the ring buffer. +A single RPC interface is served by these pieces: -Once all incoming data is decrypted, the HTTP session parses the plain text data and passes the parsed request to the application, which process it and produces a plain text response. The session then serialises this response, encrypts it through the TLS layer, and writes the encrypted data to the :term:`ring buffer` to be sent to the original caller. +- :ccf_repo:`OpenSSLServer ` owns the listening socket, one ``epoll`` instance, and one loop thread. Each accepted connection holds an ``SSL`` object bound to its file descriptor with ``SSL_set_fd``. It emits decrypted bytes through an ``OnData`` callback and reports teardown through ``OnClose``. +- :ccf_repo:`OpenSSLSessionManager ` bridges the transport to the session layer. It lazily creates one ``ccf::Session`` per connection using a caller-supplied factory, and implements :ccf_repo:`ccf::SessionWriter ` so that a session's outbound plaintext is handed back to the transport. +- :ccf_repo:`ccf::PlaintextSession ` is the base for the protocol sessions (:ccf_repo:`HTTPServerSession `, :ccf_repo:`HTTP2ServerSession `). It receives plaintext, and emits plaintext through its ``SessionWriter``. +- :ccf_repo:`RPCConnectionManager ` owns one of these stacks per configured RPC interface, and holds the cross-interface policy: certificates, session caps, and metrics. -TLS Implementation -~~~~~~~~~~~~~~~~~~ +Because TLS lives below the session, there is no separate "encrypted session" type. The difference between a TLS interface and an ``UNSECURED`` one is a flag on the connection layer, not a different session class. -The TLS implementation in 'src/tls' has three main components: +Note that the inbound and outbound paths are not symmetric. Inbound plaintext is pushed straight into the session, but a session cannot touch the socket: it hands bytes to a ``SessionWriter``, which queues them for the loop thread. This is what allows sessions to run on worker threads while all socket I/O stays on one thread per interface. -- A 'Certificate Authority' (CA), which has a root certificate that can sign other certificates in the network. This CA certificate is generated by the network outside of the TLS implementation and is imported when clients and servers are created. -- A 'Certificate' (Cert), which is the server/client's own certificate, signed by the CA and unique to this server or client. -- A 'Context', which is the common implementation of both 'Client' and 'Server' and provides input (read and decrypt) and output (encrypt and write) over the ring buffer and its triggers. Clients and servers are basically just a Context with a certificate. +Listening +~~~~~~~~~ -Both CA and Cert have internal logic to validate their certificates and private keys, and the Context has logic to complete a TLS handshake, read and write using encryption and to query the peer's certificate. +The listening socket is resolved with ``getaddrinfo`` (rather than ``inet_pton``) so that interfaces can be configured with hostnames such as ``localhost`` and with IPv6 addresses, not only IPv4 literals. ``SO_REUSEADDR`` is set, so that a port left in ``TIME_WAIT`` by a previous process can be rebound on restart. -The next layer up is the 'TLSSession' (of which 'HTTPSession' owns an instance) that holds the ring buffer, the pending read and write buffers, and the call backs for sending and receiving data. +``SO_REUSEPORT`` is deliberately not set. There is one listener per interface, so it would buy nothing today, and it would weaken two useful properties: a second node misconfigured onto the same port would bind successfully instead of failing with ``EADDRINUSE``, with connections then split between the two at random; and any process running with the same effective UID could bind the same port and siphon off a share of inbound connections. If per-worker listeners are added later, it should be enabled explicitly for that case rather than unconditionally. -Receiving Messages -~~~~~~~~~~~~~~~~~~ +After binding, the actually bound port is read back with ``getsockname``. This supports configuring port ``0`` to request an ephemeral port, which the test infrastructure relies on, and is why resolved addresses are only known once the interface is listening. -When a REST node message is received through the ring buffer, the session's ``handle_incoming_data_thread()`` method passes it to ``TLSSession::read()``, which in turn calls ``tls::Context::read()``. +Certificates +~~~~~~~~~~~~ -However, when creating the TLS 'Context', the 'TLSSession' had to register some callbacks of its own, too. This is one of the parts that was specific to MbedTLS and that was (unnaturally) replicated to OpenSSL. +An interface starts listening before its certificate is necessarily known. A joining node, for example, only obtains the service certificate once its join request has been accepted. ``OpenSSLServer`` therefore accepts an empty certificate at construction, and refuses TLS connections until one is supplied. -Those callbacks are implemented in 'TLSSession' because they need access to the ``pending_read`` buffers to read from and the 'ring buffer' to write to. But the TLS context ('SSL' and 'SSL_CTX' objects) need to encrypt/decrypt data, and they can only do that from 'BIO's in OpenSSL, so the callback has a mix of ring buffer and BIO handling that tricks both sides to take the appropriate steps at the right time in this complex dance. +``set_server_cert()`` may be called from any thread. It does not build the ``SSL_CTX`` inline: it queues the request and wakes the loop, so the context is only ever created or replaced on the loop thread. This avoids locking the context on the hot path. Replacing the context affects only subsequent connections; existing connections keep the context they were created with, which OpenSSL keeps alive by reference counting. -In the 'read' case, the message has arrived into the ``pending_read`` buffer via a (previously triggered) ring buffer callback, and that data is written to TLS's 'read' BIO (via ``BIO_write_ex``) `before` TLS itself reads it. That data is still encrypted, so when the TLS reads it with ``SSL_read_ex``, it tries to decrypt and on success, returns a plain text buffer. On error, it emits the error or a 'WANTS_READ' status, so the endpoint can try to extract more information from the ``pending_read`` buffer again. +The server context sets a minimum version of TLS 1.2 and, when the interface is configured for HTTP/2, advertises ``h2`` via ALPN. -Upon success, the read BIO is flushed and new data can be read again. The plain text result is returned to the HTTP session, which sends it to the application to process. +Two certificate helpers remain in ``src/tls``: -Sending Messages -~~~~~~~~~~~~~~~~ +- :ccf_repo:`CA ` holds a root certificate and can populate a trusted certificate store. +- :ccf_repo:`Cert ` holds an endpoint's own certificate and private key, and configures an ``SSL`` object with them. -When the application finishes, it returns a plain text response. The session passes that message to ``TLSSession::send_data()``, which fills the ``pending_write`` buffer and calls ``write_some()`` via ``flush()``. ``write_some()`` itself calls ``tls::Context::write()``. +These are used for outbound connections and by node startup code, not by the inbound server path. -This is the same process as for reads: a callback in 'TLSSession' was registered for the 'write' BIO, with access to the ``pending_write`` and the ring buffer. But in this case, the callback is only executed `after` the TLS layer has encrypted the data and written to the 'write' BIO. +Cryptographic policy +~~~~~~~~~~~~~~~~~~~~ -Now, we take that (encrypted) data, flush the BIO ourselves (to avoid clogging the pipes with the next message) and send it through the ring buffer with the ``RINGBUFFER_TRY_WRITE_MESSAGE(::tcp::tcp_outbound, ...)`` macro. +The context restricts what the handshake may negotiate: -This is what actually sends the message back to the client, so when this callback returns, the remaining stack just returns the status of that write. Different errors are treated at different levels (TLS errors in Context, ring buffer errors in TLSSession and HTTP errors in HTTPSession). +- TLS 1.2 is limited to four AES-GCM cipher suites, all with ECDHE key exchange and either ECDSA or RSA authentication. +- TLS 1.3 is limited to ``TLS_AES_256_GCM_SHA384`` and ``TLS_AES_128_GCM_SHA256``. +- The key exchange group list prefers the hybrid post-quantum groups ``SecP384r1MLKEM1024``, ``SecP256r1MLKEM768`` and ``X25519MLKEM768``, falling back to ``P-521``, ``P-384`` and ``P-256``. The hybrid groups are prefixed with ``?`` so that the list still loads on OpenSSL versions which do not implement them. +- Renegotiation is disabled, and session resumption on renegotiation with it, to avoid the associated denial-of-service vectors. +- Where a choice remains, the server's ordering wins over the client's. -Why OpenSSL? -~~~~~~~~~~~~ +Note that in TLS 1.3 the *client* effectively chooses the group: OpenSSL picks the first client-offered group that the server also supports, so the server list acts as a filter rather than a preference. + +The same policy is applied by :ccf_repo:`ccf::tls::Context ` for the remaining non-RPC TLS users, and the two must be kept in sync. It is asserted from the wire by the ``openssl_server_test`` unit tests and by the :ccf_repo:`tls_groups ` end-to-end test, both of which only check the hybrid groups when built with ``-DTEST_HYBRID_TLS_GROUPS=ON``, since those groups require OpenSSL 3.5 or later. + +The context also sets ``SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER`` and ``SSL_MODE_ENABLE_PARTIAL_WRITE``, both of which are required by the write path described below. + +Client authentication +~~~~~~~~~~~~~~~~~~~~~ + +The server calls ``SSL_CTX_set_verify`` with ``SSL_VERIFY_PEER`` and an accept-all callback. This is deliberate: it makes the server *request* the client certificate during the handshake so that it is available for application-level caller authentication, without the TLS layer itself rejecting clients. Whether a certificate is required, and whether it is acceptable, is decided by the endpoint's authentication policy. + +Reading +~~~~~~~ -The main reasons why we moved to OpenSSL are: +When ``epoll`` reports a connection readable, the loop calls ``SSL_read`` repeatedly until it reports ``SSL_ERROR_WANT_READ``. Every chunk of decrypted bytes is passed to the ``OnData`` callback as it is produced, so a single readable event may yield several callbacks. -- We already use OpenSSL for our crypto library ('src/crypto'). -- We wanted TLS 1.3 support that the MbedTLS version in use did not provide. -- We wanted to support QUIC through OpenSSL. +``OpenSSLSessionManager`` receives those bytes, finds or creates the session for that connection, and calls ``handle_incoming_data``. The session dispatches the actual parsing to a worker via ``OrderedTasks``, so the loop thread does not block on application work. -MbedTLS has since been removed from the runtime implementation. +``SSL_ERROR_WANT_WRITE`` on a read is not an error: a TLS 1.3 key update needs the socket to become writable, so the connection is left open with ``EPOLLOUT`` armed. Any other result, whether a clean ``SSL_ERROR_ZERO_RETURN``, an unclean EOF, or a fatal error, closes the connection. -MbedTLS vs OpenSSL -~~~~~~~~~~~~~~~~~~ +Writing and backpressure +~~~~~~~~~~~~~~~~~~~~~~~~ -As stated above, the current OpenSSL implementation is `emulating` the previous MbedTLS one, so some oddities are observed. +``send()`` is thread-safe. It appends the plaintext to a queue guarded by a mutex and signals an ``eventfd``, waking the loop thread, which drains the queue and attempts the write. -First, MbedTLS returns errors as negative values and amount of data handled as positive values. OpenSSL concurs on positive values but returns 0 (or -1 in previous versions) for all errors, using ``SSL_get_error`` to then classify which error and what to do. The error values are also positive. +Writes are where genuine backpressure appears. ``SSL_write`` on a non-blocking socket may report ``SSL_ERROR_WANT_WRITE`` after consuming only part of the buffer. The remainder stays buffered against the connection, ``EPOLLOUT`` is armed, and the write resumes when the socket next becomes writable. Because the socket is owned by OpenSSL and is non-blocking, this reflects the real state of the :term:`TCP` send buffer rather than an internal approximation. -To simulate this, we implement the error handling at each invocation and, on error, we negate the value of the error so that we can retain the old behavior of checking for negative values. +Closing +~~~~~~~ -Second, MbedTLS keeps all its context (configuration, connection info, read and write buffers) in a single large structure, while OpenSSL has separate structures for each and uses 'BIO' objects for buffers. Reads and writes in MbedTLS is done exclusively via callbacks. +Closing is deferred rather than immediate. A close requested while output is still buffered sets a flag and flushes; the file descriptor is only closed once the buffered bytes have been written. -OpenSSL callbacks, however, are very different from MbedTLS ones. They are called twice for each action, one before the actual action and another after. +This matters because the common pattern is to write a response and immediately close. Closing eagerly truncates any response large enough to have been backpressured, which the client observes as a connection reset partway through the body rather than as a well-formed response. -To simulate this we had to implement a read callback `before` the BIO read (so we could fill it up with the contents of the ring buffer) and the write callback `after` the BIO write (so we could pick up its contents and send it into the ring buffer). +Idle connections are closed separately. If an idle timeout is configured, the loop uses a finite ``epoll_wait`` timeout and periodically sweeps connections whose last I/O is older than the timeout. -There is a complex dance of return values in OpenSSL's callbacks. If any returns errors the action is canceled immediately. On reads, because the BIO was empty, the initial return value is an error, so we must make sure that, if there is anything in the ``pending_read`` buffer, we have to change the status to the amount of bytes read, so it can continue. +Threading +~~~~~~~~~ -Third, the handshake in MbedTLS had various types of errors, which we had to emulate by making the appropriate ``SSL_*`` calls, check the peer certificate, etc. to get the same types of responses for the same situations. +Each ``OpenSSLServer`` runs exactly one loop thread, and all socket and ``SSL`` operations happen on it. Work reaches that thread in one of two ways: file descriptor readiness reported by ``epoll``, or a cross-thread request (a queued write, a close, or a certificate update) posted to a queue and signalled through an ``eventfd``. -Finally, in MbedTLS, the configuration and session objects were setup at the same time, while in OpenSSL they're separate. We ended up duplicating every single configuration, but this is unnecessary, because once the config object is correct, any session object created from it has the same properties. +.. warning:: -But the TLS Context doesn't handle more than one session per configuration, so we could set either of them once and ignore the other. The simplest thing would be to setup just the session, but if we end up having more than one session later, we'd have to refactor that. + OpenSSL's error queue is **thread-local**, and one loop thread services every connection on an interface. ``SSL_get_error`` consults that queue, so an error left behind by one connection can be misattributed to the next operation on a completely different connection. -Simplifying the OpenSSL Implementation -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + Every ``SSL_accept``, ``SSL_read`` and ``SSL_write`` is therefore preceded by ``ERR_clear_error()``. Omitting this causes healthy keep-alive connections to be closed spuriously, because a stale error (for example a previous client disconnecting without ``close_notify``) is read as a fatal error on an unrelated connection. -With MbedTLS gone from the code base, the OpenSSL implementation can be simplified. +Unsecured interfaces +~~~~~~~~~~~~~~~~~~~~ -The considerations are: +An interface configured as ``UNSECURED`` runs the same loop with no ``SSL`` object. Connections are immediately ready, and reads and writes use ``::recv`` and ``::send`` directly. The buffering, backpressure, graceful close and idle handling are identical, so the session layer above is unchanged. -- We don't need to handle errors inside the calls to read/write, but can leave for each caller to handle IFF there is an error by calling ``SSL_get_error``. This also means we don't need to negate error values, as they're in different domains. -- We can simplify the SSL configuration on startup, handshake handling and peer certificate handling. +Outbound connections +~~~~~~~~~~~~~~~~~~~~ -However, getting rid of the callbacks and using BIOs directly is going to be hard. +Outbound connections use a non-blocking ``connect`` followed by a client handshake driven by the same loop. The caller supplies a callback that configures the client ``SSL`` object, which is where peer CA verification, the client certificate and SNI are applied via ``tls::Cert``. -First, the current callback is in 'TLSSession' because it has access to both pending buffers and the ring buffer. The TLS Context does not have access to it nor it would be wise to pass references to it, as that'd make the Context exclusive to the TLSSession. +Why OpenSSL? +~~~~~~~~~~~~ + +CCF originally used MbedTLS, and the OpenSSL implementation that replaced it emulated MbedTLS conventions, notably negated error codes and a memory-BIO indirection driven by callbacks. + +MbedTLS itself is long gone, and the inbound server path no longer goes anywhere near that emulation: ``OpenSSLServer`` calls ``SSL_read``/``SSL_write`` on a socket-backed ``SSL`` and interprets ``SSL_get_error`` directly. The emulation layer itself, however, still exists in :ccf_repo:`src/tls/context.h ` (``TLS_ERR_WANT_READ`` and friends in :ccf_repo:`src/tls/tls.h ` are negated OpenSSL error codes, and ``set_bio()`` installs callback-driven memory BIOs). ``ccf::tls::Context`` and its ``Client``, ``Server`` and ``PlaintextServer`` subclasses are now reachable only from the ``tls_test`` unit test, and are candidates for removal. + +The reasons for OpenSSL remain: + +- OpenSSL is already used for the :doc:`cryptography ` implementation in ``src/crypto``. +- TLS 1.3 support. +- A path to QUIC. + +Future: QUIC +~~~~~~~~~~~~ -Second, both endpoint and TLS have a need to read and write asynchronously. Data arrives from the ring buffer at any time and the TLS implementation can request reads and writes (for example, during handshake) that the endpoint didn't request itself. +QUIC is not yet implemented. Server-side QUIC requires OpenSSL 3.5 or later, which adds ``SSL_new_listener``, ``SSL_accept_connection`` and ``OSSL_QUIC_server_method``; these are absent from the 3.3.x baseline CCF currently supports. -So if ``SSL_handshake``, ``SSL_read_ex`` and ``SSL_write_ex`` don't have direct access to read and write from the ring buffers without direct requests from the endpoints, it won't be able to conclude the asynchronous handshake and start the connection. +:ccf_repo:`DatagramServer ` exists as the substrate for that work. It is deliberately shaped as the UDP socket a QUIC server operates on: socket creation, binding, the ``epoll`` loop and the per-datagram dispatch are all reusable as-is. The points that change for QUIC are marked ``QUIC EXTENSION POINT`` inline, and consist of wrapping the socket with ``BIO_new_dgram``/``SSL_set_fd`` on a listener ``SSL``, and replacing the datagram callback with ``SSL_handle_events``. -One possible way out of it is to create a `BIO pair `_ for each read/write action between the 'TLSSession' and the TLS 'Context', driven by two asynchronous tasks in 'TLSSession' that just poll the BIOs and buffers and pass data across. This removes a callback, but introduces polling, which is not an actual improvement. +Until then, a UDP interface uses a built-in datagram echo session. diff --git a/doc/contribute/onboarding.rst b/doc/contribute/onboarding.rst index 3cfc66930e34..49ee4cc9223c 100644 --- a/doc/contribute/onboarding.rst +++ b/doc/contribute/onboarding.rst @@ -63,12 +63,13 @@ Note that this diagram deliberately does not represent host-to-enclave communica .. mermaid:: flowchart TB - Client[HTTPS/1.1 Client auth] -- TLS 1.2 or 1.3 --> TLSSession - TLSSession[TLS Session src] -- PlainText --> HTTPSession + Client[HTTPS/1.1 Client auth] -- TLS 1.2 or 1.3 --> TLSConnection + TLSConnection[TLS Connection src] -- PlainText --> HTTPSession + HTTPSession -- PlainText --> SessionWriter[Session Writer src] + SessionWriter --> TLSConnection HTTPSession[HTTP Session src] -- Request --> Endpoint[Application Endpoint doc] Endpoint -- Response --> HTTPSession - HTTPSession --> TLSSession - TLSSession --> Client + TLSConnection --> Client Endpoint -- WriteSet --> Store[Store doc] Store -- LedgerEntry --> Ledger[Ledger doc] Ledger -- LedgerEntry --> Disk diff --git a/src/host/datagram_server.h b/src/host/datagram_server.h index 20a691d7a618..c244541956f7 100644 --- a/src/host/datagram_server.h +++ b/src/host/datagram_server.h @@ -189,12 +189,6 @@ namespace asynchost sock = -1; continue; } - if (setsockopt(sock, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)) != 0) - { - ::close(sock); - sock = -1; - continue; - } if (::bind(sock, ai->ai_addr, ai->ai_addrlen) == 0) { bound = true; diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp index 986288f0480a..f427be3a5e14 100644 --- a/src/host/test/openssl_server_test.cpp +++ b/src/host/test/openssl_server_test.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -32,8 +33,21 @@ using namespace asynchost; +// Set by the build when the toolchain's OpenSSL is new enough (3.5+) to +// negotiate the hybrid post-quantum groups the server offers. +#ifndef TEST_HYBRID_TLS_GROUPS +# define TEST_HYBRID_TLS_GROUPS 0 +#endif + namespace { + // The host process ignores SIGPIPE (see src/host/run.cpp), so writes to a + // socket the peer has already closed return EPIPE rather than killing it. + // Tests must do the same to reproduce production behaviour. + [[maybe_unused]] const bool ignore_sigpipe = []() { + return signal(SIGPIPE, SIG_IGN) != SIG_ERR; + }(); + std::pair make_server_cert() { using namespace std::literals; @@ -130,6 +144,84 @@ namespace return v; } + std::string negotiated_group_name(SSL* ssl) + { + const auto group_id = SSL_get_negotiated_group(ssl); + if (group_id == NID_undef) + { + return {}; + } + + const auto* group_name = SSL_group_to_name(ssl, group_id); + if (group_name != nullptr) + { + return group_name; + } + + return std::to_string(group_id); + } + + struct HandshakeResult + { + bool succeeded = false; + std::string group; + std::string cipher; + }; + + // Handshakes against the server, optionally restricting what the client + // offers, and reports what was negotiated. Used to assert the server's + // configured cipher/group policy from the wire rather than by inspecting + // the SSL_CTX. + HandshakeResult handshake_and_inspect( + uint16_t port, + const std::string& client_groups = {}, + const std::string& client_ciphersuites = {}) + { + const int fd = ::socket(AF_INET, SOCK_STREAM, 0); + REQUIRE(fd >= 0); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + REQUIRE(inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) == 1); + REQUIRE( + ::connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0); + + SSL_CTX* cctx = SSL_CTX_new(TLS_client_method()); + REQUIRE(cctx != nullptr); + if (!client_groups.empty()) + { + REQUIRE(SSL_CTX_set1_groups_list(cctx, client_groups.c_str()) == 1); + } + if (!client_ciphersuites.empty()) + { + REQUIRE(SSL_CTX_set_ciphersuites(cctx, client_ciphersuites.c_str()) == 1); + } + + SSL* ssl = SSL_new(cctx); + REQUIRE(ssl != nullptr); + REQUIRE(SSL_set_fd(ssl, fd) == 1); + SSL_set_connect_state(ssl); + + HandshakeResult result; + result.succeeded = SSL_connect(ssl) == 1; + if (result.succeeded) + { + result.group = negotiated_group_name(ssl); + const auto* cipher = SSL_get_current_cipher(ssl); + if (cipher != nullptr) + { + result.cipher = SSL_CIPHER_get_name(cipher); + } + SSL_shutdown(ssl); + } + + SSL_free(ssl); + SSL_CTX_free(cctx); + ::close(fd); + return result; + } + // Echoes received plaintext back to the same connection via send(). struct EchoServer { @@ -569,3 +661,100 @@ TEST_CASE("Persistent connection survives many sequential round-trips") SSL_CTX_free(cctx); ::close(fd); } + +// The server's cipher, ciphersuite and group policy must match +// ccf::tls::Context (src/tls/context.h). These assert it from the wire. + +TEST_CASE("Server restricts TLS 1.3 ciphersuites to the configured list") +{ + auto [cert, key] = make_server_cert(); + EchoServer s(cert, key); + + SUBCASE("a configured ciphersuite is accepted") + { + const auto r = + handshake_and_inspect(s.port(), {}, "TLS_AES_256_GCM_SHA384"); + REQUIRE(r.succeeded); + REQUIRE(r.cipher == "TLS_AES_256_GCM_SHA384"); + } + + SUBCASE("the other configured ciphersuite is accepted") + { + const auto r = + handshake_and_inspect(s.port(), {}, "TLS_AES_128_GCM_SHA256"); + REQUIRE(r.succeeded); + REQUIRE(r.cipher == "TLS_AES_128_GCM_SHA256"); + } + + SUBCASE("an unconfigured ciphersuite is refused") + { + // ChaCha20-Poly1305 is a valid TLS 1.3 ciphersuite that CCF does not offer. + const auto r = + handshake_and_inspect(s.port(), {}, "TLS_CHACHA20_POLY1305_SHA256"); + REQUIRE_FALSE(r.succeeded); + } +} + +TEST_CASE("Server restricts key exchange groups to the configured list") +{ + auto [cert, key] = make_server_cert(); + EchoServer s(cert, key); + + SUBCASE("an approved classical group is accepted") + { + const auto r = handshake_and_inspect(s.port(), "P-256"); + REQUIRE(r.succeeded); + REQUIRE(r.group == "secp256r1"); + } + + SUBCASE("the client order decides among approved groups") + { + // In TLS 1.3 OpenSSL selects the first client-offered group the server + // also supports, so the server order is only a filter. + REQUIRE( + handshake_and_inspect(s.port(), "P-521:P-256").group == "secp521r1"); + REQUIRE( + handshake_and_inspect(s.port(), "P-256:P-521").group == "secp256r1"); + } + + SUBCASE("a group the server does not offer is refused") + { + const auto r = handshake_and_inspect(s.port(), "X448"); + REQUIRE_FALSE(r.succeeded); + } + + SUBCASE("an unoffered group falls back to the first shared approved one") + { + const auto r = handshake_and_inspect(s.port(), "X448:P-384"); + REQUIRE(r.succeeded); + REQUIRE(r.group == "secp384r1"); + } +} + +TEST_CASE( + "Server prefers the strongest hybrid post-quantum group" * + doctest::skip(TEST_HYBRID_TLS_GROUPS == 0)) +{ + auto [cert, key] = make_server_cert(); + EchoServer s(cert, key); + + SUBCASE("a client offering all configured groups gets the strongest hybrid") + { + const auto r = handshake_and_inspect( + s.port(), + "SecP384r1MLKEM1024:SecP256r1MLKEM768:X25519MLKEM768:P-521:P-384:P-256"); + REQUIRE(r.succeeded); + REQUIRE(r.group == "SecP384r1MLKEM1024"); + } + + SUBCASE("each configured hybrid group can be negotiated") + { + for (const auto* group : + {"SecP384r1MLKEM1024", "SecP256r1MLKEM768", "X25519MLKEM768"}) + { + const auto r = handshake_and_inspect(s.port(), group); + REQUIRE(r.succeeded); + REQUIRE(r.group == group); + } + } +} diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index 708bfcab1420..beb753b2548a 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -201,6 +201,11 @@ namespace asynchost // Build a server SSL_CTX (min TLS 1.2, ALPN if configured) and load the // cert/key. Returns nullptr on failure. Called on the loop thread. + // + // The cipher, ciphersuite, group and mode configuration below must be kept + // in sync with ccf::tls::Context (src/tls/context.h), which applies the + // same policy to the remaining non-RPC TLS users. tests/tls_groups.py + // asserts the negotiated group against this list. SSL_CTX* build_server_ctx( const std::string& cert_pem, const std::string& key_pem) { @@ -209,11 +214,61 @@ namespace asynchost { return nullptr; } + // Require at least TLS 1.2, support up to 1.3 if (SSL_CTX_set_min_proto_version(c, TLS1_2_VERSION) != 1) { SSL_CTX_free(c); return nullptr; } + + // Disable renegotiation to avoid DoS + SSL_CTX_set_options( + c, + SSL_OP_CIPHER_SERVER_PREFERENCE | + SSL_OP_NO_SESSION_RESUMPTION_ON_RENEGOTIATION | + SSL_OP_NO_RENEGOTIATION); + + // Set cipher for TLS 1.2 + const auto* const cipher_list = + "ECDHE-ECDSA-AES256-GCM-SHA384:" + "ECDHE-ECDSA-AES128-GCM-SHA256:" + "ECDHE-RSA-AES256-GCM-SHA384:" + "ECDHE-RSA-AES128-GCM-SHA256"; + if (SSL_CTX_set_cipher_list(c, cipher_list) != 1) + { + SSL_CTX_free(c); + return nullptr; + } + + // Set cipher for TLS 1.3 + const auto* const ciphersuites = + "TLS_AES_256_GCM_SHA384:" + "TLS_AES_128_GCM_SHA256"; + if (SSL_CTX_set_ciphersuites(c, ciphersuites) != 1) + { + SSL_CTX_free(c); + return nullptr; + } + + // Prefer hybrid post-quantum groups when available, while retaining the + // approved classical groups as fallbacks + if ( + SSL_CTX_set1_groups_list( + c, + "?SecP384r1MLKEM1024:?SecP256r1MLKEM768:?X25519MLKEM768:" + "P-521:P-384:P-256") != 1) + { + SSL_CTX_free(c); + return nullptr; + } + + // Allow buffer to be relocated between WANT_WRITE retries, and do partial + // writes if possible. do_write() retries SSL_write() from a std::vector + // that may have been appended to (and so reallocated) by + // drain_pending_out() since the previous attempt, so both are required. + SSL_CTX_set_mode( + c, SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER | SSL_MODE_ENABLE_PARTIAL_WRITE); + // Request the client certificate during the handshake so it can be used // for application-level caller authentication (user/member cert auth). // Verification is not enforced here - the application decides. @@ -844,6 +899,12 @@ namespace asynchost { continue; } + // SO_REUSEADDR permits rebinding a port left in TIME_WAIT by a + // previous process. Note that SO_REUSEPORT is deliberately *not* set: + // it would suppress EADDRINUSE, so two nodes misconfigured onto the + // same port would both bind successfully and have connections split + // between them at random, and it would let any process with the same + // effective UID siphon off a share of inbound connections. if ( setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)) != 0) @@ -852,15 +913,6 @@ namespace asynchost listen_fd = -1; continue; } - // Allow multiple listeners to bind the same address. - if ( - setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)) != - 0) - { - ::close(listen_fd); - listen_fd = -1; - continue; - } if (bind(listen_fd, ai->ai_addr, ai->ai_addrlen) == 0) { bound_ok = true; diff --git a/tests/tls_groups.py b/tests/tls_groups.py index 759c4c14c9ee..efb350619a66 100644 --- a/tests/tls_groups.py +++ b/tests/tls_groups.py @@ -8,7 +8,8 @@ import suite.test_requirements as reqs from loguru import logger as LOG -# Hybrid groups offered by src/tls/context.h, in the order they are offered +# Hybrid groups offered by src/host/tls/openssl_server.h, in the order they are +# offered HYBRID_GROUPS = ["SecP384r1MLKEM1024", "SecP256r1MLKEM768", "X25519MLKEM768"] # Weakest classical fallback, and the name OpenSSL reports for it From 43b55d402ce38dc42c04d138ec724c8a621e3abd Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Tue, 4 Aug 2026 15:23:35 +0000 Subject: [PATCH 36/59] Remove some dead code --- CMakeLists.txt | 4 - doc/architecture/tls_internals.rst | 4 +- src/host/rpc_connection_manager.h | 1 - src/host/test/openssl_server_test.cpp | 143 +++- src/host/tls/openssl_server.h | 7 +- src/node/node_state.h | 2 - src/tls/README.md | 74 +- src/tls/client.h | 22 - src/tls/context.h | 264 -------- src/tls/plaintext_server.h | 87 --- src/tls/server.h | 74 -- src/tls/test/main.cpp | 936 ++------------------------ src/tls/tls.h | 36 - 13 files changed, 224 insertions(+), 1430 deletions(-) delete mode 100644 src/tls/client.h delete mode 100644 src/tls/context.h delete mode 100644 src/tls/plaintext_server.h delete mode 100644 src/tls/server.h delete mode 100644 src/tls/tls.h diff --git a/CMakeLists.txt b/CMakeLists.txt index d15207120376..0475aa70bba7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -873,10 +873,6 @@ if(BUILD_TESTS) add_unit_test(tls_test ${CMAKE_CURRENT_SOURCE_DIR}/src/tls/test/main.cpp) target_link_libraries(tls_test PRIVATE ${CMAKE_THREAD_LIBS_INIT}) - target_compile_definitions( - tls_test - PRIVATE TEST_HYBRID_TLS_GROUPS=$ - ) add_unit_test( base64_test diff --git a/doc/architecture/tls_internals.rst b/doc/architecture/tls_internals.rst index 946d28a8d642..2b2b8a113379 100644 --- a/doc/architecture/tls_internals.rst +++ b/doc/architecture/tls_internals.rst @@ -62,7 +62,7 @@ The context restricts what the handshake may negotiate: Note that in TLS 1.3 the *client* effectively chooses the group: OpenSSL picks the first client-offered group that the server also supports, so the server list acts as a filter rather than a preference. -The same policy is applied by :ccf_repo:`ccf::tls::Context ` for the remaining non-RPC TLS users, and the two must be kept in sync. It is asserted from the wire by the ``openssl_server_test`` unit tests and by the :ccf_repo:`tls_groups ` end-to-end test, both of which only check the hybrid groups when built with ``-DTEST_HYBRID_TLS_GROUPS=ON``, since those groups require OpenSSL 3.5 or later. +This policy is defined in one place, ``build_server_ctx()``. It is asserted from the wire by the ``openssl_server_test`` unit tests and, for a running service, by the :ccf_repo:`tls_groups ` end-to-end test. Both only check the hybrid groups when built with ``-DTEST_HYBRID_TLS_GROUPS=ON``, since those groups require OpenSSL 3.5 or later. The context also sets ``SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER`` and ``SSL_MODE_ENABLE_PARTIAL_WRITE``, both of which are required by the write path described below. @@ -122,7 +122,7 @@ Why OpenSSL? CCF originally used MbedTLS, and the OpenSSL implementation that replaced it emulated MbedTLS conventions, notably negated error codes and a memory-BIO indirection driven by callbacks. -MbedTLS itself is long gone, and the inbound server path no longer goes anywhere near that emulation: ``OpenSSLServer`` calls ``SSL_read``/``SSL_write`` on a socket-backed ``SSL`` and interprets ``SSL_get_error`` directly. The emulation layer itself, however, still exists in :ccf_repo:`src/tls/context.h ` (``TLS_ERR_WANT_READ`` and friends in :ccf_repo:`src/tls/tls.h ` are negated OpenSSL error codes, and ``set_bio()`` installs callback-driven memory BIOs). ``ccf::tls::Context`` and its ``Client``, ``Server`` and ``PlaintextServer`` subclasses are now reachable only from the ``tls_test`` unit test, and are candidates for removal. +Both are now gone. ``OpenSSLServer`` calls ``SSL_read``/``SSL_write`` on a socket-backed ``SSL`` and interprets ``SSL_get_error`` directly. All that remains in ``src/tls`` are the ``CA`` and ``Cert`` helpers described above. The reasons for OpenSSL remain: diff --git a/src/host/rpc_connection_manager.h b/src/host/rpc_connection_manager.h index e5e955cfce78..fd763b97a719 100644 --- a/src/host/rpc_connection_manager.h +++ b/src/host/rpc_connection_manager.h @@ -30,7 +30,6 @@ #include "http/http_session.h" #include "node/rpc/custom_protocol_subsystem.h" #include "node/session_metrics.h" -#include "tls/cert.h" #include #include diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp index f427be3a5e14..68d4c7855105 100644 --- a/src/host/test/openssl_server_test.cpp +++ b/src/host/test/openssl_server_test.cpp @@ -8,6 +8,7 @@ #include "crypto/certs.h" #include "host/tls/openssl_server.h" #include "host/tls/openssl_session_manager.h" +#include "tls/ca.h" #define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN #include @@ -59,6 +60,42 @@ namespace return {cert.str(), kp->private_key_pem().str()}; } + struct TestCA + { + ccf::crypto::ECKeyPairPtr kp; + ccf::crypto::Pem cert; + }; + + TestCA make_ca() + { + using namespace std::literals; + auto kp = ccf::crypto::make_ec_key_pair(); + const auto valid_from = + ccf::ds::to_x509_time_string(std::chrono::system_clock::now() - 24h); + return { + kp, + ccf::crypto::create_self_signed_cert( + kp, "CN=issuer", {}, valid_from, /*validity_days*/ 365)}; + } + + std::pair make_endorsed_server_cert( + const TestCA& ca) + { + using namespace std::literals; + auto kp = ccf::crypto::make_ec_key_pair(); + const auto valid_from = + ccf::ds::to_x509_time_string(std::chrono::system_clock::now() - 24h); + auto cert = ccf::crypto::create_endorsed_cert( + kp, + "CN=localhost", + {}, + valid_from, + /*validity_days*/ 365, + ca.kp->private_key_pem(), + ca.cert); + return {cert.str(), kp->private_key_pem().str()}; + } + // Blocking TLS client: connects, sends `req` in full, reads exactly // `expected_resp` bytes. Verification is disabled for the self-signed test // certificate. @@ -222,6 +259,44 @@ namespace return result; } + // Handshakes with a client that verifies the server certificate against + // `trusted_ca`, the way ::tls::CA configures outbound CCF connections. + // Returns whether the handshake completed. + bool verifying_client_handshake(uint16_t port, const ccf::crypto::Pem& ca) + { + const int fd = ::socket(AF_INET, SOCK_STREAM, 0); + REQUIRE(fd >= 0); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + REQUIRE(inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) == 1); + REQUIRE( + ::connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0); + + SSL_CTX* cctx = SSL_CTX_new(TLS_client_method()); + REQUIRE(cctx != nullptr); + ::tls::CA(ca.str()).configure_trusted_cert_store(cctx); + SSL_CTX_set_verify(cctx, SSL_VERIFY_PEER, nullptr); + + SSL* ssl = SSL_new(cctx); + REQUIRE(ssl != nullptr); + REQUIRE(SSL_set_fd(ssl, fd) == 1); + SSL_set_connect_state(ssl); + + const bool ok = SSL_connect(ssl) == 1; + if (ok) + { + REQUIRE(SSL_get_verify_result(ssl) == X509_V_OK); + SSL_shutdown(ssl); + } + + SSL_free(ssl); + SSL_CTX_free(cctx); + ::close(fd); + return ok; + } + // Echoes received plaintext back to the same connection via send(). struct EchoServer { @@ -493,6 +568,70 @@ TEST_CASE("Peer certificate is captured for inbound connections") mgr.stop(); } +// The server deliberately does not enforce client certificate validity: it +// requests one and hands whatever arrives to the application, which decides. +// A client presenting no certificate at all must therefore still connect. +TEST_CASE("Client certificate is requested but not enforced") +{ + auto [cert, key] = make_server_cert(); + + std::mutex m; + std::vector captured; + std::atomic got{false}; + + OpenSSLSessionManager mgr( + cert, + key, + "127.0.0.1", + static_cast(0), + [&](::tcp::ConnID id, ccf::SessionWriter& w, std::vector pc) { + { + std::lock_guard l(m); + captured = std::move(pc); + } + got.store(true); + return std::make_shared(id, w); + }); + mgr.start(); + + const std::vector msg = {'n', 'o', 'c', 'e', 'r', 't'}; + REQUIRE(tls_client_exchange(mgr.port(), msg, msg.size()) == msg); + + REQUIRE(got.load()); + std::lock_guard l(m); + REQUIRE(captured.empty()); + + mgr.stop(); +} + +// The server certificate must be verifiable by a client that trusts the CA +// which endorsed it, and rejected by one that does not. +TEST_CASE("Server certificate is verified by the client") +{ + auto ca = make_ca(); + + SUBCASE("a client trusting the endorsing CA completes the handshake") + { + auto [cert, key] = make_endorsed_server_cert(ca); + EchoServer s(cert, key); + REQUIRE(verifying_client_handshake(s.port(), ca.cert)); + } + + SUBCASE("a client trusting a different CA rejects the server") + { + auto [cert, key] = make_endorsed_server_cert(ca); + EchoServer s(cert, key); + REQUIRE_FALSE(verifying_client_handshake(s.port(), make_ca().cert)); + } + + SUBCASE("a verifying client rejects a self-signed server certificate") + { + auto [cert, key] = make_server_cert(); + EchoServer s(cert, key); + REQUIRE_FALSE(verifying_client_handshake(s.port(), ca.cert)); + } +} + namespace { // Connect to host:port (resolved via getaddrinfo, any family), TLS @@ -662,8 +801,8 @@ TEST_CASE("Persistent connection survives many sequential round-trips") ::close(fd); } -// The server's cipher, ciphersuite and group policy must match -// ccf::tls::Context (src/tls/context.h). These assert it from the wire. +// The server's cipher, ciphersuite and group policy is defined in +// build_server_ctx(). These assert it from the wire. TEST_CASE("Server restricts TLS 1.3 ciphersuites to the configured list") { diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index beb753b2548a..1cd9d3cfdb65 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -202,10 +202,9 @@ namespace asynchost // Build a server SSL_CTX (min TLS 1.2, ALPN if configured) and load the // cert/key. Returns nullptr on failure. Called on the loop thread. // - // The cipher, ciphersuite, group and mode configuration below must be kept - // in sync with ccf::tls::Context (src/tls/context.h), which applies the - // same policy to the remaining non-RPC TLS users. tests/tls_groups.py - // asserts the negotiated group against this list. + // This is the only place CCF's inbound TLS policy is defined. It is + // asserted from the wire by src/host/test/openssl_server_test.cpp and, for + // a running service, by tests/tls_groups.py. SSL_CTX* build_server_ctx( const std::string& cert_pem, const std::string& key_pem) { diff --git a/src/node/node_state.h b/src/node/node_state.h index 37dc4ae39333..a26d6ac68d86 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -59,8 +59,6 @@ #include "share_manager.h" #include "snapshots/fetch.h" #include "snapshots/filenames.h" -#include "tls/ca.h" -#include "tls/cert.h" #include "uvm_endorsements.h" #include diff --git a/src/tls/README.md b/src/tls/README.md index 0585b0535085..7f19ca9de495 100644 --- a/src/tls/README.md +++ b/src/tls/README.md @@ -1,67 +1,15 @@ -# OpenSSL TLS Implementation +# TLS certificate helpers -This is a TLS implementation using OpenSSL that mimics the existing MbedTLS -one in a similar fashion. Because of that, some structures and call backs -look odd and have some work-arounds to make it fit the current workflow. +This directory holds the two certificate helpers used to configure OpenSSL +objects with a trusted root and an owned identity: -Once we completely deprecate the MbedTLS implementation from CCF, we should -re-write the TLS implementation to fit the OpenSSL coding flow, which would -make it much simpler and easier to use. +- `CA` parses one or more root certificates and populates an `SSL_CTX`'s + trusted certificate store. +- `Cert` holds an endpoint's own certificate and private key, and applies them + (and the peer verification mode) to an `SSL_CTX` or `SSL`. -## CAs and Certificates +They are used by outbound connections, by the JS `isValidX509CertChain` API, +and by the test clients in `src/clients`. -In the MbedTLS world, certificates can be null and have methods to change -some configurations in the TLS config/session objects. There isn't a lot of -cross-over, so updating the config does the trick. - -However, in OpenSSL, session objects (ssl) are created from config objects -(cfg) and inherit all its properties. Therefore, to emulate MbedTLS, we need -to do to the session object every action we do to the config object, which is -not only redundant, but could be unsafe, if the calls are slightly different. - -### Validation - -Certificate validation can be complex to handle if you can accept connections -with certificates or not, and if they come, when and how to validate. - -MbedTLS is a lot more lenient on checks. For example, CAs are not tested for -validity of actually signing other certificates, while OpenSSL has extensive -checks, which can fail functionality that was previously passing. - -For this reason, a number of extra checks in the OpenSSL side were disabled. -Once we get rid of MbedTLS we should revisit those checks again and improve -CCF's usage of TLS, and perhaps also creating weaker checks for non-CA -certificates, etc. - -## Context - -### BIOs - -MbedTLS operates reads and writes solely via callbacks, with a buffer in the -session object acting as async I/O. This is in stark contrast with OpenSSL -which uses BIO objects to pass information back and forth, and only have -callbacks for debug or very specialized cases. - -We had to implement callbacks and specialize our case, but it could really be -just done with BIOs between the ring buffer and the context, but we'd have to -change a lot of code outside of the TLS implementation to add that. - -### Reads and Writes - -Reading and writing in MbedTLS returns a positive value for success (number of -bytes written) or a negative value for error (pre-defined error codes) including -WANTS_READ and WANTS_WRITE. - -In OpenSSL, those methods return 1 for success and 0 or -1 for errors (depending -on the version), with all errors, including WANTS_READ and WANTS_WRITE -accessible through `SSL_get_error`. This imposes a number of hacks needed to -mimic the MbedTLS implementation, including: - -- Multiple `#define`s with common error messages in `tls.h` -- Having to negate the error code to match -- Multiple checks to `SSL_want_read` and `SSL_want_write` - -### Error Handling - -As discussed above, the error handling is slightly different and promotes -verbose code in OpenSSL's side. +The TLS transport itself lives in `src/host/tls`. See +`doc/architecture/tls_internals.rst` for how it works. diff --git a/src/tls/client.h b/src/tls/client.h deleted file mode 100644 index c3e6a2ac718c..000000000000 --- a/src/tls/client.h +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the Apache 2.0 License. -#pragma once - -#include "context.h" - -namespace tls -{ - class Client : public ccf::tls::Context - { - private: - std::shared_ptr cert; - - public: - Client(std::shared_ptr cert_) : Context(true), cert(std::move(cert_)) - { - cert->configure_context(cfg); - create_ssl(); - cert->configure_connection(get_ssl()); - } - }; -} diff --git a/src/tls/context.h b/src/tls/context.h deleted file mode 100644 index 69be4fe3ff31..000000000000 --- a/src/tls/context.h +++ /dev/null @@ -1,264 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the Apache 2.0 License. -#pragma once - -#include "ccf/crypto/base64.h" -#include "cert.h" -#include "ds/internal_logger.h" -#include "tls/tls.h" - -#include -#include -#include - -namespace ccf::tls -{ - class Context - { - protected: - ccf::crypto::OpenSSL::Unique_SSL_CTX cfg; - std::unique_ptr ssl; - bool client; - - void create_ssl() - { - ssl = std::make_unique(cfg); - - // Initialise connection - if (client) - { - SSL_set_connect_state(*ssl); - } - else - { - SSL_set_accept_state(*ssl); - } - } - - SSL* get_ssl() - { - // Context construction is split from SSL creation, so catch accidental - // use before create_ssl(). - CHECKNULL(ssl.get()); - CHECKNULL(*ssl); - return *ssl; - } - - public: - Context(bool client_) : - cfg(client_ ? TLS_client_method() : TLS_server_method()), - client(client_) - { - // Require at least TLS 1.2, support up to 1.3 - CHECK1(SSL_CTX_set_min_proto_version(cfg, TLS1_2_VERSION)); - - // Disable renegotiation to avoid DoS - SSL_CTX_set_options( - cfg, - SSL_OP_CIPHER_SERVER_PREFERENCE | - SSL_OP_NO_SESSION_RESUMPTION_ON_RENEGOTIATION | - SSL_OP_NO_RENEGOTIATION); - - // Set cipher for TLS 1.2 - const auto* const cipher_list = - "ECDHE-ECDSA-AES256-GCM-SHA384:" - "ECDHE-ECDSA-AES128-GCM-SHA256:" - "ECDHE-RSA-AES256-GCM-SHA384:" - "ECDHE-RSA-AES128-GCM-SHA256"; - CHECK1(SSL_CTX_set_cipher_list(cfg, cipher_list)); - - // Set cipher for TLS 1.3 - const auto* const ciphersuites = - "TLS_AES_256_GCM_SHA384:" - "TLS_AES_128_GCM_SHA256"; - CHECK1(SSL_CTX_set_ciphersuites(cfg, ciphersuites)); - - // Prefer hybrid post-quantum groups when available, while retaining the - // approved classical groups as fallbacks - CHECK1(SSL_CTX_set1_groups_list( - cfg, - "?SecP384r1MLKEM1024:?SecP256r1MLKEM768:?X25519MLKEM768:" - "P-521:P-384:P-256")); - - // Allow buffer to be relocated between WANT_WRITE retries, and do partial - // writes if possible - SSL_CTX_set_mode( - cfg, - SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER | SSL_MODE_ENABLE_PARTIAL_WRITE); - } - - virtual ~Context() = default; - - virtual void set_bio( - void* cb_obj, BIO_callback_fn_ex send, BIO_callback_fn_ex recv) - { - // Read/Write BIOs will be used by TLS - std::unique_ptr rbio( - BIO_new(BIO_s_mem()), BIO_free); - CHECKNULL(rbio.get()); - - std::unique_ptr wbio( - BIO_new(BIO_s_mem()), BIO_free); - CHECKNULL(wbio.get()); - - BIO_set_mem_eof_return(rbio.get(), -1); - BIO_set_callback_arg(rbio.get(), static_cast(cb_obj)); - BIO_set_callback_ex(rbio.get(), recv); - SSL_set0_rbio(get_ssl(), rbio.release()); - - BIO_set_mem_eof_return(wbio.get(), -1); - BIO_set_callback_arg(wbio.get(), static_cast(cb_obj)); - BIO_set_callback_ex(wbio.get(), send); - SSL_set0_wbio(get_ssl(), wbio.release()); - } - - virtual int handshake() - { - if (SSL_is_init_finished(get_ssl()) != 0) - { - return 0; - } - - int rc = SSL_do_handshake(get_ssl()); - // Success in OpenSSL is 1, MBed is 0 - if (rc > 0) - { - LOG_TRACE_FMT("Context::handshake() : Success"); - return 0; - } - - // Want read/write needs special return - if (SSL_want_read(get_ssl())) - { - return TLS_ERR_WANT_READ; - } - - if (SSL_want_write(get_ssl())) - { - return TLS_ERR_WANT_WRITE; - } - - // So does x509 validation - if (!peer_cert_ok()) - { - return TLS_ERR_X509_VERIFY; - } - - // Everything else falls here. - LOG_TRACE_FMT("Context::handshake() : Error code {}", rc); - - // As an MBedTLS emulation, we return negative for errors. - return -SSL_get_error(get_ssl(), rc); - } - - virtual int read(uint8_t* buf, size_t len) - { - if (len == 0) - { - return 0; - } - size_t readbytes = 0; - int rc = SSL_read_ex(get_ssl(), buf, len, &readbytes); - if (rc > 0) - { - return readbytes; - } - if (SSL_want_read(get_ssl())) - { - return TLS_ERR_WANT_READ; - } - - // Everything else falls here. - LOG_TRACE_FMT("Context::read() : Error code {}", rc); - - // As an MBedTLS emulation, we return negative for errors. - return -SSL_get_error(get_ssl(), rc); - } - - virtual int write(const uint8_t* buf, size_t len) - { - if (len == 0) - { - return 0; - } - size_t written = 0; - int rc = SSL_write_ex(get_ssl(), buf, len, &written); - if (rc > 0) - { - return written; - } - if (SSL_want_write(get_ssl())) - { - return TLS_ERR_WANT_WRITE; - } - - // Everything else falls here. - LOG_TRACE_FMT("Context::write() : Error code {}", rc); - - // As an MBedTLS emulation, we return negative for errors. - return -SSL_get_error(get_ssl(), rc); - } - - virtual int close() - { - LOG_TRACE_FMT("Context::close() : Shutdown"); - return SSL_shutdown(get_ssl()); - } - - virtual bool peer_cert_ok() - { - return SSL_get_verify_result(get_ssl()) == X509_V_OK; - } - - virtual std::string get_verify_error() - { - return X509_verify_cert_error_string(SSL_get_verify_result(get_ssl())); - } - - virtual std::string host() - { - return {}; - } - - virtual std::vector peer_cert() - { - // CodeQL complains that we don't verify the peer certificate. We don't - // need to do that because it's been verified before and we use - // SSL_get_peer_certificate just to extract it from the context. - - ccf::crypto::OpenSSL::Unique_X509 cert( - SSL_get_peer_certificate(get_ssl()), /*check_null=*/false); - if (cert == nullptr) - { - LOG_TRACE_FMT("Empty peer cert"); - return {}; - } - ccf::crypto::OpenSSL::Unique_BIO bio; - if (i2d_X509_bio(bio, cert) == 0) - { - LOG_TRACE_FMT("Can't convert X509 to DER"); - return {}; - } - - // Get the total length of the DER representation - auto len = BIO_get_mem_data(bio, nullptr); - if (len == 0) - { - LOG_TRACE_FMT("Null X509 peer cert"); - return {}; - } - - // Get the BIO memory pointer - BUF_MEM* ptr = nullptr; - if (BIO_get_mem_ptr(bio, &ptr) == 0) - { - LOG_TRACE_FMT("Invalid X509 peer cert"); - return {}; - } - - // Return its contents as a vector - auto ret = std::vector(ptr->data, ptr->data + len); - return ret; - } - }; -} diff --git a/src/tls/plaintext_server.h b/src/tls/plaintext_server.h deleted file mode 100644 index 60648e091984..000000000000 --- a/src/tls/plaintext_server.h +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the Apache 2.0 License. -#pragma once - -#include "context.h" - -namespace nontls -{ - class PlaintextServer : public ccf::tls::Context - { - public: - PlaintextServer() : Context(false) {} - - protected: - Unique_BIO read_bio; - Unique_BIO write_bio; - - public: - void set_bio( - void* cb_obj, BIO_callback_fn_ex send, BIO_callback_fn_ex recv) override - { - // Read/Write BIOs will be used by TLS - BIO_set_mem_eof_return(read_bio, -1); - BIO_set_callback_arg(read_bio, static_cast(cb_obj)); - BIO_set_callback_ex(read_bio, recv); - - BIO_set_mem_eof_return(write_bio, -1); - BIO_set_callback_arg(write_bio, static_cast(cb_obj)); - BIO_set_callback_ex(write_bio, send); - } - - int handshake() override - { - return 0; - } - - int read(uint8_t* buf, size_t len) override - { - if (len == 0) - { - return 0; - } - size_t readbytes = 0; - int rc = BIO_read_ex(read_bio, buf, len, &readbytes); - if (rc > 0) - { - return readbytes; - } - return -rc; - } - - int write(const uint8_t* buf, size_t len) override - { - if (len == 0) - { - return 0; - } - size_t written = 0; - int rc = BIO_write_ex(write_bio, buf, len, &written); - if (rc > 0) - { - return written; - } - return -rc; - } - - int close() override - { - return 0; - } - - bool peer_cert_ok() override - { - return true; - } - - std::string get_verify_error() override - { - return "no error"; - } - - std::vector peer_cert() override - { - return {}; - } - }; -} \ No newline at end of file diff --git a/src/tls/server.h b/src/tls/server.h deleted file mode 100644 index eb777603538e..000000000000 --- a/src/tls/server.h +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the Apache 2.0 License. -#pragma once - -#include "context.h" - -namespace tls -{ - struct AlpnProtocols - { - const unsigned char* data; - unsigned int size; - }; - - static int alpn_select_cb( - SSL* /*ssl*/, - const unsigned char** out, - unsigned char* outlen, - const unsigned char* in, - unsigned int inlen, - void* arg) - { - auto* protos = static_cast(arg); - - if ( - SSL_select_next_proto( - const_cast(out), - outlen, - protos->data, - protos->size, - in, - inlen) != OPENSSL_NPN_NEGOTIATED) - { - return SSL_TLSEXT_ERR_NOACK; - } - - return SSL_TLSEXT_ERR_OK; - } - - class Server : public ccf::tls::Context - { - private: - std::shared_ptr cert; - - public: - Server(const std::shared_ptr& cert_, bool http2 = false) : - Context(false), - cert(cert_) - { - cert->configure_context(cfg); - - // Configure protocols negotiated by ALPN - // See https://nghttp2.org/documentation/tutorial-server.html and use of - // nghttp2_select_next_protocol for better example - if (http2) - { - static unsigned char alpn_protos_data[] = {2, 'h', '2'}; - static AlpnProtocols alpn_protos{ - alpn_protos_data, sizeof(alpn_protos_data)}; - SSL_CTX_set_alpn_select_cb(cfg, alpn_select_cb, &alpn_protos); - } - else - { - static unsigned char alpn_protos_data[] = { - 8, 'h', 't', 't', 'p', '/', '1', '.', '1'}; - static AlpnProtocols alpn_protos{ - alpn_protos_data, sizeof(alpn_protos_data)}; - SSL_CTX_set_alpn_select_cb(cfg, alpn_select_cb, &alpn_protos); - } - - create_ssl(); - } - }; -} diff --git a/src/tls/test/main.cpp b/src/tls/test/main.cpp index e36106e4998c..8fefde1a75a4 100644 --- a/src/tls/test/main.cpp +++ b/src/tls/test/main.cpp @@ -1,288 +1,78 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the Apache 2.0 License. + +// Unit tests for the certificate helpers in src/tls, which configure an +// OpenSSL context and connection with a trusted root and an owned identity. +// The TLS transport itself is tested in src/host/test/openssl_server_test.cpp. + #include "ccf/crypto/ec_key_pair.h" #include "ccf/crypto/verifier.h" -#include "ccf/ds/nonstd.h" +#include "ccf/ds/x509_time_fmt.h" #include "crypto/certs.h" -#include "ds/internal_logger.h" -#include "tcp/msg_types.h" -#include "tls/client.h" -#include "tls/server.h" -#include "tls/tls.h" +#include "tls/ca.h" +#include "tls/cert.h" -#include -#include -#include -#include #define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include #include -#include #include +#include #include -#include -#include -using namespace std; -using namespace ccf::crypto; -using namespace tls; - -/// Server uses one pipe while client uses the other. -/// Writes always to one side, reads always from the other. -/// Use the send/recv template wrappers below as callbacks. -class TestPipe +namespace { - int pfd[2]; - -public: - static const int SERVER = 0; - static const int CLIENT = 1; - - TestPipe() - { - if (socketpair(PF_LOCAL, SOCK_STREAM, 0, pfd) == -1) - { - throw runtime_error( - "Failed to create socketpair: " + string(ccf::nonstd::strerror(errno))); - } - } - ~TestPipe() - { - close(pfd[0]); - close(pfd[1]); - } + constexpr size_t certificate_validity_period_days = 365; - size_t send(int id, const uint8_t* buf, size_t len) + std::string valid_from_yesterday() { - int rc = write(pfd[id], buf, len); - if (rc == -1) - LOG_FAIL_FMT("Error while reading: {}", ccf::nonstd::strerror(errno)); - return rc; + using namespace std::literals; + return ccf::ds::to_x509_time_string(std::chrono::system_clock::now() - 24h); } - size_t recv(int id, uint8_t* buf, size_t len) + ccf::crypto::Pem generate_self_signed_cert( + const ccf::crypto::ECKeyPairPtr& kp, const std::string& name) { - int rc = read(pfd[id], buf, len); - if (rc == -1) - LOG_FAIL_FMT("Error while reading: {}", ccf::nonstd::strerror(errno)); - return rc; + return ccf::crypto::create_self_signed_cert( + kp, name, {}, valid_from_yesterday(), certificate_validity_period_days); } -}; - -/// Callback wrapper around TestPipe->send(). -template -int send(void* ctx, const uint8_t* buf, size_t len) -{ - auto pipe = reinterpret_cast(ctx); - int rc = pipe->send(end, buf, len); - REQUIRE(rc == len); - return rc; -} -/// Callback wrapper around TestPipe->recv(). -template -int recv(void* ctx, uint8_t* buf, size_t len) -{ - auto pipe = reinterpret_cast(ctx); - int rc = pipe->recv(end, buf, len); - REQUIRE(rc == len); - return rc; -} - -// OpenSSL callbacks that call onto the pipe's ones -template -long send( - BIO* b, - int oper, - const char* argp, - size_t len, - int argi, - long argl, - int ret, - size_t* processed) -{ - // Unused arguments - (void)argi; - (void)argl; - (void)processed; - - if (ret && oper == (BIO_CB_WRITE | BIO_CB_RETURN)) + struct NetworkCA { - // Flush the BIO so the "pipe doesn't clog", but we don't use the - // data here, because 'argp' already has it. - BIO_flush(b); - size_t pending = BIO_pending(b); - if (pending) - BIO_reset(b); - - // Pipe object - auto pipe = reinterpret_cast(BIO_get_callback_arg(b)); - size_t put = send(pipe, (const uint8_t*)argp, len); - REQUIRE(put == len); - } + ccf::crypto::ECKeyPairPtr kp; + ccf::crypto::Pem cert; + }; - // Unless we detected an error, the return value is always the same as the - // original operation. - return ret; -} - -template -long recv( - BIO* b, - int oper, - const char* argp, - size_t len, - int argi, - long argl, - int ret, - size_t* processed) -{ - // Unused arguments - (void)argi; - (void)argl; - - if (ret && oper == (BIO_CB_READ | BIO_CB_RETURN)) + /// Get self-signed CA certificate. + NetworkCA get_ca() { - // Pipe object - auto pipe = reinterpret_cast(BIO_get_callback_arg(b)); - size_t got = recv(pipe, (uint8_t*)argp, len); - - // Got nothing, return "WANTS READ" - if (got <= 0) - return ret; - - // Write to the actual BIO so SSL can use it - BIO_write_ex(b, argp, got, processed); - - // If original return was -1 because it didn't find anything to read, return - // 1 to say we actually read something - if (got > 0 && ret < 0) - return 1; + auto kp = ccf::crypto::make_ec_key_pair(); + return {kp, generate_self_signed_cert(kp, "CN=issuer")}; } - // Unless we detected an error, the return value is always the same as the - // original operation. - return ret; -} - -/// Performs a TLS handshake, looping until there's nothing more to read/write. -/// Returns 0 on success, throws a runtime error with SSL error str on failure. -int handshake(ccf::tls::Context* ctx, std::atomic& keep_going) -{ - while (keep_going) + /// Creates a ::tls::Cert endorsed by the given CA. + std::unique_ptr<::tls::Cert> get_dummy_cert( + NetworkCA& net_ca, const std::string& name, bool auth_required = true) { - int rc = ctx->handshake(); - - switch (rc) - { - case 0: - return 0; - - case TLS_ERR_WANT_READ: - case TLS_ERR_WANT_WRITE: - // Continue calling handshake until finished - LOG_DEBUG_FMT("Handshake wants data"); - break; + auto ca = std::make_unique<::tls::CA>(net_ca.cert.str()); - case TLS_ERR_NEED_CERT: - { - LOG_FAIL_FMT("Handshake error: {}", ::tls::error_string(rc)); - return 1; - } + // Create a signing request and sign with the CA + auto kp = ccf::crypto::make_ec_key_pair(); + auto crt = ccf::crypto::create_endorsed_cert( + kp, + "CN=" + name, + {}, + valid_from_yesterday(), + certificate_validity_period_days, + net_ca.kp->private_key_pem(), + net_ca.cert); - case TLS_ERR_CONN_CLOSE_NOTIFY: - { - LOG_FAIL_FMT("Handshake error: {}", ::tls::error_string(rc)); - return 1; - } + // Verify node certificate with the CA's certificate + auto v = ccf::crypto::make_verifier(crt); + REQUIRE(v->verify_certificate({&net_ca.cert})); - case TLS_ERR_X509_VERIFY: - { - auto err = ctx->get_verify_error(); - LOG_FAIL_FMT("Handshake error: {} [{}]", err, ::tls::error_string(rc)); - return 1; - } - - default: - { - LOG_FAIL_FMT("Handshake error: {}", ::tls::error_string(rc)); - return 1; - } - } + return std::make_unique<::tls::Cert>( + std::move(ca), crt, kp->private_key_pem(), std::nullopt, auth_required); } - - return 0; -} - -struct NetworkCA -{ - shared_ptr kp; - ccf::crypto::Pem cert; -}; - -static ccf::crypto::Pem generate_self_signed_cert( - const ccf::crypto::ECKeyPairPtr& kp, const std::string& name) -{ - using namespace std::literals; - constexpr size_t certificate_validity_period_days = 365; - auto valid_from = - ccf::ds::to_x509_time_string(std::chrono::system_clock::now() - 24h); - - return ccf::crypto::create_self_signed_cert( - kp, name, {}, valid_from, certificate_validity_period_days); -} - -static ccf::crypto::Pem generate_endorsed_cert( - const ccf::crypto::ECKeyPairPtr& kp, - const std::string& name, - const ccf::crypto::ECKeyPairPtr& issuer_kp, - const ccf::crypto::Pem& issuer_cert) -{ - constexpr size_t certificate_validity_period_days = 365; - - using namespace std::literals; - auto valid_from = - ccf::ds::to_x509_time_string(std::chrono::system_clock::now() - 24h); - - return ccf::crypto::create_endorsed_cert( - kp, - name, - {}, - valid_from, - certificate_validity_period_days, - issuer_kp->private_key_pem(), - issuer_cert); -} - -/// Get self-signed CA certificate. -NetworkCA get_ca() -{ - // Create a CA with a self-signed certificate - auto kp = ccf::crypto::make_ec_key_pair(); - auto crt = generate_self_signed_cert(kp, "CN=issuer"); - LOG_DEBUG_FMT("New self-signed CA certificate:\n{}", crt.str()); - return {kp, crt}; -} - -/// Creates a ::tls::Cert with a new CA using a new self-signed Pem certificate. -unique_ptr<::tls::Cert> get_dummy_cert( - NetworkCA& net_ca, string name, bool auth_required = true) -{ - // Create a CA with a self-signed certificate - auto ca = make_unique<::tls::CA>(net_ca.cert.str()); - - // Create a signing request and sign with the CA - auto kp = ccf::crypto::make_ec_key_pair(); - auto crt = generate_endorsed_cert(kp, "CN=" + name, net_ca.kp, net_ca.cert); - LOG_DEBUG_FMT("New CA-signed certificate:\n{}", crt.str()); - - // Verify node certificate with the CA's certificate - auto v = ccf::crypto::make_verifier(crt); - REQUIRE(v->verify_certificate({&net_ca.cert})); - - // Create a ::tls::Cert with the CA, the signed certificate and the private - // key - auto pk = kp->private_key_pem(); - return make_unique(std::move(ca), crt, pk, std::nullopt, auth_required); } TEST_CASE("CA configures trusted certificate store") @@ -304,627 +94,35 @@ TEST_CASE("CA configures trusted certificate store") TEST_CASE("Cert configures TLS verification and own certificate") { auto ca = get_ca(); - auto cert = get_dummy_cert(ca, "server"); - ccf::crypto::OpenSSL::Unique_SSL_CTX ctx(TLS_method()); - - cert->configure_context(ctx); - ccf::crypto::OpenSSL::Unique_SSL ssl(ctx); - cert->configure_connection(ssl); - constexpr auto expected_verify_mode = - SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT; - REQUIRE(SSL_CTX_get_verify_mode(ctx) == expected_verify_mode); - REQUIRE(SSL_get_verify_mode(ssl) == expected_verify_mode); - REQUIRE(SSL_CTX_get0_certificate(ctx) != nullptr); - REQUIRE(SSL_get_certificate(ssl) != nullptr); -} - -/// Helper to write past the maximum buffer (16k) -int write_helper(ccf::tls::Context& handler, const uint8_t* buf, size_t len) -{ - LOG_DEBUG_FMT("WRITE {} bytes", len); - int rc = handler.write(buf, len); - if (rc <= 0 || (size_t)rc == len) - return rc; - return rc + write_helper(handler, buf + rc, len - rc); -} - -/// Helper to read past the maximum buffer (16k) -int read_helper(ccf::tls::Context& handler, uint8_t* buf, size_t len) -{ - LOG_DEBUG_FMT("READ {} bytes", len); - int rc = handler.read(buf, len); - if (rc <= 0 || (size_t)rc == len) - return rc; - return rc + read_helper(handler, buf + rc, len - rc); -} - -/// Helper to truncate long messages to make logs more readable -std::string truncate_message(const uint8_t* msg, size_t len) -{ - const size_t MAX_LEN = 32; - if (len < MAX_LEN) - return std::string((const char*)msg); - std::string str((const char*)msg, MAX_LEN); - str += "... + " + std::to_string(len - MAX_LEN); - return str; -} - -void run_handshake(tls::Server& server, tls::Client& client) -{ - std::atomic keep_going = true; - std::optional client_exception, server_exception; - - thread client_thread([&client, &keep_going, &client_exception]() { - LOG_INFO_FMT("Client handshake"); - try - { - if (handshake(&client, keep_going)) - throw runtime_error("Client handshake error"); - } - catch (std::runtime_error& ex) - { - keep_going = false; - client_exception = ex; - } - }); - - thread server_thread([&server, &keep_going, &server_exception]() { - LOG_INFO_FMT("Server handshake"); - try - { - if (handshake(&server, keep_going)) - throw runtime_error("Server handshake error"); - } - catch (std::runtime_error& ex) - { - keep_going = false; - server_exception = ex; - } - }); - - client_thread.join(); - server_thread.join(); - LOG_INFO_FMT("Handshake completed"); - - if (client_exception) - { - throw *client_exception; - } - if (server_exception) + SUBCASE("auth_required requires a peer certificate") { - throw *server_exception; - } -} - -/// Test runner, with various options for different kinds of tests. -void run_test_case( - const uint8_t* message, - size_t message_length, - const uint8_t* response, - size_t response_length, - unique_ptr<::tls::Cert> server_cert, - unique_ptr<::tls::Cert> client_cert) -{ - std::vector buf(max(message_length, response_length) + 1); - - // Create a pair of client/server - tls::Server server(std::move(server_cert)); - tls::Client client(std::move(client_cert)); + auto cert = get_dummy_cert(ca, "server"); + ccf::crypto::OpenSSL::Unique_SSL_CTX ctx(TLS_method()); - // Connect BIOs together - TestPipe pipe; - server.set_bio(&pipe, send, recv); - client.set_bio(&pipe, send, recv); + cert->configure_context(ctx); + ccf::crypto::OpenSSL::Unique_SSL ssl(ctx); + cert->configure_connection(ssl); - run_handshake(server, client); - - // The rest of the communication is deterministic and easy to simulate - // so we take them out of the thread, to guarantee there will be bytes - // to read at the right time. - if (message_length == 0) - { - LOG_INFO_FMT("Empty message. Ignoring communication test"); - LOG_INFO_FMT("Closing connection"); - client.close(); - server.close(); - return; + constexpr auto expected_verify_mode = + SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT; + REQUIRE(SSL_CTX_get_verify_mode(ctx) == expected_verify_mode); + REQUIRE(SSL_get_verify_mode(ssl) == expected_verify_mode); + REQUIRE(SSL_CTX_get0_certificate(ctx) != nullptr); + REQUIRE(SSL_get_certificate(ssl) != nullptr); } - // Send the first message - LOG_INFO_FMT( - "Client sending message [{}]", truncate_message(message, message_length)); - int written = write_helper(client, message, message_length); - REQUIRE(written == message_length); - - // Receive the first message - int read = read_helper(server, buf.data(), message_length); - REQUIRE(read == message_length); - buf[message_length] = '\0'; - LOG_INFO_FMT( - "Server message received [{}]", - truncate_message(buf.data(), message_length)); - REQUIRE( - strncmp((const char*)buf.data(), (const char*)message, message_length) == - 0); - - // Send the response - LOG_INFO_FMT( - "Server sending message [{}]", truncate_message(response, message_length)); - written = write_helper(server, response, response_length); - REQUIRE(written == response_length); - - // Receive the response - read = read_helper(client, buf.data(), response_length); - REQUIRE(read == response_length); - buf[response_length] = '\0'; - LOG_INFO_FMT( - "Client message received [{}]", - truncate_message(buf.data(), message_length)); - REQUIRE( - strncmp((const char*)buf.data(), (const char*)response, response_length) == - 0); - - LOG_INFO_FMT("Closing connection"); - client.close(); - server.close(); -} - -std::string negotiated_group_name(SSL* ssl) -{ - const auto group_id = SSL_get_negotiated_group(ssl); - if (group_id == NID_undef) + SUBCASE("without auth_required a peer certificate is requested, not required") { - return {}; - } + auto cert = get_dummy_cert(ca, "server", false); + ccf::crypto::OpenSSL::Unique_SSL_CTX ctx(TLS_method()); - const auto* group_name = SSL_group_to_name(ssl, group_id); - if (group_name != nullptr) - { - return group_name; - } + cert->configure_context(ctx); + ccf::crypto::OpenSSL::Unique_SSL ssl(ctx); + cert->configure_connection(ssl); - return std::to_string(group_id); -} - -class InspectableClient : public tls::Client -{ -public: - using tls::Client::Client; - - InspectableClient( - std::shared_ptr<::tls::Cert> cert, const std::string& groups) : - tls::Client(std::move(cert)) - { - REQUIRE(SSL_set1_groups_list(get_ssl(), groups.c_str()) == 1); - } - - int verify_mode() - { - return SSL_get_verify_mode(get_ssl()); + // The connection inherits the context's verification mode + REQUIRE((SSL_get_verify_mode(ssl) & SSL_VERIFY_PEER) != 0); + REQUIRE((SSL_get_verify_mode(ssl) & SSL_VERIFY_FAIL_IF_NO_PEER_CERT) == 0); } - - std::string negotiated_group() - { - return negotiated_group_name(get_ssl()); - } -}; - -class InspectableServer : public tls::Server -{ -public: - InspectableServer( - const std::shared_ptr<::tls::Cert>& cert, const std::string& groups) : - tls::Server(cert) - { - REQUIRE(SSL_set1_groups_list(get_ssl(), groups.c_str()) == 1); - } - - std::string negotiated_group() - { - return negotiated_group_name(get_ssl()); - } -}; - -#ifndef TEST_HYBRID_TLS_GROUPS -# define TEST_HYBRID_TLS_GROUPS 0 -#endif - -// Hybrid groups offered by src/tls/context.h, in the order they are offered -constexpr auto secp384r1_mlkem1024 = "SecP384r1MLKEM1024"; -constexpr auto secp256r1_mlkem768 = "SecP256r1MLKEM768"; -constexpr auto x25519_mlkem768 = "X25519MLKEM768"; - -// Classical groups offered by src/tls/context.h, in the same order -constexpr auto classical_groups = "P-521:P-384:P-256"; - -/// Handshakes a client offering client_groups against a server offering -/// server_groups, and returns the group they agreed on. Throws if they cannot -/// agree. -std::string negotiate_group( - const std::string& client_groups, const std::string& server_groups) -{ - INFO("client groups: ", client_groups); - INFO("server groups: ", server_groups); - - auto ca = get_ca(); - InspectableServer server(get_dummy_cert(ca, "server", false), server_groups); - InspectableClient client(get_dummy_cert(ca, "client", false), client_groups); - - TestPipe pipe; - server.set_bio(&pipe, send, recv); - client.set_bio(&pipe, send, recv); - - run_handshake(server, client); - - const auto group = client.negotiated_group(); - REQUIRE(group == server.negotiated_group()); - return group; -} - -TEST_CASE("connection inherits verification mode from context") -{ - auto ca = get_ca(); - - InspectableClient verified_client(get_dummy_cert(ca, "verified")); - REQUIRE( - (verified_client.verify_mode() & - (SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT)) == - (SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT)); - - InspectableClient request_only_client( - get_dummy_cert(ca, "request_only", false)); - // auth_required=false still requests a peer certificate, but does not fail - // the handshake if the peer certificate is missing. - REQUIRE((request_only_client.verify_mode() & SSL_VERIFY_PEER) != 0); - REQUIRE( - (request_only_client.verify_mode() & SSL_VERIFY_FAIL_IF_NO_PEER_CERT) == 0); -} - -TEST_CASE("group negotiation") -{ - SUBCASE("the first group offered by the client wins") - { - REQUIRE( - negotiate_group("P-384:P-256:P-521", "P-384:P-256:P-521") == "secp384r1"); - REQUIRE( - negotiate_group("P-256:P-384:P-521", "P-256:P-384:P-521") == "secp256r1"); - } - - SUBCASE("the client order wins over the server order") - { - // In TLS 1.3 the server selects the group, and OpenSSL selects the first - // group the client offered that the server also supports. The server order - // is only a filter, so the client dictates the outcome. - REQUIRE(negotiate_group("P-256:P-521", "P-521:P-256") == "secp256r1"); - REQUIRE(negotiate_group("P-521:P-256", "P-256:P-521") == "secp521r1"); - } - - SUBCASE("disjoint groups fail the handshake") - { - REQUIRE_THROWS_AS( - negotiate_group("P-521", "P-256"), const std::runtime_error&); - } -} - -TEST_CASE( - "hybrid group negotiation" * doctest::skip(TEST_HYBRID_TLS_GROUPS == 0)) -{ - const std::vector hybrid_groups = { - secp384r1_mlkem1024, secp256r1_mlkem768, x25519_mlkem768}; - - SUBCASE("the configured groups negotiate the strongest hybrid group") - { - auto ca = get_ca(); - tls::Server server(get_dummy_cert(ca, "server", false)); - InspectableClient client(get_dummy_cert(ca, "client", false)); - - TestPipe pipe; - server.set_bio(&pipe, send, recv); - client.set_bio(&pipe, send, recv); - - run_handshake(server, client); - - REQUIRE(client.negotiated_group() == secp384r1_mlkem1024); - } - - SUBCASE("disjoint hybrid groups with no classical fallback fail") - { - REQUIRE_THROWS_AS( - negotiate_group(secp384r1_mlkem1024, secp256r1_mlkem768), - const std::runtime_error&); - } - - SUBCASE("disjoint hybrid groups fall back to the first shared classical one") - { - REQUIRE( - negotiate_group( - fmt::format("{}:{}", secp384r1_mlkem1024, classical_groups), - fmt::format("{}:{}", secp256r1_mlkem768, classical_groups)) == - "secp521r1"); - } - - SUBCASE("disjoint hybrid and disjoint classical groups fail") - { - REQUIRE_THROWS_AS( - negotiate_group( - fmt::format("{}:P-521", secp384r1_mlkem1024), - fmt::format("{}:P-256", secp256r1_mlkem768)), - const std::runtime_error&); - } - - SUBCASE("the client order decides which shared hybrid group is used") - { - // As for classical groups, the client's order is what matters - REQUIRE( - negotiate_group( - fmt::format("{}:{}", secp384r1_mlkem1024, secp256r1_mlkem768), - fmt::format("{}:{}", secp256r1_mlkem768, secp384r1_mlkem1024)) == - secp384r1_mlkem1024); - REQUIRE( - negotiate_group( - fmt::format("{}:{}", secp256r1_mlkem768, secp384r1_mlkem1024), - fmt::format("{}:{}", secp384r1_mlkem1024, secp256r1_mlkem768)) == - secp256r1_mlkem768); - } - - SUBCASE("each hybrid group can be negotiated on its own") - { - for (const auto& group : hybrid_groups) - { - INFO("group: ", group); - REQUIRE(negotiate_group(group, group) == group); - } - } - - SUBCASE("a shared hybrid group is preferred over classical fallbacks") - { - for (const auto& group : hybrid_groups) - { - INFO("group: ", group); - const auto groups = fmt::format("{}:{}", group, classical_groups); - REQUIRE(negotiate_group(groups, groups) == group); - } - } -} - -TEST_CASE("unverified handshake") -{ - // Create a CA - auto ca = get_ca(); - - // Create bogus certificate - auto server_cert = get_dummy_cert(ca, "server", false); - auto client_cert = get_dummy_cert(ca, "client", false); - - LOG_INFO_FMT("TEST: unverified handshake"); - - // Just testing handshake, does not verify certificates, no communication. - run_test_case( - (const uint8_t*)"", - 0, - (const uint8_t*)"", - 0, - std::move(server_cert), - std::move(client_cert)); -} - -TEST_CASE("unverified communication") -{ - const uint8_t message[] = "Hello World!"; - size_t message_length = strlen((const char*)message); - const uint8_t response[] = "Hi back!"; - size_t response_length = strlen((const char*)response); - - // Create a CA - auto ca = get_ca(); - - // Create bogus certificate - auto server_cert = get_dummy_cert(ca, "server", false); - auto client_cert = get_dummy_cert(ca, "client", false); - - LOG_INFO_FMT("TEST: unverified communication"); - - // Just testing communication channel, does not verify certificates. - run_test_case( - message, - message_length, - response, - response_length, - std::move(server_cert), - std::move(client_cert)); -} - -TEST_CASE("verified handshake") -{ - // Create a CA - auto ca = get_ca(); - - // Create bogus certificate - auto server_cert = get_dummy_cert(ca, "server"); - auto client_cert = get_dummy_cert(ca, "client"); - - LOG_INFO_FMT("TEST: verified handshake"); - - // Just testing handshake, no communication, but verifies certificates. - run_test_case( - (const uint8_t*)"", - 0, - (const uint8_t*)"", - 0, - std::move(server_cert), - std::move(client_cert)); -} - -TEST_CASE("self-signed server certificate") -{ - auto kp = ccf::crypto::make_ec_key_pair(); - auto pk = kp->private_key_pem(); - auto crt = generate_self_signed_cert(kp, "CN=server"); - auto server_cert = make_unique(nullptr, crt, pk); - - // Create a CA - auto ca = get_ca(); - auto client_cert = get_dummy_cert(ca, "client"); - - // Client expected to complain about self-signedness. - REQUIRE_THROWS_WITH_AS( - run_test_case( - (const uint8_t*)"", - 0, - (const uint8_t*)"", - 0, - std::move(server_cert), - std::move(client_cert)), - "Client handshake error", - std::runtime_error); -} - -TEST_CASE("server certificate from different CA") -{ - auto server_ca = get_ca(); - auto server_cert = get_dummy_cert(server_ca, "server"); - - auto client_ca = get_ca(); - auto client_cert = get_dummy_cert(client_ca, "client"); - - // Client expected to complain - REQUIRE_THROWS_WITH_AS( - run_test_case( - (const uint8_t*)"", - 0, - (const uint8_t*)"", - 0, - std::move(server_cert), - std::move(client_cert)), - "Client handshake error", - std::runtime_error); -} - -TEST_CASE("self-signed client certificate") -{ - auto server_ca = get_ca(); - auto server_cert = get_dummy_cert(server_ca, "server", false); - - auto kp = ccf::crypto::make_ec_key_pair(); - auto pk = kp->private_key_pem(); - auto crt = generate_self_signed_cert(kp, "CN=server"); - - // With verification enabled, the client is expected to complain. - auto client_cert = make_unique(nullptr, crt, pk); - - REQUIRE_THROWS_WITH_AS( - run_test_case( - (const uint8_t*)"", - 0, - (const uint8_t*)"", - 0, - std::move(server_cert), - std::move(client_cert)), - "Client handshake error", - std::runtime_error); - - // Without verification enabled on the client, the server should complain. - server_cert = get_dummy_cert(server_ca, "server"); - client_cert = make_unique(nullptr, crt, pk, std::nullopt, false); - - REQUIRE_THROWS_WITH_AS( - run_test_case( - (const uint8_t*)"", - 0, - (const uint8_t*)"", - 0, - std::move(server_cert), - std::move(client_cert)), - "Server handshake error", - std::runtime_error); - - // Neither, neither. - server_cert = get_dummy_cert(server_ca, "server", false); - client_cert = make_unique(nullptr, crt, pk, std::nullopt, false); - REQUIRE_NOTHROW(run_test_case( - (const uint8_t*)"", - 0, - (const uint8_t*)"", - 0, - std::move(server_cert), - std::move(client_cert))); -} - -TEST_CASE("verified communication") -{ - const uint8_t message[] = "Hello World!"; - size_t message_length = strlen((const char*)message); - const uint8_t response[] = "Hi back!"; - size_t response_length = strlen((const char*)response); - - // Create a CA - auto ca = get_ca(); - - // Create bogus certificate - auto server_cert = get_dummy_cert(ca, "server"); - auto client_cert = get_dummy_cert(ca, "client"); - - LOG_INFO_FMT("TEST: verified communication"); - - // Testing communication channel, verifying certificates. - run_test_case( - message, - message_length, - response, - response_length, - std::move(server_cert), - std::move(client_cert)); -} - -TEST_CASE("large message") -{ - // Uninitialised on purpose, we don't care what's in here - size_t len = 8192; - std::vector buf(len); - auto message = ccf::crypto::b64_from_raw(buf.data(), len); - - // Create a CA - auto ca = get_ca(); - - // Create bogus certificate - auto server_cert = get_dummy_cert(ca, "server"); - auto client_cert = get_dummy_cert(ca, "client"); - - LOG_INFO_FMT("TEST: large message"); - - // Testing communication channel, verifying certificates. - run_test_case( - (const uint8_t*)message.data(), - message.size(), - (const uint8_t*)message.data(), - message.size(), - std::move(server_cert), - std::move(client_cert)); -} - -TEST_CASE("very large message") -{ - // Uninitialised on purpose, we don't care what's in here - size_t len = 16 * 1024; // 16k, base64 will be more - std::vector buf(len); - auto message = ccf::crypto::b64_from_raw(buf.data(), len); - - // Create a CA - auto ca = get_ca(); - - // Create bogus certificate - auto server_cert = get_dummy_cert(ca, "server"); - auto client_cert = get_dummy_cert(ca, "client"); - - LOG_INFO_FMT("TEST: very large message"); - - // Testing communication channel, verifying certificates. - run_test_case( - (const uint8_t*)message.data(), - message.size(), - (const uint8_t*)message.data(), - message.size(), - std::move(server_cert), - std::move(client_cert)); } diff --git a/src/tls/tls.h b/src/tls/tls.h deleted file mode 100644 index e293f793c848..000000000000 --- a/src/tls/tls.h +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the Apache 2.0 License. -#pragma once - -// These macros setup return values for when the connection is reading/writing -// and needs more data or has an error. -// -// In OpenSSL, the return is -1/0/1 and the error code depends on what -// SSL_want() returns. So we need to return some distinct negative number -// and then handle WANT_READ/WANT_WRITE and errors. -// -// Depending on the error, the connection needs to close with success, failure -// or auth-failure. -#define TLS_READING -SSL_READING -#define TLS_WRITING -SSL_WRITING -#define TLS_ERR_WANT_READ -SSL_ERROR_WANT_READ -#define TLS_ERR_WANT_WRITE -SSL_ERROR_WANT_WRITE -#define TLS_ERR_CONN_CLOSE_NOTIFY -SSL_ERROR_ZERO_RETURN -#define TLS_ERR_NEED_CERT -SSL_ERROR_WANT_X509_LOOKUP -// Specific error to check validity of certificate, not emitted by OpenSSL, but -// by Context. We set to a bogus negative value that won't match any OpenSSL -// error code. -// Once we refactor the code to match the OpenSSL style we may not need this. -#define TLS_ERR_X509_VERIFY INT_MIN - -#include "ccf/crypto/openssl/openssl_wrappers.h" - -#include - -namespace tls -{ - inline std::string error_string(int ec) - { - return ccf::crypto::OpenSSL::error_string(ec); - } -} From 77d8aa3e1ba85c587f11ca51dc04c4cceeac35dc Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Tue, 4 Aug 2026 15:48:42 +0000 Subject: [PATCH 37/59] More unification and cleanup --- CMakeLists.txt | 5 ++++- doc/architecture/tls_internals.rst | 15 +++++---------- src/clients/tls/README.md | 14 ++++++++++++++ src/{ => clients}/tls/ca.h | 0 src/{ => clients}/tls/cert.h | 2 +- src/{ => clients}/tls/test/main.cpp | 11 ++++++----- src/clients/tls_client.h | 4 ++-- src/host/test/openssl_server_test.cpp | 18 ++++++++++++------ src/host/tls/openssl_session_manager.h | 22 +++++++++------------- src/js/extensions/ccf/crypto.cpp | 13 +++++++++++-- src/node/node_state.h | 10 ++++------ src/tls/README.md | 15 --------------- 12 files changed, 68 insertions(+), 61 deletions(-) create mode 100644 src/clients/tls/README.md rename src/{ => clients}/tls/ca.h (100%) rename src/{ => clients}/tls/cert.h (99%) rename src/{ => clients}/tls/test/main.cpp (92%) delete mode 100644 src/tls/README.md diff --git a/CMakeLists.txt b/CMakeLists.txt index 0475aa70bba7..9bc9433ade40 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -871,7 +871,10 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/src/node/test/node_info_json.cpp ) - add_unit_test(tls_test ${CMAKE_CURRENT_SOURCE_DIR}/src/tls/test/main.cpp) + add_unit_test( + tls_test + ${CMAKE_CURRENT_SOURCE_DIR}/src/clients/tls/test/main.cpp + ) target_link_libraries(tls_test PRIVATE ${CMAKE_THREAD_LIBS_INIT}) add_unit_test( diff --git a/doc/architecture/tls_internals.rst b/doc/architecture/tls_internals.rst index 2b2b8a113379..a504c3bb7089 100644 --- a/doc/architecture/tls_internals.rst +++ b/doc/architecture/tls_internals.rst @@ -8,7 +8,7 @@ In CCF, the :term:`TLS` layer is implemented using OpenSSL (3.3 or later). TLS is terminated in the **connection layer**: OpenSSL owns the socket file descriptor directly, and a local ``epoll`` loop drives the non-blocking handshake, reads, writes, graceful close and idle connection cleanup. Everything above the connection layer, including HTTP parsing and endpoint dispatch, only ever sees plaintext. -This document describes the connection layer and the seam between it and the session layer, to facilitate further changes. +This document describes the connection layer and its interface to the session layer above it. Layers ~~~~~~ @@ -42,13 +42,6 @@ An interface starts listening before its certificate is necessarily known. A joi The server context sets a minimum version of TLS 1.2 and, when the interface is configured for HTTP/2, advertises ``h2`` via ALPN. -Two certificate helpers remain in ``src/tls``: - -- :ccf_repo:`CA ` holds a root certificate and can populate a trusted certificate store. -- :ccf_repo:`Cert ` holds an endpoint's own certificate and private key, and configures an ``SSL`` object with them. - -These are used for outbound connections and by node startup code, not by the inbound server path. - Cryptographic policy ~~~~~~~~~~~~~~~~~~~~ @@ -115,14 +108,16 @@ An interface configured as ``UNSECURED`` runs the same loop with no ``SSL`` obje Outbound connections ~~~~~~~~~~~~~~~~~~~~ -Outbound connections use a non-blocking ``connect`` followed by a client handshake driven by the same loop. The caller supplies a callback that configures the client ``SSL`` object, which is where peer CA verification, the client certificate and SNI are applied via ``tls::Cert``. +``OpenSSLServer`` is inbound only. Outbound requests - fetching quote endorsements, refreshing JWT signing keys, and the recovery decision protocol - are made with libcurl, which does its own TLS. + +The only remaining OpenSSL client helpers, :ccf_repo:`CA ` and :ccf_repo:`Cert `, are used solely by the C++ test and perf clients in :ccf_repo:`src/clients `. No node code uses them. Why OpenSSL? ~~~~~~~~~~~~ CCF originally used MbedTLS, and the OpenSSL implementation that replaced it emulated MbedTLS conventions, notably negated error codes and a memory-BIO indirection driven by callbacks. -Both are now gone. ``OpenSSLServer`` calls ``SSL_read``/``SSL_write`` on a socket-backed ``SSL`` and interprets ``SSL_get_error`` directly. All that remains in ``src/tls`` are the ``CA`` and ``Cert`` helpers described above. +Both are now gone. ``OpenSSLServer`` calls ``SSL_read``/``SSL_write`` on a socket-backed ``SSL`` and interprets ``SSL_get_error`` directly, and ``src/tls`` no longer exists. The reasons for OpenSSL remain: diff --git a/src/clients/tls/README.md b/src/clients/tls/README.md new file mode 100644 index 000000000000..763759b37a26 --- /dev/null +++ b/src/clients/tls/README.md @@ -0,0 +1,14 @@ +# TLS certificate helpers (client tooling only) + +These helpers configure an OpenSSL client with a trusted root and an owned +identity: + +- `CA` parses one or more root certificates and populates an `SSL_CTX`'s + trusted certificate store. +- `Cert` holds a client's own certificate and private key, and applies them + (and the peer verification mode) to an `SSL_CTX` or `SSL`. + +Their only consumer is `TlsClient` in the parent directory, used by the C++ +test and perf clients. **No node code uses them.** The node's inbound TLS is +handled by `src/host/tls`, which builds its own `SSL_CTX`, and its outbound +requests go through libcurl. diff --git a/src/tls/ca.h b/src/clients/tls/ca.h similarity index 100% rename from src/tls/ca.h rename to src/clients/tls/ca.h diff --git a/src/tls/cert.h b/src/clients/tls/cert.h similarity index 99% rename from src/tls/cert.h rename to src/clients/tls/cert.h index 5e7776e3e058..c8cd67fe9319 100644 --- a/src/tls/cert.h +++ b/src/clients/tls/cert.h @@ -3,9 +3,9 @@ #pragma once #include "ccf/crypto/openssl/openssl_wrappers.h" +#include "clients/tls/ca.h" #include "crypto/openssl/ec_key_pair.h" #include "ds/internal_logger.h" -#include "tls/ca.h" #include #include diff --git a/src/tls/test/main.cpp b/src/clients/tls/test/main.cpp similarity index 92% rename from src/tls/test/main.cpp rename to src/clients/tls/test/main.cpp index 8fefde1a75a4..b71c1db7363b 100644 --- a/src/tls/test/main.cpp +++ b/src/clients/tls/test/main.cpp @@ -1,16 +1,17 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the Apache 2.0 License. -// Unit tests for the certificate helpers in src/tls, which configure an -// OpenSSL context and connection with a trusted root and an owned identity. -// The TLS transport itself is tested in src/host/test/openssl_server_test.cpp. +// Unit tests for the certificate helpers used by the C++ test clients, which +// configure an OpenSSL context and connection with a trusted root and an owned +// identity. The TLS transport itself is tested in +// src/host/test/openssl_server_test.cpp. #include "ccf/crypto/ec_key_pair.h" #include "ccf/crypto/verifier.h" #include "ccf/ds/x509_time_fmt.h" +#include "clients/tls/ca.h" +#include "clients/tls/cert.h" #include "crypto/certs.h" -#include "tls/ca.h" -#include "tls/cert.h" #define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN #include diff --git a/src/clients/tls_client.h b/src/clients/tls_client.h index bdeee21ca057..2b574bee4fae 100644 --- a/src/clients/tls_client.h +++ b/src/clients/tls_client.h @@ -3,9 +3,9 @@ #pragma once #include "ccf/crypto/openssl/openssl_wrappers.h" +#include "clients/tls/ca.h" +#include "clients/tls/cert.h" #include "ds/internal_logger.h" -#include "tls/ca.h" -#include "tls/cert.h" #include #include diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp index 68d4c7855105..395703ee1b84 100644 --- a/src/host/test/openssl_server_test.cpp +++ b/src/host/test/openssl_server_test.cpp @@ -8,7 +8,6 @@ #include "crypto/certs.h" #include "host/tls/openssl_server.h" #include "host/tls/openssl_session_manager.h" -#include "tls/ca.h" #define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN #include @@ -260,8 +259,7 @@ namespace } // Handshakes with a client that verifies the server certificate against - // `trusted_ca`, the way ::tls::CA configures outbound CCF connections. - // Returns whether the handshake completed. + // `ca`. Returns whether the handshake completed. bool verifying_client_handshake(uint16_t port, const ccf::crypto::Pem& ca) { const int fd = ::socket(AF_INET, SOCK_STREAM, 0); @@ -276,7 +274,15 @@ namespace SSL_CTX* cctx = SSL_CTX_new(TLS_client_method()); REQUIRE(cctx != nullptr); - ::tls::CA(ca.str()).configure_trusted_cert_store(cctx); + { + const auto ca_pem = ca.str(); + BIO* cb = BIO_new_mem_buf(ca_pem.data(), static_cast(ca_pem.size())); + X509* root = PEM_read_bio_X509(cb, nullptr, nullptr, nullptr); + BIO_free(cb); + REQUIRE(root != nullptr); + REQUIRE(X509_STORE_add_cert(SSL_CTX_get_cert_store(cctx), root) == 1); + X509_free(root); + } SSL_CTX_set_verify(cctx, SSL_VERIFY_PEER, nullptr); SSL* ssl = SSL_new(cctx); @@ -330,7 +336,7 @@ namespace }; // A minimal ccf::Session that echoes received bytes back through its writer, - // exercising the real Session / SessionWriter seam over TLS. + // exercising the real Session / SessionWriter path over TLS. struct EchoSession : public ccf::Session { ::tcp::ConnID id; @@ -509,7 +515,7 @@ TEST_CASE("Session bridge: round-trip via ccf::Session + SessionWriter") mgr.stop(); } -TEST_CASE("Session bridge: large transfer through the seam") +TEST_CASE("Session bridge: large transfer via ccf::Session + SessionWriter") { auto [cert, key] = make_server_cert(); OpenSSLSessionManager mgr( diff --git a/src/host/tls/openssl_session_manager.h b/src/host/tls/openssl_session_manager.h index f3f4cea7ba6f..1b1606a99918 100644 --- a/src/host/tls/openssl_session_manager.h +++ b/src/host/tls/openssl_session_manager.h @@ -2,31 +2,26 @@ // Licensed under the Apache 2.0 License. #pragma once -// Bridges the OpenSSL-native transport (OpenSSLServer) to ccf::Session objects. +// Bridges the OpenSSL-native transport (OpenSSLServer) to ccf::Session objects: // -// This is the seam the real HTTP/HTTP2 sessions plug into once TLS lives in the -// connection layer: // * inbound plaintext from a connection -> ccf::Session::handle_incoming_data // * ccf::Session output (via ccf::SessionWriter) -> OpenSSLServer::send, -// which -// encrypts + writes with backpressure +// which encrypts and writes with backpressure // * connection teardown -> the owning session is dropped // // One ccf::Session is created per connection by a caller-supplied factory (e.g. // "make an HTTPServerSession for this interface"). Sessions are created lazily // on first inbound data and removed on close. // -// Threading: OpenSSLServer invokes on_data/on_close on its epoll thread; the +// Threading: OpenSSLServer invokes on_data/on_close on its loop thread. The // session may then process on OrderedTasks workers and reply via write_outbound -// from those threads. write_outbound/close_socket forward to OpenSSLServer's -// thread-safe send/close_connection, so this class is safe to call from any -// thread. The sessions map is guarded by a mutex. +// from those threads, which forwards to OpenSSLServer's thread-safe +// send/close_connection. Every public method is therefore safe to call from any +// thread, and the sessions map is guarded by a mutex. #include "ccf/node/session.h" #include "enclave/session_writer.h" #include "host/tls/openssl_server.h" -#include "tasks/basic_task.h" -#include "tasks/task_system.h" #include #include @@ -55,8 +50,9 @@ namespace asynchost private: std::unique_ptr server; SessionFactory factory; - // Invoked (on the loop thread) when a connection's session is dropped, so - // an owner can update per-interface counters/metrics. + // Invoked when a connection's session is dropped, so an owner can update + // per-interface counters/metrics. Called on the loop thread from on_close, + // or on a worker thread from close_socket, so it must be thread-safe. std::function on_session_closed; std::mutex sessions_mutex; diff --git a/src/js/extensions/ccf/crypto.cpp b/src/js/extensions/ccf/crypto.cpp index 02eeb642f1ca..f4ed73ced590 100644 --- a/src/js/extensions/ccf/crypto.cpp +++ b/src/js/extensions/ccf/crypto.cpp @@ -9,6 +9,7 @@ #include "ccf/crypto/entropy.h" #include "ccf/crypto/hmac.h" #include "ccf/crypto/key_wrap.h" +#include "ccf/crypto/openssl/openssl_wrappers.h" #include "ccf/crypto/rsa_key_pair.h" #include "ccf/crypto/sha256.h" #include "ccf/crypto/verifier.h" @@ -16,7 +17,6 @@ #include "ccf/js/core/context.h" #include "ds/internal_logger.h" #include "js/checks.h" -#include "tls/ca.h" #include @@ -310,7 +310,16 @@ namespace ccf::js::extensions try { - ::tls::CA ca(pem.value()); + ccf::crypto::OpenSSL::Unique_BIO bio( + pem.value().data(), pem.value().size()); + ccf::crypto::OpenSSL::Unique_X509 cert(bio, true); + if (cert == nullptr) + { + LOG_DEBUG_FMT( + "isValidX509Bundle: {}", + ccf::crypto::OpenSSL::error_string(ERR_get_error())); + return ccf::js::core::constants::False; + } } catch (const std::runtime_error& e) { diff --git a/src/node/node_state.h b/src/node/node_state.h index a26d6ac68d86..7cea470aba1d 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -1162,12 +1162,10 @@ namespace ccf // The service certificate is the sole trust anchor for the join // connection. CURLOPT_CAINFO_BLOB installs it and CURLOPT_CAPATH=nullptr - // prevents any fallback to the system CA store, so the set of accepted - // certificate authorities is identical to the legacy tls::CA path. The - // joining node presents its self-signed node certificate for mutual TLS - // (it is not yet endorsed at join time). CURLOPT_SSL_VERIFYHOST=2 - // additionally checks that the target certificate matches the address we - // connected to. + // prevents any fallback to the system CA store. The joining node presents + // its self-signed node certificate for mutual TLS (it is not yet endorsed + // at join time). CURLOPT_SSL_VERIFYHOST=2 additionally checks that the + // target certificate matches the address we connected to. ccf::curl::UniqueCURL curl_handle; curl_handle.set_opt(CURLOPT_SSL_VERIFYPEER, 1L); curl_handle.set_opt(CURLOPT_SSL_VERIFYHOST, 2L); diff --git a/src/tls/README.md b/src/tls/README.md deleted file mode 100644 index 7f19ca9de495..000000000000 --- a/src/tls/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# TLS certificate helpers - -This directory holds the two certificate helpers used to configure OpenSSL -objects with a trusted root and an owned identity: - -- `CA` parses one or more root certificates and populates an `SSL_CTX`'s - trusted certificate store. -- `Cert` holds an endpoint's own certificate and private key, and applies them - (and the peer verification mode) to an `SSL_CTX` or `SSL`. - -They are used by outbound connections, by the JS `isValidX509CertChain` API, -and by the test clients in `src/clients`. - -The TLS transport itself lives in `src/host/tls`. See -`doc/architecture/tls_internals.rst` for how it works. From 3bd9f7e5c920813315edb68d3283a97a7310761a Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Wed, 5 Aug 2026 10:37:11 +0000 Subject: [PATCH 38/59] Use uv_poll_t rather than a custom epoll loop --- CMakeLists.txt | 2 +- doc/architecture/tls_internals.rst | 16 +- src/host/datagram_server.h | 277 +++++++++------- src/host/test/openssl_server_test.cpp | 102 +++++- src/host/tls/openssl_server.h | 456 +++++++++++++++++--------- 5 files changed, 561 insertions(+), 292 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9bc9433ade40..7f2da13e7c38 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -718,7 +718,7 @@ if(BUILD_TESTS) openssl_server_test ${CMAKE_CURRENT_SOURCE_DIR}/src/host/test/openssl_server_test.cpp ) - target_link_libraries(openssl_server_test PRIVATE ccf_tasks) + target_link_libraries(openssl_server_test PRIVATE ccf_tasks uv) target_compile_definitions( openssl_server_test PRIVATE TEST_HYBRID_TLS_GROUPS=$ diff --git a/doc/architecture/tls_internals.rst b/doc/architecture/tls_internals.rst index a504c3bb7089..4b01b4550798 100644 --- a/doc/architecture/tls_internals.rst +++ b/doc/architecture/tls_internals.rst @@ -6,7 +6,7 @@ Overview In CCF, the :term:`TLS` layer is implemented using OpenSSL (3.3 or later). -TLS is terminated in the **connection layer**: OpenSSL owns the socket file descriptor directly, and a local ``epoll`` loop drives the non-blocking handshake, reads, writes, graceful close and idle connection cleanup. Everything above the connection layer, including HTTP parsing and endpoint dispatch, only ever sees plaintext. +TLS is terminated in the **connection layer**: OpenSSL owns the socket file descriptor directly, while the existing host ``libuv`` loop drives the non-blocking handshake, reads, writes, graceful close and idle connection cleanup. Everything above the connection layer, including HTTP parsing and endpoint dispatch, only ever sees plaintext. This document describes the connection layer and its interface to the session layer above it. @@ -15,7 +15,7 @@ Layers A single RPC interface is served by these pieces: -- :ccf_repo:`OpenSSLServer ` owns the listening socket, one ``epoll`` instance, and one loop thread. Each accepted connection holds an ``SSL`` object bound to its file descriptor with ``SSL_set_fd``. It emits decrypted bytes through an ``OnData`` callback and reports teardown through ``OnClose``. +- :ccf_repo:`OpenSSLServer ` owns the listening and accepted sockets and registers ``uv_poll_t`` handles for them on the existing host loop. Each accepted connection holds an ``SSL`` object bound to its file descriptor with ``SSL_set_fd``. It emits decrypted bytes through an ``OnData`` callback and reports teardown through ``OnClose``. - :ccf_repo:`OpenSSLSessionManager ` bridges the transport to the session layer. It lazily creates one ``ccf::Session`` per connection using a caller-supplied factory, and implements :ccf_repo:`ccf::SessionWriter ` so that a session's outbound plaintext is handed back to the transport. - :ccf_repo:`ccf::PlaintextSession ` is the base for the protocol sessions (:ccf_repo:`HTTPServerSession `, :ccf_repo:`HTTP2ServerSession `). It receives plaintext, and emits plaintext through its ``SessionWriter``. - :ccf_repo:`RPCConnectionManager ` owns one of these stacks per configured RPC interface, and holds the cross-interface policy: certificates, session caps, and metrics. @@ -67,18 +67,18 @@ The server calls ``SSL_CTX_set_verify`` with ``SSL_VERIFY_PEER`` and an accept-a Reading ~~~~~~~ -When ``epoll`` reports a connection readable, the loop calls ``SSL_read`` repeatedly until it reports ``SSL_ERROR_WANT_READ``. Every chunk of decrypted bytes is passed to the ``OnData`` callback as it is produced, so a single readable event may yield several callbacks. +When ``libuv`` reports a connection readable, the loop calls ``SSL_read`` repeatedly until it reports ``SSL_ERROR_WANT_READ``. Every chunk of decrypted bytes is passed to the ``OnData`` callback as it is produced, so a single readable event may yield several callbacks. ``OpenSSLSessionManager`` receives those bytes, finds or creates the session for that connection, and calls ``handle_incoming_data``. The session dispatches the actual parsing to a worker via ``OrderedTasks``, so the loop thread does not block on application work. -``SSL_ERROR_WANT_WRITE`` on a read is not an error: a TLS 1.3 key update needs the socket to become writable, so the connection is left open with ``EPOLLOUT`` armed. Any other result, whether a clean ``SSL_ERROR_ZERO_RETURN``, an unclean EOF, or a fatal error, closes the connection. +``SSL_ERROR_WANT_WRITE`` on a read is not an error: a TLS 1.3 key update needs the socket to become writable, so the connection is left open with ``UV_WRITABLE`` armed. Any other result, whether a clean ``SSL_ERROR_ZERO_RETURN``, an unclean EOF, or a fatal error, closes the connection. Writing and backpressure ~~~~~~~~~~~~~~~~~~~~~~~~ ``send()`` is thread-safe. It appends the plaintext to a queue guarded by a mutex and signals an ``eventfd``, waking the loop thread, which drains the queue and attempts the write. -Writes are where genuine backpressure appears. ``SSL_write`` on a non-blocking socket may report ``SSL_ERROR_WANT_WRITE`` after consuming only part of the buffer. The remainder stays buffered against the connection, ``EPOLLOUT`` is armed, and the write resumes when the socket next becomes writable. Because the socket is owned by OpenSSL and is non-blocking, this reflects the real state of the :term:`TCP` send buffer rather than an internal approximation. +Writes are where genuine backpressure appears. ``SSL_write`` on a non-blocking socket may report ``SSL_ERROR_WANT_WRITE`` after consuming only part of the buffer. The remainder stays buffered against the connection, ``UV_WRITABLE`` is armed, and the write resumes when the socket next becomes writable. Because the socket is owned by OpenSSL and is non-blocking, this reflects the real state of the :term:`TCP` send buffer rather than an internal approximation. Closing ~~~~~~~ @@ -87,12 +87,12 @@ Closing is deferred rather than immediate. A close requested while output is sti This matters because the common pattern is to write a response and immediately close. Closing eagerly truncates any response large enough to have been backpressured, which the client observes as a connection reset partway through the body rather than as a well-formed response. -Idle connections are closed separately. If an idle timeout is configured, the loop uses a finite ``epoll_wait`` timeout and periodically sweeps connections whose last I/O is older than the timeout. +Idle connections are closed separately. If an idle timeout is configured, a repeating ``uv_timer_t`` periodically sweeps connections whose last I/O is older than the timeout. This retains the once-per-second scheduling used by the previous RPC transport while comparing actual ``steady_clock`` timestamps rather than counting timer ticks. Threading ~~~~~~~~~ -Each ``OpenSSLServer`` runs exactly one loop thread, and all socket and ``SSL`` operations happen on it. Work reaches that thread in one of two ways: file descriptor readiness reported by ``epoll``, or a cross-thread request (a queued write, a close, or a certificate update) posted to a queue and signalled through an ``eventfd``. +All socket and ``SSL`` operations happen on the existing host ``libuv`` thread; an ``OpenSSLServer`` does not create a thread or private reactor. Work reaches the loop in one of two ways: file descriptor readiness reported through ``uv_poll_t``, or a cross-thread request (a queued write, a close, or a certificate update) posted to a queue and signalled through ``uv_async_t``. .. warning:: @@ -130,6 +130,6 @@ Future: QUIC QUIC is not yet implemented. Server-side QUIC requires OpenSSL 3.5 or later, which adds ``SSL_new_listener``, ``SSL_accept_connection`` and ``OSSL_QUIC_server_method``; these are absent from the 3.3.x baseline CCF currently supports. -:ccf_repo:`DatagramServer ` exists as the substrate for that work. It is deliberately shaped as the UDP socket a QUIC server operates on: socket creation, binding, the ``epoll`` loop and the per-datagram dispatch are all reusable as-is. The points that change for QUIC are marked ``QUIC EXTENSION POINT`` inline, and consist of wrapping the socket with ``BIO_new_dgram``/``SSL_set_fd`` on a listener ``SSL``, and replacing the datagram callback with ``SSL_handle_events``. +:ccf_repo:`DatagramServer ` exists as the substrate for that work. It is deliberately shaped as the UDP socket a QUIC server operates on: socket creation, binding, ``uv_poll_t`` readiness and per-datagram dispatch are all reusable as-is. The points that change for QUIC are marked ``QUIC EXTENSION POINT`` inline, and consist of wrapping the socket with ``BIO_new_dgram``/``SSL_set_fd`` on a listener ``SSL``, adding the OpenSSL event timeout, and replacing the datagram callback with ``SSL_handle_events``. Until then, a UDP interface uses a built-in datagram echo session. diff --git a/src/host/datagram_server.h b/src/host/datagram_server.h index c244541956f7..b5cf77bd8fb8 100644 --- a/src/host/datagram_server.h +++ b/src/host/datagram_server.h @@ -2,9 +2,9 @@ // Licensed under the Apache 2.0 License. #pragma once -// A minimal UDP datagram server: it owns a SOCK_DGRAM socket in its own epoll -// loop and delivers each received datagram to a handler. It backs UDP -// interfaces, leaving protocol behaviour to its handler. +// A minimal UDP datagram server: it owns a SOCK_DGRAM socket polled by the +// host libuv loop and delivers each received datagram to a handler. It backs +// UDP interfaces, leaving protocol behaviour to its handler. // // =========================================================================== // QUIC EXTENSION POINT @@ -12,50 +12,40 @@ // This is deliberately the substrate a future OpenSSL-native QUIC server would // build on. The UDP socket created and bound here is exactly the datagram // socket OpenSSL QUIC operates on. The pieces that change for QUIC are marked -// "QUIC EXTENSION POINT" inline; the socket creation, binding, epoll loop and -// lifecycle below are unchanged by that switch. +// "QUIC EXTENSION POINT" inline; socket creation, binding and lifecycle remain. // // To become a QUIC server (needs OpenSSL >= 3.5, which adds SSL_new_listener / -// SSL_accept_connection / OSSL_QUIC_server_method - absent in the 3.3.x we -// build against today): -// * wrap `sock` with BIO_new_dgram()/SSL_set_fd() on a QUIC listener SSL -// (OSSL_QUIC_server_method + SSL_new_listener); -// * epoll the descriptor returned by SSL_get_rpoll_descriptor() (it is this -// same UDP fd) plus an SSL_get_event_timeout() timer, instead of `sock` -// directly; +// SSL_accept_connection / OSSL_QUIC_server_method): +// * wrap `sock` with BIO_new_dgram()/SSL_set_fd() on a QUIC listener SSL; +// * poll the descriptor returned by SSL_get_rpoll_descriptor() and use an +// SSL_get_event_timeout() timer; // * on readability/timeout call SSL_handle_events(), then // SSL_accept_connection()/SSL_accept_stream()/SSL_read_ex(), and reply with -// SSL_write_ex() on a stream rather than the raw sendto() below. -// The event-driven integration primitives (SSL_handle_events, -// SSL_get_rpoll_descriptor, SSL_get_event_timeout, BIO_new_dgram, -// SSL_set1_initial_peer_addr) already exist in 3.3.x - only the server-side -// listener/accept is missing. +// SSL_write_ex(). // =========================================================================== #include -#include #include +#include #include #include #include #include +#include #include #include #include #include -#include -#include #include #include #include -#include +#include namespace asynchost { class DatagramServer { public: - // Invoked on the loop thread for each received datagram. using OnDatagram = std::function; private: - // Max UDP payload (theoretical IPv4 limit); datagrams are read whole. static constexpr size_t max_datagram = 65535; + uv_loop_t* loop = nullptr; int sock = -1; - int epoll_fd = -1; - int stop_fd = -1; + uv_poll_t socket_poll{}; + uv_async_t stop_handle{}; uint16_t bound_port = 0; OnDatagram on_datagram; - std::thread loop_thread; - std::atomic running{false}; + std::mutex lifecycle_mutex; + std::condition_variable stopped_cv; + std::thread::id loop_thread_id; + bool started = false; + bool stopping = false; + bool shutdown_started = false; + bool stopped = false; + size_t pending_uv_closes = 0; + std::thread::id initialising_thread_id; + bool loop_thread_seen = false; static bool set_nonblocking(int fd) { @@ -85,6 +83,13 @@ namespace asynchost return fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0; } + void mark_loop_thread() + { + std::lock_guard guard(lifecycle_mutex); + loop_thread_id = std::this_thread::get_id(); + loop_thread_seen = true; + } + void drain() { for (;;) @@ -115,54 +120,80 @@ namespace asynchost if (on_datagram) { // === QUIC EXTENSION POINT === - // A QUIC server would feed the received bytes to OpenSSL - // (SSL_handle_events). `peer` is the source address that - // SSL_set1_initial_peer_addr() consumes. + // A QUIC server would feed these bytes to SSL_handle_events(). on_datagram(buf, static_cast(n), peer, peerlen); } } } - void run() + static void on_socket_poll(uv_poll_t* handle, int status, int events) { - constexpr int max_events = 8; - std::vector events(max_events); - while (running.load()) + auto* self = static_cast(handle->data); + self->mark_loop_thread(); + if (status < 0) { - const int n = epoll_wait(epoll_fd, events.data(), max_events, -1); - if (n < 0) - { - if (errno == EINTR) - { - continue; - } - break; - } - for (int i = 0; i < n; ++i) + self->stop_on_loop(); + return; + } + if ((events & UV_READABLE) != 0) + { + // === QUIC EXTENSION POINT === + // For QUIC this becomes SSL_handle_events() on the listener. + self->drain(); + } + } + + static void on_stop(uv_async_t* handle) + { + auto* self = static_cast(handle->data); + self->mark_loop_thread(); + self->stop_on_loop(); + } + + static void on_handle_closed(uv_handle_t* handle) + { + auto* self = static_cast(handle->data); + std::lock_guard guard(self->lifecycle_mutex); + --self->pending_uv_closes; + if (self->pending_uv_closes == 0) + { + self->stopped = true; + self->stopped_cv.notify_all(); + } + } + + void stop_on_loop() + { + { + std::lock_guard guard(lifecycle_mutex); + if (shutdown_started) { - const int fd = events[i].data.fd; - if (fd == stop_fd) - { - running.store(false); - break; - } - if (fd == sock) - { - // === QUIC EXTENSION POINT === - // For QUIC this becomes SSL_handle_events() on the listener. - drain(); - } + return; } + stopping = true; + shutdown_started = true; + pending_uv_closes = 2; + } + + (void)uv_poll_stop(&socket_poll); + if (sock >= 0) + { + ::close(sock); + sock = -1; } + uv_close(reinterpret_cast(&socket_poll), on_handle_closed); + uv_close(reinterpret_cast(&stop_handle), on_handle_closed); } public: DatagramServer( - const std::string& host, uint16_t port, OnDatagram on_datagram_) : + const std::string& host, + uint16_t port, + OnDatagram on_datagram_, + uv_loop_t* loop_ = uv_default_loop()) : + loop(loop_), on_datagram(std::move(on_datagram_)) { - // Resolve + bind the datagram address (getaddrinfo supports hostnames and - // IPv6, matching the TCP listener). addrinfo hints{}; hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_DGRAM; @@ -209,42 +240,17 @@ namespace asynchost throw std::runtime_error("set_nonblocking (udp) failed"); } - // Read back the actual bound port (supports ephemeral port 0, v4 and v6). - sockaddr_storage b{}; - socklen_t blen = sizeof(b); - if (getsockname(sock, reinterpret_cast(&b), &blen) == 0) - { - bound_port = (b.ss_family == AF_INET6) ? - ntohs(reinterpret_cast(&b)->sin6_port) : - ntohs(reinterpret_cast(&b)->sin_port); - } - - epoll_fd = epoll_create1(0); - if (epoll_fd < 0) - { - cleanup(); - throw std::runtime_error("epoll_create1 (udp) failed"); - } - stop_fd = eventfd(0, EFD_NONBLOCK); - if (stop_fd < 0) - { - cleanup(); - throw std::runtime_error("eventfd (udp) failed"); - } - - epoll_event ev{}; - ev.events = EPOLLIN; - ev.data.fd = sock; - if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sock, &ev) != 0) - { - cleanup(); - throw std::runtime_error("epoll_ctl(sock udp) failed"); - } - ev.data.fd = stop_fd; - if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, stop_fd, &ev) != 0) + sockaddr_storage bound_address{}; + socklen_t bound_address_len = sizeof(bound_address); + if ( + getsockname( + sock, + reinterpret_cast(&bound_address), + &bound_address_len) == 0) { - cleanup(); - throw std::runtime_error("epoll_ctl(stop udp) failed"); + bound_port = (bound_address.ss_family == AF_INET6) ? + ntohs(reinterpret_cast(&bound_address)->sin6_port) : + ntohs(reinterpret_cast(&bound_address)->sin_port); } } @@ -261,29 +267,76 @@ namespace asynchost void start() { - running.store(true); - loop_thread = std::thread([this]() { run(); }); + std::lock_guard guard(lifecycle_mutex); + if (started) + { + return; + } + int rc = uv_poll_init_socket(loop, &socket_poll, sock); + if (rc != 0) + { + throw std::runtime_error( + std::string("uv_poll_init_socket(udp) failed: ") + uv_strerror(rc)); + } + socket_poll.data = this; + rc = uv_async_init(loop, &stop_handle, on_stop); + if (rc != 0) + { + throw std::runtime_error( + std::string("uv_async_init(udp) failed: ") + uv_strerror(rc)); + } + stop_handle.data = this; + rc = uv_poll_start(&socket_poll, UV_READABLE, on_socket_poll); + if (rc != 0) + { + throw std::runtime_error( + std::string("uv_poll_start(udp) failed: ") + uv_strerror(rc)); + } + started = true; + stopping = false; + shutdown_started = false; + stopped = false; + initialising_thread_id = std::this_thread::get_id(); + loop_thread_seen = false; } void stop() { - if (!running.exchange(false)) + std::unique_lock lock(lifecycle_mutex); + if (!started || stopped) { - if (loop_thread.joinable()) - { - loop_thread.join(); - } return; } - if (stop_fd >= 0) + if (!stopping) + { + stopping = true; + (void)uv_async_send(&stop_handle); + } + const bool loop_not_started_here = !loop_thread_seen && + std::this_thread::get_id() == initialising_thread_id; + if (loop_not_started_here) { - const uint64_t one = 1; - [[maybe_unused]] auto w = ::write(stop_fd, &one, sizeof(one)); + lock.unlock(); + stop_on_loop(); + for (;;) + { + { + std::lock_guard guard(lifecycle_mutex); + if (stopped) + { + return; + } + } + (void)uv_run(loop, UV_RUN_NOWAIT); + } } - if (loop_thread.joinable()) + if (std::this_thread::get_id() == loop_thread_id) { - loop_thread.join(); + lock.unlock(); + stop_on_loop(); + return; } + stopped_cv.wait(lock, [this]() { return stopped; }); } [[nodiscard]] uint16_t port() const @@ -305,16 +358,6 @@ namespace asynchost private: void cleanup() { - if (stop_fd >= 0) - { - ::close(stop_fd); - stop_fd = -1; - } - if (epoll_fd >= 0) - { - ::close(epoll_fd); - epoll_fd = -1; - } if (sock >= 0) { ::close(sock); @@ -322,4 +365,4 @@ namespace asynchost } } }; -} +} \ No newline at end of file diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp index 395703ee1b84..fe4cd3d29499 100644 --- a/src/host/test/openssl_server_test.cpp +++ b/src/host/test/openssl_server_test.cpp @@ -6,6 +6,7 @@ #include "ccf/crypto/ec_key_pair.h" #include "ccf/ds/x509_time_fmt.h" #include "crypto/certs.h" +#include "host/datagram_server.h" #include "host/tls/openssl_server.h" #include "host/tls/openssl_session_manager.h" @@ -304,8 +305,27 @@ namespace } // Echoes received plaintext back to the same connection via send(). + struct UVLoopRunner + { + std::thread thread; + + void start() + { + thread = std::thread([]() { uv_run(uv_default_loop(), UV_RUN_DEFAULT); }); + } + + ~UVLoopRunner() + { + if (thread.joinable()) + { + thread.join(); + } + } + }; + struct EchoServer { + UVLoopRunner loop; std::unique_ptr server; EchoServer( @@ -322,6 +342,7 @@ namespace server->send(id, d.data(), d.size()); }); server->start(); + loop.start(); } ~EchoServer() @@ -386,6 +407,24 @@ namespace }; } +TEST_CASE("Transports stop cleanly before the libuv loop starts") +{ + auto [cert, key] = make_server_cert(); + OpenSSLServer tcp_server( + cert, key, "127.0.0.1", 0, [](::tcp::ConnID, std::vector) {}); + tcp_server.start(); + tcp_server.stop(); + + DatagramServer udp_server( + "127.0.0.1", + 0, + [](const uint8_t*, size_t, const sockaddr_storage&, socklen_t) {}); + udp_server.start(); + udp_server.stop(); + + REQUIRE(uv_loop_alive(uv_default_loop()) == 0); +} + TEST_CASE("TLS handshake and small round-trip") { auto [cert, key] = make_server_cert(); @@ -402,7 +441,7 @@ TEST_CASE("Large transfer exercises the backpressure path") EchoServer s(cert, key); // 4 MiB forces the socket send buffer to fill, so SSL_write returns - // WANT_WRITE and the server must buffer + re-arm EPOLLOUT. + // WANT_WRITE and the server must buffer + re-arm UV_WRITABLE. const auto payload = random_bytes(4 * 1024 * 1024); const auto resp = tls_client_exchange(s.port(), payload, payload.size()); @@ -439,12 +478,13 @@ TEST_CASE("Concurrent connections") REQUIRE(ok.load() == num_clients); } -// Models the production dispatch path: the epoll thread hands the request to a +// Models the production dispatch path: the libuv thread hands the request to a // worker thread, which replies via send() - exercising cross-thread send + -// eventfd loop wakeup. +// uv_async_t loop wakeup. TEST_CASE("Reply from a worker thread") { auto [cert, key] = make_server_cert(); + UVLoopRunner loop; OpenSSLServer* sp = nullptr; std::mutex m; @@ -482,6 +522,7 @@ TEST_CASE("Reply from a worker thread") }); sp = &server; server.start(); + loop.start(); const std::vector msg = {'w', 'o', 'r', 'k', 'e', 'r'}; REQUIRE(tls_client_exchange(server.port(), msg, msg.size()) == msg); @@ -495,9 +536,55 @@ TEST_CASE("Reply from a worker thread") worker.join(); } +TEST_CASE("Datagram server round-trip on the libuv reactor") +{ + UVLoopRunner loop; + DatagramServer* server_ptr = nullptr; + DatagramServer server( + "127.0.0.1", + 0, + [&]( + const uint8_t* data, + size_t len, + const sockaddr_storage& peer, + socklen_t peerlen) { + REQUIRE(server_ptr->send_to(peer, peerlen, data, len)); + }); + server_ptr = &server; + server.start(); + loop.start(); + + const int fd = ::socket(AF_INET, SOCK_DGRAM, 0); + REQUIRE(fd >= 0); + timeval timeout{1, 0}; + REQUIRE( + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) == 0); + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_port = htons(server.port()); + REQUIRE(inet_pton(AF_INET, "127.0.0.1", &address.sin_addr) == 1); + + const std::vector message = {'u', 'd', 'p'}; + REQUIRE( + ::sendto( + fd, + message.data(), + message.size(), + 0, + reinterpret_cast(&address), + sizeof(address)) == static_cast(message.size())); + std::vector response(message.size()); + REQUIRE(::recv(fd, response.data(), response.size(), 0) == 3); + REQUIRE(response == message); + ::close(fd); + + server.stop(); +} + TEST_CASE("Session bridge: round-trip via ccf::Session + SessionWriter") { auto [cert, key] = make_server_cert(); + UVLoopRunner loop; OpenSSLSessionManager mgr( cert, key, @@ -507,6 +594,7 @@ TEST_CASE("Session bridge: round-trip via ccf::Session + SessionWriter") return std::make_shared(id, w); }); mgr.start(); + loop.start(); REQUIRE(mgr.port() != 0); const std::vector msg = {'b', 'r', 'i', 'd', 'g', 'e'}; @@ -518,6 +606,7 @@ TEST_CASE("Session bridge: round-trip via ccf::Session + SessionWriter") TEST_CASE("Session bridge: large transfer via ccf::Session + SessionWriter") { auto [cert, key] = make_server_cert(); + UVLoopRunner loop; OpenSSLSessionManager mgr( cert, key, @@ -527,6 +616,7 @@ TEST_CASE("Session bridge: large transfer via ccf::Session + SessionWriter") return std::make_shared(id, w); }); mgr.start(); + loop.start(); const auto payload = random_bytes(2 * 1024 * 1024); const auto resp = tls_client_exchange(mgr.port(), payload, payload.size()); @@ -542,6 +632,7 @@ TEST_CASE("Peer certificate is captured for inbound connections") { auto [cert, key] = make_server_cert(); auto [client_cert, client_key] = make_server_cert(); + UVLoopRunner loop; std::mutex m; std::vector captured; @@ -561,6 +652,7 @@ TEST_CASE("Peer certificate is captured for inbound connections") return std::make_shared(id, w); }); mgr.start(); + loop.start(); const std::vector msg = {'m', 't', 'l', 's'}; REQUIRE( @@ -580,6 +672,7 @@ TEST_CASE("Peer certificate is captured for inbound connections") TEST_CASE("Client certificate is requested but not enforced") { auto [cert, key] = make_server_cert(); + UVLoopRunner loop; std::mutex m; std::vector captured; @@ -599,6 +692,7 @@ TEST_CASE("Client certificate is requested but not enforced") return std::make_shared(id, w); }); mgr.start(); + loop.start(); const std::vector msg = {'n', 'o', 'c', 'e', 'r', 't'}; REQUIRE(tls_client_exchange(mgr.port(), msg, msg.size()) == msg); @@ -738,6 +832,7 @@ TEST_CASE("Graceful close flushes buffered response without truncation") { auto [cert, key] = make_server_cert(); const auto payload = random_bytes(4 * 1024 * 1024); + UVLoopRunner loop; OpenSSLSessionManager mgr( cert, @@ -748,6 +843,7 @@ TEST_CASE("Graceful close flushes buffered response without truncation") return std::make_shared(id, w, payload); }); mgr.start(); + loop.start(); const std::vector req = {'g', 'o'}; const auto resp = tls_client_exchange(mgr.port(), req, payload.size()); diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index 1cd9d3cfdb65..5b79324d287b 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -3,7 +3,7 @@ #pragma once // OpenSSL-native TLS/plaintext TCP server for RPC interfaces. OpenSSL owns the -// socket fd directly, while a local epoll loop drives non-blocking handshake, +// socket fd directly, while the host libuv loop drives non-blocking handshake, // reads, writes, graceful close, and idle connection cleanup. #include "tcp/msg_types.h" @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -29,14 +30,13 @@ #include #include #include -#include -#include #include #include #include #include #include #include +#include #include namespace asynchost @@ -44,14 +44,14 @@ namespace asynchost class OpenSSLServer { public: - // Invoked on the epoll thread with a complete chunk of decrypted bytes for + // Invoked on the libuv thread with a complete chunk of decrypted bytes for // connection `conn_id`. The handler typically hands processing to a worker // (e.g. OrderedTasks) and later calls send()/close_connection() from that // thread - both are thread-safe and wake the loop. using OnData = std::function data)>; - // Invoked on the epoll thread when a connection is torn down (peer + // Invoked on the libuv thread when a connection is torn down (peer // disconnect, error, or close_connection()). Lets an owner drop per- // connection state. using OnClose = std::function; @@ -61,7 +61,9 @@ namespace asynchost struct Conn { + OpenSSLServer* owner = nullptr; int fd = -1; + uv_poll_t poll{}; SSL* ssl = nullptr; ::tcp::ConnID id = 0; enum State : uint8_t @@ -90,16 +92,21 @@ namespace asynchost // ALPN protocol advertised by the server (wire format, length-prefixed), // e.g. "\x02h2" or "\x08http/1.1". Empty disables ALPN. std::string alpn_wire; + uv_loop_t* loop = nullptr; int listen_fd = -1; - int epoll_fd = -1; - int stop_fd = -1; - int wake_fd = -1; + uv_poll_t listen_poll{}; + uv_async_t wake_handle{}; + uv_timer_t idle_timer{}; + bool listen_poll_initialised = false; + bool wake_handle_initialised = false; + bool idle_timer_initialised = false; uint16_t bound_port = 0; OnData on_data; OnClose on_close; bool verbose = false; std::unordered_map> conns; + std::unordered_map> closing_conns; std::unordered_map<::tcp::ConnID, int> id_to_fd; ::tcp::ConnID next_id = 1; // Optional shared id source so multiple servers (one per interface) @@ -108,14 +115,14 @@ namespace asynchost std::atomic<::tcp::ConnID>* shared_next_id = nullptr; // Close a connection after this much inactivity (no I/O); nullopt disables - // idle closure. The loop wakes every idle_sweep_interval_ms to check. + // idle closure. A libuv timer wakes every idle_sweep_interval_ms to check. static constexpr int idle_sweep_interval_ms = 1000; std::optional idle_timeout; std::chrono::steady_clock::time_point last_idle_sweep = std::chrono::steady_clock::now(); // Cross-thread outbound queue: send()/close_connection() append here from - // any thread and wake the loop, which drains it on the epoll thread. + // any thread and wake the loop, which drains it on the libuv thread. struct OutItem { ::tcp::ConnID id = 0; @@ -129,8 +136,16 @@ namespace asynchost // applied on the loop thread so `ctx` is only ever touched there. std::vector> pending_certs; - std::thread loop_thread; - std::atomic running{false}; + std::mutex lifecycle_mutex; + std::condition_variable stopped_cv; + bool started = false; + bool stopping = false; + bool shutdown_started = false; + bool stopped = false; + size_t pending_uv_closes = 0; + std::thread::id initialising_thread_id; + std::thread::id loop_thread_id; + bool loop_thread_seen = false; void logf(const char* fmt, ...) const { @@ -287,21 +302,17 @@ namespace asynchost void update_interest(Conn& c) const { - epoll_event ev{}; - ev.data.fd = c.fd; - ev.events = EPOLLIN | (c.want_write ? EPOLLOUT : 0); - if (epoll_ctl(epoll_fd, EPOLL_CTL_MOD, c.fd, &ev) != 0) + const int events = UV_READABLE | (c.want_write ? UV_WRITABLE : 0); + const int rc = uv_poll_start(&c.poll, events, on_connection_poll); + if (rc != 0) { - const auto err = errno; - logf( - "epoll_ctl MOD error: %s", - std::generic_category().message(err).c_str()); + logf("uv_poll_start error: %s", uv_strerror(rc)); } } // After writing, tear the connection down if a graceful close was requested - // and all buffered output has been flushed; otherwise update epoll - // interest (re-arming EPOLLOUT while output remains). + // and all buffered output has been flushed; otherwise update poll interest + // (re-arming UV_WRITABLE while output remains). void finish_or_close(int fd, Conn& c) { if (c.close_after_flush && c.out_off >= c.outbuf.size()) @@ -480,7 +491,7 @@ namespace asynchost // Returns false if the connection should be closed. Implements // backpressure: a WANT_WRITE leaves the remaining plaintext buffered and - // arms EPOLLOUT. + // arms UV_WRITABLE. bool do_write(Conn& c) { if (c.ssl == nullptr) @@ -533,16 +544,24 @@ namespace asynchost { on_close(it->second->id); } - epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, nullptr); id_to_fd.erase(it->second->id); - SSL* ssl = it->second->ssl; + auto conn = std::move(it->second); + conns.erase(it); + (void)uv_poll_stop(&conn->poll); + SSL* ssl = conn->ssl; if (ssl != nullptr) { SSL_shutdown(ssl); SSL_free(ssl); + conn->ssl = nullptr; } ::close(fd); - conns.erase(it); + conn->fd = -1; + auto* raw = conn.get(); + closing_conns.emplace(raw, std::move(conn)); + ++pending_uv_closes; + uv_close( + reinterpret_cast(&raw->poll), on_connection_poll_closed); } void accept_all() @@ -570,6 +589,7 @@ namespace asynchost } auto c = std::make_unique(); + c->owner = this; c->fd = cfd; c->id = (shared_next_id != nullptr) ? shared_next_id->fetch_add(1) : next_id++; @@ -608,16 +628,33 @@ namespace asynchost c->ssl = ssl; } - epoll_event ev{}; - ev.data.fd = cfd; - ev.events = EPOLLIN; - if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, cfd, &ev) != 0) + const int poll_rc = uv_poll_init_socket(loop, &c->poll, cfd); + if (poll_rc != 0) + { + if (c->ssl != nullptr) + { + SSL_free(c->ssl); + } + ::close(cfd); + continue; + } + c->poll.data = c.get(); + const int start_rc = + uv_poll_start(&c->poll, UV_READABLE, on_connection_poll); + if (start_rc != 0) { if (c->ssl != nullptr) { SSL_free(c->ssl); } ::close(cfd); + ++pending_uv_closes; + c->fd = -1; + auto* raw = c.get(); + closing_conns.emplace(raw, std::move(c)); + uv_close( + reinterpret_cast(&raw->poll), + on_connection_poll_closed); continue; } const auto cid = c->id; @@ -627,7 +664,7 @@ namespace asynchost } } - void on_conn_event(int fd, uint32_t events) + void on_conn_event(int fd, int events) { auto it = conns.find(fd); if (it == conns.end()) @@ -643,7 +680,7 @@ namespace asynchost } else { - if ((events & (EPOLLIN | EPOLLERR | EPOLLHUP)) != 0) + if ((events & (UV_READABLE | UV_DISCONNECT)) != 0) { alive = do_read(c); } @@ -661,25 +698,47 @@ namespace asynchost finish_or_close(fd, c); } - void wake() const + void mark_loop_thread() { - if (wake_fd >= 0) + std::lock_guard guard(lifecycle_mutex); + loop_thread_id = std::this_thread::get_id(); + loop_thread_seen = true; + } + + static void on_connection_poll(uv_poll_t* handle, int status, int events) + { + auto* conn = static_cast(handle->data); + auto* self = conn->owner; + self->mark_loop_thread(); + if (status < 0) { - const uint64_t one = 1; - [[maybe_unused]] auto w = ::write(wake_fd, &one, sizeof(one)); + self->close_conn(conn->fd); + return; } + self->on_conn_event(conn->fd, events); } - // Drain the cross-thread outbound queue on the epoll thread: append queued - // plaintext to each connection and flush (with backpressure), or close. - void drain_pending_out() + static void on_connection_poll_closed(uv_handle_t* handle) + { + auto* conn = static_cast(handle->data); + auto* self = conn->owner; + self->closing_conns.erase(conn); + self->complete_uv_close(); + } + + void wake() { - uint64_t counter = 0; - while (::read(wake_fd, &counter, sizeof(counter)) > 0) + std::lock_guard guard(lifecycle_mutex); + if (wake_handle_initialised && !stopping) { - // Clear the eventfd counter. + (void)uv_async_send(&wake_handle); } + } + // Drain the cross-thread outbound queue on the libuv thread: append queued + // plaintext to each connection and flush (with backpressure), or close. + void drain_pending_out() + { std::vector items; std::vector> certs; { @@ -771,69 +830,119 @@ namespace asynchost } } - void run() + static void on_listen_poll(uv_poll_t* handle, int status, int events) { - constexpr int max_events = 64; - // With an idle timeout configured, wake periodically to sweep idle - // connections; otherwise block until there is work. - const int wait_ms = - idle_timeout.has_value() ? idle_sweep_interval_ms : -1; - std::vector events(max_events); - while (running.load()) - { - const int n = epoll_wait(epoll_fd, events.data(), max_events, wait_ms); - if (n < 0) - { - if (errno == EINTR) - { - continue; - } - const auto err = errno; - logf( - "epoll_wait error: %s", - std::generic_category().message(err).c_str()); - break; - } + auto* self = static_cast(handle->data); + self->mark_loop_thread(); + if (status < 0) + { + self->request_stop_on_loop(); + return; + } + if ((events & UV_READABLE) != 0) + { + self->accept_all(); + } + } - for (int i = 0; i < n; ++i) - { - const int fd = events[i].data.fd; - if (fd == stop_fd) - { - running.store(false); - break; - } - if (fd == wake_fd) - { - drain_pending_out(); - continue; - } - if (fd == listen_fd) - { - accept_all(); - continue; - } - on_conn_event(fd, events[i].events); - } + static void on_wake(uv_async_t* handle) + { + auto* self = static_cast(handle->data); + self->mark_loop_thread(); + bool should_stop = false; + { + std::lock_guard guard(self->lifecycle_mutex); + should_stop = self->stopping; + } + if (should_stop) + { + self->request_stop_on_loop(); + } + else + { + self->drain_pending_out(); + } + } + + static void on_idle_timer(uv_timer_t* handle) + { + auto* self = static_cast(handle->data); + self->mark_loop_thread(); + self->sweep_idle(); + } + + static void on_server_handle_closed(uv_handle_t* handle) + { + auto* self = static_cast(handle->data); + self->complete_uv_close(); + } + + void complete_uv_close() + { + std::lock_guard guard(lifecycle_mutex); + if (pending_uv_closes > 0) + { + --pending_uv_closes; + } + if (stopping && pending_uv_closes == 0) + { + stopped = true; + stopped_cv.notify_all(); + } + } - if (idle_timeout.has_value()) + void close_server_handle(uv_handle_t* handle) + { + if (!uv_is_closing(handle)) + { + ++pending_uv_closes; + uv_close(handle, on_server_handle_closed); + } + } + + void request_stop_on_loop() + { + { + std::lock_guard guard(lifecycle_mutex); + if (shutdown_started) { - const auto now = std::chrono::steady_clock::now(); - if ( - now - last_idle_sweep >= - std::chrono::milliseconds(idle_sweep_interval_ms)) - { - last_idle_sweep = now; - sweep_idle(); - } + return; } + stopping = true; + shutdown_started = true; + } + (void)uv_poll_stop(&listen_poll); + if (listen_fd >= 0) + { + ::close(listen_fd); + listen_fd = -1; } - - // Tear down all live connections on the loop thread. while (!conns.empty()) { close_conn(conns.begin()->first); } + if (idle_timer_initialised) + { + (void)uv_timer_stop(&idle_timer); + close_server_handle(reinterpret_cast(&idle_timer)); + idle_timer_initialised = false; + } + if (listen_poll_initialised) + { + close_server_handle(reinterpret_cast(&listen_poll)); + listen_poll_initialised = false; + } + if (wake_handle_initialised) + { + close_server_handle(reinterpret_cast(&wake_handle)); + wake_handle_initialised = false; + } + std::lock_guard guard(lifecycle_mutex); + if (pending_uv_closes == 0) + { + stopped = true; + stopped_cv.notify_all(); + } } public: @@ -848,8 +957,10 @@ namespace asynchost bool plaintext_ = false, bool verbose_ = false, std::atomic<::tcp::ConnID>* shared_next_id_ = nullptr, - std::optional idle_timeout_ = std::nullopt) : + std::optional idle_timeout_ = std::nullopt, + uv_loop_t* loop_ = uv_default_loop()) : plaintext(plaintext_), + loop(loop_), on_data(std::move(on_data_)), on_close(std::move(on_close_)), verbose(verbose_), @@ -953,46 +1064,6 @@ namespace asynchost bound_port = ntohs(reinterpret_cast(&bound)->sin_port); } } - - epoll_fd = epoll_create1(0); - if (epoll_fd < 0) - { - cleanup(); - throw std::runtime_error("epoll_create1 failed"); - } - stop_fd = eventfd(0, EFD_NONBLOCK); - if (stop_fd < 0) - { - cleanup(); - throw std::runtime_error("eventfd failed"); - } - wake_fd = eventfd(0, EFD_NONBLOCK); - if (wake_fd < 0) - { - cleanup(); - throw std::runtime_error("eventfd (wake) failed"); - } - - epoll_event ev{}; - ev.data.fd = listen_fd; - ev.events = EPOLLIN; - if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev) != 0) - { - cleanup(); - throw std::runtime_error("epoll_ctl(listen) failed"); - } - ev.data.fd = stop_fd; - if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, stop_fd, &ev) != 0) - { - cleanup(); - throw std::runtime_error("epoll_ctl(stop) failed"); - } - ev.data.fd = wake_fd; - if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, wake_fd, &ev) != 0) - { - cleanup(); - throw std::runtime_error("epoll_ctl(wake) failed"); - } } OpenSSLServer(const OpenSSLServer&) = delete; @@ -1013,29 +1084,103 @@ namespace asynchost void start() { - running.store(true); - loop_thread = std::thread([this]() { run(); }); + std::lock_guard guard(lifecycle_mutex); + if (started) + { + return; + } + int rc = uv_poll_init_socket(loop, &listen_poll, listen_fd); + if (rc != 0) + { + throw std::runtime_error( + std::string("uv_poll_init_socket(listen) failed: ") + + uv_strerror(rc)); + } + listen_poll_initialised = true; + listen_poll.data = this; + + rc = uv_async_init(loop, &wake_handle, on_wake); + if (rc != 0) + { + throw std::runtime_error( + std::string("uv_async_init failed: ") + uv_strerror(rc)); + } + wake_handle_initialised = true; + wake_handle.data = this; + + if (idle_timeout.has_value()) + { + rc = uv_timer_init(loop, &idle_timer); + if (rc != 0) + { + throw std::runtime_error( + std::string("uv_timer_init failed: ") + uv_strerror(rc)); + } + idle_timer_initialised = true; + idle_timer.data = this; + rc = uv_timer_start( + &idle_timer, + on_idle_timer, + idle_sweep_interval_ms, + idle_sweep_interval_ms); + if (rc != 0) + { + throw std::runtime_error( + std::string("uv_timer_start failed: ") + uv_strerror(rc)); + } + } + + rc = uv_poll_start(&listen_poll, UV_READABLE, on_listen_poll); + if (rc != 0) + { + throw std::runtime_error( + std::string("uv_poll_start(listen) failed: ") + uv_strerror(rc)); + } + stopped = false; + stopping = false; + shutdown_started = false; + initialising_thread_id = std::this_thread::get_id(); + loop_thread_seen = false; + started = true; } void stop() { - if (!running.exchange(false)) + std::unique_lock lock(lifecycle_mutex); + if (!started || stopped) { - if (loop_thread.joinable()) - { - loop_thread.join(); - } return; } - if (stop_fd >= 0) + if (!stopping) { - const uint64_t one = 1; - [[maybe_unused]] auto w = ::write(stop_fd, &one, sizeof(one)); + stopping = true; + (void)uv_async_send(&wake_handle); + } + const bool loop_not_started_here = !loop_thread_seen && + std::this_thread::get_id() == initialising_thread_id; + if (loop_not_started_here) + { + lock.unlock(); + request_stop_on_loop(); + for (;;) + { + { + std::lock_guard guard(lifecycle_mutex); + if (stopped) + { + return; + } + } + (void)uv_run(loop, UV_RUN_NOWAIT); + } } - if (loop_thread.joinable()) + if (std::this_thread::get_id() == loop_thread_id) { - loop_thread.join(); + lock.unlock(); + request_stop_on_loop(); + return; } + stopped_cv.wait(lock, [this]() { return stopped; }); } // Thread-safe. Queue plaintext to be encrypted and written to `conn_id`. @@ -1106,21 +1251,6 @@ namespace asynchost private: void cleanup() { - if (wake_fd >= 0) - { - ::close(wake_fd); - wake_fd = -1; - } - if (stop_fd >= 0) - { - ::close(stop_fd); - stop_fd = -1; - } - if (epoll_fd >= 0) - { - ::close(epoll_fd); - epoll_fd = -1; - } if (listen_fd >= 0) { ::close(listen_fd); From 6834e425d029b1d7974ab6bb0860ea370a550215 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Wed, 5 Aug 2026 12:41:16 +0000 Subject: [PATCH 39/59] Tidy --- src/host/tls/openssl_server.h | 74 ++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index 5b79324d287b..1ab998cd8e85 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -86,65 +86,66 @@ namespace asynchost std::chrono::steady_clock::now(); }; + struct OutItem + { + ::tcp::ConnID id = 0; + std::vector data; + bool close = false; + }; + SSL_CTX* ctx = nullptr; - // Plaintext (UNSECURED) interface: no TLS, raw socket I/O. - bool plaintext = false; - // ALPN protocol advertised by the server (wire format, length-prefixed), - // e.g. "\x02h2" or "\x08http/1.1". Empty disables ALPN. - std::string alpn_wire; uv_loop_t* loop = nullptr; - int listen_fd = -1; - uv_poll_t listen_poll{}; - uv_async_t wake_handle{}; - uv_timer_t idle_timer{}; - bool listen_poll_initialised = false; - bool wake_handle_initialised = false; - bool idle_timer_initialised = false; - uint16_t bound_port = 0; - OnData on_data; - OnClose on_close; - bool verbose = false; - - std::unordered_map> conns; - std::unordered_map> closing_conns; - std::unordered_map<::tcp::ConnID, int> id_to_fd; ::tcp::ConnID next_id = 1; // Optional shared id source so multiple servers (one per interface) // allocate connection ids from a single global space - required for a // global session registry and reply routing. std::atomic<::tcp::ConnID>* shared_next_id = nullptr; + std::chrono::steady_clock::time_point last_idle_sweep = + std::chrono::steady_clock::now(); + size_t pending_uv_closes = 0; + std::thread::id initialising_thread_id; + std::thread::id loop_thread_id; // Close a connection after this much inactivity (no I/O); nullopt disables // idle closure. A libuv timer wakes every idle_sweep_interval_ms to check. static constexpr int idle_sweep_interval_ms = 1000; std::optional idle_timeout; - std::chrono::steady_clock::time_point last_idle_sweep = - std::chrono::steady_clock::now(); // Cross-thread outbound queue: send()/close_connection() append here from // any thread and wake the loop, which drains it on the libuv thread. - struct OutItem - { - ::tcp::ConnID id = 0; - std::vector data; - bool close = false; - }; - std::mutex out_mutex; std::vector pending_out; // Cross-thread server-cert (re)load requests (deferred cert / rotation), // applied on the loop thread so `ctx` is only ever touched there. std::vector> pending_certs; + // ALPN protocol advertised by the server (wire format, length-prefixed), + // e.g. "\x02h2" or "\x08http/1.1". Empty disables ALPN. + std::string alpn_wire; + OnData on_data; + OnClose on_close; + + std::mutex out_mutex; std::mutex lifecycle_mutex; std::condition_variable stopped_cv; + std::unordered_map> conns; + std::unordered_map> closing_conns; + std::unordered_map<::tcp::ConnID, int> id_to_fd; + uv_async_t wake_handle{}; + uv_timer_t idle_timer{}; + uv_poll_t listen_poll{}; + int listen_fd = -1; + uint16_t bound_port = 0; + // Plaintext (UNSECURED) interface: no TLS, raw socket I/O. + bool plaintext = false; + bool listen_poll_initialised = false; + bool wake_handle_initialised = false; + bool idle_timer_initialised = false; + bool verbose = false; bool started = false; bool stopping = false; bool shutdown_started = false; bool stopped = false; - size_t pending_uv_closes = 0; - std::thread::id initialising_thread_id; - std::thread::id loop_thread_id; bool loop_thread_seen = false; void logf(const char* fmt, ...) const @@ -893,7 +894,7 @@ namespace asynchost void close_server_handle(uv_handle_t* handle) { - if (!uv_is_closing(handle)) + if (uv_is_closing(handle) == 0) { ++pending_uv_closes; uv_close(handle, on_server_handle_closed); @@ -959,13 +960,14 @@ namespace asynchost std::atomic<::tcp::ConnID>* shared_next_id_ = nullptr, std::optional idle_timeout_ = std::nullopt, uv_loop_t* loop_ = uv_default_loop()) : - plaintext(plaintext_), loop(loop_), + shared_next_id(shared_next_id_), + idle_timeout(idle_timeout_), on_data(std::move(on_data_)), on_close(std::move(on_close_)), + plaintext(plaintext_), verbose(verbose_), - shared_next_id(shared_next_id_), - idle_timeout(idle_timeout_) + started(false) { if (!alpn.empty()) { From c1f13d0c5693eaa7bd856c875c36a784b97df76d Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Wed, 5 Aug 2026 12:47:52 +0000 Subject: [PATCH 40/59] TSAN the unit test --- src/host/test/openssl_server_test.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp index fe4cd3d29499..3786f5f3f8fb 100644 --- a/src/host/test/openssl_server_test.cpp +++ b/src/host/test/openssl_server_test.cpp @@ -325,8 +325,8 @@ namespace struct EchoServer { - UVLoopRunner loop; std::unique_ptr server; + UVLoopRunner loop; EchoServer( const std::string& cert, @@ -484,7 +484,6 @@ TEST_CASE("Concurrent connections") TEST_CASE("Reply from a worker thread") { auto [cert, key] = make_server_cert(); - UVLoopRunner loop; OpenSSLServer* sp = nullptr; std::mutex m; @@ -520,6 +519,7 @@ TEST_CASE("Reply from a worker thread") } cv.notify_one(); }); + UVLoopRunner loop; sp = &server; server.start(); loop.start(); @@ -538,7 +538,6 @@ TEST_CASE("Reply from a worker thread") TEST_CASE("Datagram server round-trip on the libuv reactor") { - UVLoopRunner loop; DatagramServer* server_ptr = nullptr; DatagramServer server( "127.0.0.1", @@ -550,6 +549,7 @@ TEST_CASE("Datagram server round-trip on the libuv reactor") socklen_t peerlen) { REQUIRE(server_ptr->send_to(peer, peerlen, data, len)); }); + UVLoopRunner loop; server_ptr = &server; server.start(); loop.start(); @@ -584,7 +584,6 @@ TEST_CASE("Datagram server round-trip on the libuv reactor") TEST_CASE("Session bridge: round-trip via ccf::Session + SessionWriter") { auto [cert, key] = make_server_cert(); - UVLoopRunner loop; OpenSSLSessionManager mgr( cert, key, @@ -593,6 +592,7 @@ TEST_CASE("Session bridge: round-trip via ccf::Session + SessionWriter") [](::tcp::ConnID id, ccf::SessionWriter& w, std::vector) { return std::make_shared(id, w); }); + UVLoopRunner loop; mgr.start(); loop.start(); REQUIRE(mgr.port() != 0); @@ -606,7 +606,6 @@ TEST_CASE("Session bridge: round-trip via ccf::Session + SessionWriter") TEST_CASE("Session bridge: large transfer via ccf::Session + SessionWriter") { auto [cert, key] = make_server_cert(); - UVLoopRunner loop; OpenSSLSessionManager mgr( cert, key, @@ -615,6 +614,7 @@ TEST_CASE("Session bridge: large transfer via ccf::Session + SessionWriter") [](::tcp::ConnID id, ccf::SessionWriter& w, std::vector) { return std::make_shared(id, w); }); + UVLoopRunner loop; mgr.start(); loop.start(); @@ -632,7 +632,6 @@ TEST_CASE("Peer certificate is captured for inbound connections") { auto [cert, key] = make_server_cert(); auto [client_cert, client_key] = make_server_cert(); - UVLoopRunner loop; std::mutex m; std::vector captured; @@ -651,6 +650,7 @@ TEST_CASE("Peer certificate is captured for inbound connections") got.store(true); return std::make_shared(id, w); }); + UVLoopRunner loop; mgr.start(); loop.start(); @@ -672,7 +672,6 @@ TEST_CASE("Peer certificate is captured for inbound connections") TEST_CASE("Client certificate is requested but not enforced") { auto [cert, key] = make_server_cert(); - UVLoopRunner loop; std::mutex m; std::vector captured; @@ -691,6 +690,7 @@ TEST_CASE("Client certificate is requested but not enforced") got.store(true); return std::make_shared(id, w); }); + UVLoopRunner loop; mgr.start(); loop.start(); @@ -832,7 +832,6 @@ TEST_CASE("Graceful close flushes buffered response without truncation") { auto [cert, key] = make_server_cert(); const auto payload = random_bytes(4 * 1024 * 1024); - UVLoopRunner loop; OpenSSLSessionManager mgr( cert, @@ -842,6 +841,7 @@ TEST_CASE("Graceful close flushes buffered response without truncation") [&payload](::tcp::ConnID id, ccf::SessionWriter& w, std::vector) { return std::make_shared(id, w, payload); }); + UVLoopRunner loop; mgr.start(); loop.start(); From 511532fef8a9d7d45ea5b2ddff28b79217f657ef Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Wed, 5 Aug 2026 12:51:48 +0000 Subject: [PATCH 41/59] Tidier --- src/host/tls/openssl_server.h | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index 1ab998cd8e85..cfaa2e9e4226 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -966,8 +966,7 @@ namespace asynchost on_data(std::move(on_data_)), on_close(std::move(on_close_)), plaintext(plaintext_), - verbose(verbose_), - started(false) + verbose(verbose_) { if (!alpn.empty()) { From 72c1b22d288f2f10640b046174f7f2f55550a8f7 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Wed, 5 Aug 2026 13:32:05 +0000 Subject: [PATCH 42/59] Handle empty port case in OpenSSL and Datagram server initialization --- src/host/rpc_connection_manager.h | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/host/rpc_connection_manager.h b/src/host/rpc_connection_manager.h index fd763b97a719..cb5f8b71e39b 100644 --- a/src/host/rpc_connection_manager.h +++ b/src/host/rpc_connection_manager.h @@ -494,7 +494,8 @@ namespace ccf decrement_interface_sessions(li); }; - const auto port_num = static_cast(std::stoi(port)); + const auto port_num = + port.empty() ? 0 : static_cast(std::stoi(port)); li->bridge = std::make_unique( cert_pem, key_pem, @@ -543,7 +544,7 @@ namespace ccf udp->server = std::make_unique( host, - static_cast(std::stoi(port)), + port.empty() ? 0 : static_cast(std::stoi(port)), [this, li, udp_ptr, writer]( const uint8_t* data, size_t len, From e2d94caa30d44fd8a981b6de73019fd9706c7145 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Wed, 5 Aug 2026 21:10:15 +0000 Subject: [PATCH 43/59] Disable Nagle on inbound RPC connections --- src/host/tls/openssl_server.h | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index 5b79324d287b..818de75d48de 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -588,6 +589,17 @@ namespace asynchost break; } + const int one = 1; + if (setsockopt(cfd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)) != 0) + { + const auto err = errno; + logf( + "setsockopt(TCP_NODELAY) error: %s", + std::generic_category().message(err).c_str()); + ::close(cfd); + continue; + } + auto c = std::make_unique(); c->owner = this; c->fd = cfd; From ab55b28929df397cdb80b57d952a81442825e58a Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Thu, 6 Aug 2026 11:31:25 +0000 Subject: [PATCH 44/59] Fix thread dispatch --- src/host/test/openssl_server_test.cpp | 137 ++++++- src/host/tls/openssl_server.h | 534 ++++++++++++++++--------- src/host/tls/openssl_session_manager.h | 28 +- 3 files changed, 499 insertions(+), 200 deletions(-) diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp index 3786f5f3f8fb..2ded13c15a58 100644 --- a/src/host/test/openssl_server_test.cpp +++ b/src/host/test/openssl_server_test.cpp @@ -9,6 +9,7 @@ #include "host/datagram_server.h" #include "host/tls/openssl_server.h" #include "host/tls/openssl_session_manager.h" +#include "tasks/task_system.h" #define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN #include @@ -21,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -49,6 +51,19 @@ namespace return signal(SIGPIPE, SIG_IGN) != SIG_ERR; }(); + struct TaskWorkers + { + TaskWorkers() + { + ccf::tasks::set_task_threads(4); + } + + ~TaskWorkers() + { + ccf::tasks::set_task_threads(0); + } + } task_workers; + std::pair make_server_cert() { using namespace std::literals; @@ -338,7 +353,8 @@ namespace key, host, static_cast(0), - [this](uint64_t id, std::vector d) { + [this]( + uint64_t id, std::vector d, const std::vector&) { server->send(id, d.data(), d.size()); }); server->start(); @@ -411,7 +427,11 @@ TEST_CASE("Transports stop cleanly before the libuv loop starts") { auto [cert, key] = make_server_cert(); OpenSSLServer tcp_server( - cert, key, "127.0.0.1", 0, [](::tcp::ConnID, std::vector) {}); + cert, + key, + "127.0.0.1", + 0, + [](::tcp::ConnID, std::vector, const std::vector&) {}); tcp_server.start(); tcp_server.stop(); @@ -425,6 +445,80 @@ TEST_CASE("Transports stop cleanly before the libuv loop starts") REQUIRE(uv_loop_alive(uv_default_loop()) == 0); } +TEST_CASE("Transport shutdown drains TLS tasks with no background workers") +{ + struct RestoreWorkers + { + ~RestoreWorkers() + { + ccf::tasks::set_task_threads(4); + } + } restore_workers; + ccf::tasks::set_task_threads(0); + + auto [cert, key] = make_server_cert(); + OpenSSLServer server( + cert, + key, + "127.0.0.1", + 0, + [](::tcp::ConnID, std::vector, const std::vector&) {}); + UVLoopRunner loop; + server.start(); + loop.start(); + + const int fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + REQUIRE(fd >= 0); + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(server.port()); + REQUIRE(inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) == 1); + REQUIRE(::connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0); + const uint8_t byte = 0; + REQUIRE(::send(fd, &byte, sizeof(byte), MSG_NOSIGNAL) == sizeof(byte)); + + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (ccf::tasks::get_main_job_board().get_summary().pending_tasks == 0 && + std::chrono::steady_clock::now() < deadline) + { + std::this_thread::yield(); + } + REQUIRE(ccf::tasks::get_main_job_board().get_summary().pending_tasks > 0); + + server.stop(); + ::close(fd); +} + +TEST_CASE("TCP connections use the legacy latency and keepalive options") +{ + const int fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + REQUIRE(fd >= 0); + REQUIRE_FALSE(asynchost::details::configure_tcp_connection(fd).has_value()); + + const auto get_option = [fd](int level, int option) { + int value = 0; + socklen_t value_size = sizeof(value); + REQUIRE(getsockopt(fd, level, option, &value, &value_size) == 0); + REQUIRE(value_size == sizeof(value)); + return value; + }; + + REQUIRE(get_option(IPPROTO_TCP, TCP_NODELAY) == 1); + REQUIRE(get_option(SOL_SOCKET, SO_KEEPALIVE) == 1); + REQUIRE(get_option(IPPROTO_TCP, TCP_KEEPIDLE) == 30); + REQUIRE(get_option(IPPROTO_TCP, TCP_KEEPINTVL) == 1); + REQUIRE(get_option(IPPROTO_TCP, TCP_KEEPCNT) == 10); + REQUIRE((fcntl(fd, F_GETFD) & FD_CLOEXEC) != 0); + + ::close(fd); + + const auto error = asynchost::details::configure_tcp_connection(-1); + REQUIRE(error.has_value()); + REQUIRE(std::string(error->option) == "TCP_NODELAY"); + REQUIRE(error->error == EBADF); +} + TEST_CASE("TLS handshake and small round-trip") { auto [cert, key] = make_server_cert(); @@ -435,6 +529,43 @@ TEST_CASE("TLS handshake and small round-trip") REQUIRE(tls_client_exchange(s.port(), msg, msg.size()) == msg); } +TEST_CASE("TLS processing runs off the libuv thread") +{ + auto [cert, key] = make_server_cert(); + std::mutex callback_mutex; + std::thread::id callback_thread; + OpenSSLServer* server_ptr = nullptr; + OpenSSLServer server( + cert, + key, + "127.0.0.1", + 0, + [&]( + ::tcp::ConnID id, + std::vector data, + const std::vector&) { + { + std::lock_guard guard(callback_mutex); + callback_thread = std::this_thread::get_id(); + } + server_ptr->send(id, data.data(), data.size()); + }); + UVLoopRunner loop; + server_ptr = &server; + server.start(); + loop.start(); + + const std::vector msg = {'w', 'o', 'r', 'k', 'e', 'r'}; + REQUIRE(tls_client_exchange(server.port(), msg, msg.size()) == msg); + { + std::lock_guard guard(callback_mutex); + REQUIRE(callback_thread != std::thread::id{}); + REQUIRE(callback_thread != loop.thread.get_id()); + } + + server.stop(); +} + TEST_CASE("Large transfer exercises the backpressure path") { auto [cert, key] = make_server_cert(); @@ -512,7 +643,7 @@ TEST_CASE("Reply from a worker thread") key, "127.0.0.1", static_cast(0), - [&](uint64_t id, std::vector d) { + [&](uint64_t id, std::vector d, const std::vector&) { { std::lock_guard l(m); q.emplace_back(id, std::move(d)); diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index cfaa2e9e4226..f5dbb9720361 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -3,13 +3,19 @@ #pragma once // OpenSSL-native TLS/plaintext TCP server for RPC interfaces. OpenSSL owns the -// socket fd directly, while the host libuv loop drives non-blocking handshake, -// reads, writes, graceful close, and idle connection cleanup. - +// socket fd directly. The host libuv loop reports readiness, while +// per-connection OrderedTasks drive non-blocking handshake, reads, writes, and +// graceful close away from the loop thread. + +#include "ds/internal_logger.h" +#include "tasks/ordered_tasks.h" +#include "tasks/task_system.h" +#include "tasks/worker.h" #include "tcp/msg_types.h" #include #include +#include #include #include #include @@ -23,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -41,15 +48,61 @@ namespace asynchost { + namespace details + { + struct SocketOptionError + { + const char* option = nullptr; + int error = 0; + }; + + inline std::optional configure_tcp_connection(int fd) + { + const auto set_option = [fd]( + int level, + int option, + const char* option_name, + int value) -> std::optional { + if (setsockopt(fd, level, option, &value, sizeof(value)) != 0) + { + return SocketOptionError{option_name, errno}; + } + return std::nullopt; + }; + + const int one = 1; + if (auto error = set_option(IPPROTO_TCP, TCP_NODELAY, "TCP_NODELAY", one)) + { + return error; + } + if ( + auto error = set_option(SOL_SOCKET, SO_KEEPALIVE, "SO_KEEPALIVE", one)) + { + return error; + } + if ( + auto error = set_option(IPPROTO_TCP, TCP_KEEPIDLE, "TCP_KEEPIDLE", 30)) + { + return error; + } + if ( + auto error = set_option(IPPROTO_TCP, TCP_KEEPINTVL, "TCP_KEEPINTVL", 1)) + { + return error; + } + return set_option(IPPROTO_TCP, TCP_KEEPCNT, "TCP_KEEPCNT", 10); + } + } + class OpenSSLServer { public: - // Invoked on the libuv thread with a complete chunk of decrypted bytes for - // connection `conn_id`. The handler typically hands processing to a worker - // (e.g. OrderedTasks) and later calls send()/close_connection() from that - // thread - both are thread-safe and wake the loop. - using OnData = - std::function data)>; + // Invoked on a worker with a complete chunk of decrypted bytes and the + // certificate captured during this connection's handshake. + using OnData = std::function data, + const std::vector& peer_cert)>; // Invoked on the libuv thread when a connection is torn down (peer // disconnect, error, or close_connection()). Lets an owner drop per- @@ -58,12 +111,17 @@ namespace asynchost private: static constexpr size_t read_chunk = 16384; + static constexpr size_t max_read_per_event = read_chunk * 4; + + struct OutItem; struct Conn { OpenSSLServer* owner = nullptr; int fd = -1; uv_poll_t poll{}; + std::shared_ptr tls_tasks; + std::shared_ptr accepted_ctx; SSL* ssl = nullptr; ::tcp::ConnID id = 0; enum State : uint8_t @@ -71,6 +129,7 @@ namespace asynchost Handshaking, Ready } state = Handshaking; + std::vector peer_cert; // Pending plaintext to be encrypted/written; out_off bytes already sent. std::vector outbuf; size_t out_off = 0; @@ -84,6 +143,13 @@ namespace asynchost // Last time any I/O happened on this connection; used for idle timeout. std::chrono::steady_clock::time_point last_active = std::chrono::steady_clock::now(); + + // The fields below are accessed only on the libuv thread. Polling stays + // stopped while worker_active is true. + std::vector pending_commands; + int pending_events = 0; + bool worker_active = false; + bool close_requested = false; }; struct OutItem @@ -93,7 +159,21 @@ namespace asynchost bool close = false; }; - SSL_CTX* ctx = nullptr; + struct DriveInput + { + int events = 0; + std::vector commands; + bool close_requested = false; + }; + + struct DriveResult + { + std::shared_ptr conn; + bool alive = true; + std::chrono::steady_clock::time_point last_active; + }; + + std::shared_ptr ctx; uv_loop_t* loop = nullptr; ::tcp::ConnID next_id = 1; // Optional shared id source so multiple servers (one per interface) @@ -114,6 +194,7 @@ namespace asynchost // Cross-thread outbound queue: send()/close_connection() append here from // any thread and wake the loop, which drains it on the libuv thread. std::vector pending_out; + std::vector completed_drives; // Cross-thread server-cert (re)load requests (deferred cert / rotation), // applied on the loop thread so `ctx` is only ever touched there. @@ -128,8 +209,8 @@ namespace asynchost std::mutex out_mutex; std::mutex lifecycle_mutex; std::condition_variable stopped_cv; - std::unordered_map> conns; - std::unordered_map> closing_conns; + std::unordered_map> conns; + std::unordered_map> closing_conns; std::unordered_map<::tcp::ConnID, int> id_to_fd; uv_async_t wake_handle{}; uv_timer_t idle_timer{}; @@ -221,19 +302,19 @@ namespace asynchost // This is the only place CCF's inbound TLS policy is defined. It is // asserted from the wire by src/host/test/openssl_server_test.cpp and, for // a running service, by tests/tls_groups.py. - SSL_CTX* build_server_ctx( + std::shared_ptr build_server_ctx( const std::string& cert_pem, const std::string& key_pem) { SSL_CTX* c = SSL_CTX_new(TLS_server_method()); if (c == nullptr) { - return nullptr; + return {}; } // Require at least TLS 1.2, support up to 1.3 if (SSL_CTX_set_min_proto_version(c, TLS1_2_VERSION) != 1) { SSL_CTX_free(c); - return nullptr; + return {}; } // Disable renegotiation to avoid DoS @@ -252,7 +333,7 @@ namespace asynchost if (SSL_CTX_set_cipher_list(c, cipher_list) != 1) { SSL_CTX_free(c); - return nullptr; + return {}; } // Set cipher for TLS 1.3 @@ -262,7 +343,7 @@ namespace asynchost if (SSL_CTX_set_ciphersuites(c, ciphersuites) != 1) { SSL_CTX_free(c); - return nullptr; + return {}; } // Prefer hybrid post-quantum groups when available, while retaining the @@ -274,7 +355,7 @@ namespace asynchost "P-521:P-384:P-256") != 1) { SSL_CTX_free(c); - return nullptr; + return {}; } // Allow buffer to be relocated between WANT_WRITE retries, and do partial @@ -296,9 +377,9 @@ namespace asynchost if (!load_cert_key(c, cert_pem, key_pem)) { SSL_CTX_free(c); - return nullptr; + return {}; } - return c; + return {c, SSL_CTX_free}; } void update_interest(Conn& c) const @@ -311,21 +392,6 @@ namespace asynchost } } - // After writing, tear the connection down if a graceful close was requested - // and all buffered output has been flushed; otherwise update poll interest - // (re-arming UV_WRITABLE while output remains). - void finish_or_close(int fd, Conn& c) - { - if (c.close_after_flush && c.out_off >= c.outbuf.size()) - { - close_conn(fd); - } - else - { - update_interest(c); - } - } - static int alpn_select_cb( SSL* /*ssl*/, const unsigned char** out, @@ -359,16 +425,30 @@ namespace asynchost // Returns false if the connection should be closed. bool do_handshake(Conn& c) { - // The error queue is thread-local and shared across every connection - // serviced by this loop, and SSL_get_error() consults it. Clear it before - // each SSL operation so a stale error from another connection cannot be - // misattributed (which would spuriously close healthy connections). + // The error queue is thread-local and SSL_get_error() consults it. Clear + // it before each operation because successive actions for this + // connection may execute on different workers. ERR_clear_error(); const int r = SSL_accept(c.ssl); if (r == 1) { c.state = Conn::Ready; c.want_write = false; + X509* cert = SSL_get_peer_certificate(c.ssl); + if (cert != nullptr) + { + const int len = i2d_X509(cert, nullptr); + if (len > 0) + { + c.peer_cert.resize(static_cast(len)); + unsigned char* p = c.peer_cert.data(); + if (i2d_X509(cert, &p) != len) + { + c.peer_cert.clear(); + } + } + X509_free(cert); + } logf("conn %llu: handshake complete", (unsigned long long)c.id); return do_read(c) && do_write(c); } @@ -391,16 +471,20 @@ namespace asynchost // Returns false if the connection should be closed. bool do_read_plaintext(Conn& c) { - for (;;) + size_t total_read = 0; + while (total_read < max_read_per_event) { uint8_t buf[read_chunk]; const ssize_t n = ::recv(c.fd, buf, sizeof(buf), 0); if (n > 0) { + total_read += static_cast(n); if (on_data) { on_data( - c.id, std::vector(buf, buf + static_cast(n))); + c.id, + std::vector(buf, buf + static_cast(n)), + c.peer_cert); } continue; } @@ -418,6 +502,7 @@ namespace asynchost } return false; } + return true; } // Returns false if the connection should be closed. @@ -459,16 +544,18 @@ namespace asynchost { return do_read_plaintext(c); } - for (;;) + size_t total_read = 0; + while (total_read < max_read_per_event) { uint8_t buf[read_chunk]; ERR_clear_error(); const int n = SSL_read(c.ssl, buf, static_cast(sizeof(buf))); if (n > 0) { + total_read += static_cast(n); if (on_data) { - on_data(c.id, std::vector(buf, buf + n)); + on_data(c.id, std::vector(buf, buf + n), c.peer_cert); } continue; } @@ -488,6 +575,7 @@ namespace asynchost // (no close_notify), or a fatal error - in all cases close. return false; } + return true; } // Returns false if the connection should be closed. Implements @@ -534,6 +622,108 @@ namespace asynchost return true; } + void complete_drive(std::shared_ptr conn, bool alive) + { + { + std::lock_guard guard(out_mutex); + completed_drives.push_back( + {std::move(conn), alive, std::chrono::steady_clock::now()}); + } + std::lock_guard guard(lifecycle_mutex); + if (wake_handle_initialised) + { + (void)uv_async_send(&wake_handle); + } + } + + void drive_connection(std::shared_ptr conn, DriveInput input) + { + bool alive = true; + + if (conn->ssl == nullptr && conn->accepted_ctx != nullptr) + { + conn->ssl = SSL_new(conn->accepted_ctx.get()); + if (conn->ssl == nullptr || SSL_set_fd(conn->ssl, conn->fd) != 1) + { + if (conn->ssl != nullptr) + { + SSL_free(conn->ssl); + conn->ssl = nullptr; + } + complete_drive(std::move(conn), false); + return; + } + SSL_set_accept_state(conn->ssl); + } + + for (auto& command : input.commands) + { + if (command.close) + { + conn->close_after_flush = true; + } + else + { + conn->outbuf.insert( + conn->outbuf.end(), command.data.begin(), command.data.end()); + } + } + if (input.close_requested) + { + conn->close_after_flush = true; + } + + if (conn->state == Conn::Handshaking) + { + alive = do_handshake(*conn); + } + else + { + if ((input.events & (UV_READABLE | UV_DISCONNECT)) != 0) + { + alive = do_read(*conn); + } + if (alive) + { + alive = do_write(*conn); + } + } + + if ( + alive && conn->close_after_flush && + conn->out_off >= conn->outbuf.size()) + { + alive = false; + } + if (!alive && conn->ssl != nullptr) + { + ERR_clear_error(); + (void)SSL_shutdown(conn->ssl); + SSL_free(conn->ssl); + conn->ssl = nullptr; + } + complete_drive(std::move(conn), alive); + } + + void dispatch_connection(const std::shared_ptr& conn) + { + if (conn->worker_active) + { + return; + } + (void)uv_poll_stop(&conn->poll); + conn->worker_active = true; + DriveInput input; + input.events = std::exchange(conn->pending_events, 0); + input.commands.swap(conn->pending_commands); + input.close_requested = std::exchange(conn->close_requested, false); + conn->tls_tasks->add_action(ccf::tasks::make_basic_action( + [this, conn, input = std::move(input)]() mutable { + drive_connection(conn, std::move(input)); + }, + "OpenSSLServer::drive_connection")); + } + void close_conn(int fd) { auto it = conns.find(fd); @@ -546,16 +736,10 @@ namespace asynchost on_close(it->second->id); } id_to_fd.erase(it->second->id); - auto conn = std::move(it->second); + auto conn = it->second; conns.erase(it); (void)uv_poll_stop(&conn->poll); - SSL* ssl = conn->ssl; - if (ssl != nullptr) - { - SSL_shutdown(ssl); - SSL_free(ssl); - conn->ssl = nullptr; - } + assert(conn->ssl == nullptr); ::close(fd); conn->fd = -1; auto* raw = conn.get(); @@ -563,6 +747,7 @@ namespace asynchost ++pending_uv_closes; uv_close( reinterpret_cast(&raw->poll), on_connection_poll_closed); + finish_stopping_on_loop(); } void accept_all() @@ -572,7 +757,10 @@ namespace asynchost sockaddr_storage peer{}; socklen_t plen = sizeof(peer); const int cfd = accept4( - listen_fd, reinterpret_cast(&peer), &plen, SOCK_NONBLOCK); + listen_fd, + reinterpret_cast(&peer), + &plen, + SOCK_NONBLOCK | SOCK_CLOEXEC); if (cfd < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) @@ -589,11 +777,24 @@ namespace asynchost break; } + if (const auto error = details::configure_tcp_connection(cfd)) + { + LOG_FAIL_FMT( + "setsockopt({}) failed for accepted RPC socket: {}", + error->option, + std::generic_category().message(error->error)); + ::close(cfd); + continue; + } + auto c = std::make_unique(); c->owner = this; c->fd = cfd; c->id = (shared_next_id != nullptr) ? shared_next_id->fetch_add(1) : next_id++; + c->tls_tasks = ccf::tasks::OrderedTasks::create( + ccf::tasks::get_main_job_board(), + "TLS connection " + std::to_string(c->id)); if (plaintext) { @@ -609,33 +810,12 @@ namespace asynchost ::close(cfd); continue; } - SSL* ssl = SSL_new(ctx); - if (ssl == nullptr) - { - ::close(cfd); - continue; - } - SSL_set_mode( - ssl, - SSL_MODE_ENABLE_PARTIAL_WRITE | - SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); - if (SSL_set_fd(ssl, cfd) != 1) - { - SSL_free(ssl); - ::close(cfd); - continue; - } - SSL_set_accept_state(ssl); - c->ssl = ssl; + c->accepted_ctx = ctx; } const int poll_rc = uv_poll_init_socket(loop, &c->poll, cfd); if (poll_rc != 0) { - if (c->ssl != nullptr) - { - SSL_free(c->ssl); - } ::close(cfd); continue; } @@ -644,10 +824,6 @@ namespace asynchost uv_poll_start(&c->poll, UV_READABLE, on_connection_poll); if (start_rc != 0) { - if (c->ssl != nullptr) - { - SSL_free(c->ssl); - } ::close(cfd); ++pending_uv_closes; c->fd = -1; @@ -672,31 +848,10 @@ namespace asynchost { return; } - Conn& c = *it->second; - c.last_active = std::chrono::steady_clock::now(); - bool alive = true; - if (c.state == Conn::Handshaking) - { - alive = do_handshake(c); - } - else - { - if ((events & (UV_READABLE | UV_DISCONNECT)) != 0) - { - alive = do_read(c); - } - if (alive) - { - alive = do_write(c); - } - } - - if (!alive) - { - close_conn(fd); - return; - } - finish_or_close(fd, c); + auto& c = it->second; + c->last_active = std::chrono::steady_clock::now(); + c->pending_events |= events; + dispatch_connection(c); } void mark_loop_thread() @@ -713,7 +868,12 @@ namespace asynchost self->mark_loop_thread(); if (status < 0) { - self->close_conn(conn->fd); + auto it = self->conns.find(conn->fd); + if (it != self->conns.end()) + { + it->second->close_requested = true; + self->dispatch_connection(it->second); + } return; } self->on_conn_event(conn->fd, events); @@ -736,31 +896,28 @@ namespace asynchost } } - // Drain the cross-thread outbound queue on the libuv thread: append queued - // plaintext to each connection and flush (with backpressure), or close. + // Apply worker completions and cross-thread commands on the libuv thread. void drain_pending_out() { std::vector items; + std::vector completions; std::vector> certs; { std::lock_guard g(out_mutex); std::swap(items, pending_out); + std::swap(completions, completed_drives); std::swap(certs, pending_certs); } for (auto& [cert_pem, key_pem] : certs) { - SSL_CTX* nc = build_server_ctx(cert_pem, key_pem); + auto nc = build_server_ctx(cert_pem, key_pem); if (nc == nullptr) { logf("set_server_cert: build context failed"); continue; } - if (ctx != nullptr) - { - SSL_CTX_free(ctx); - } - ctx = nc; + ctx = std::move(nc); } for (auto& item : items) @@ -770,41 +927,54 @@ namespace asynchost { continue; } - const int fd = fit->second; - if (item.close) + auto cit = conns.find(fit->second); + if (cit == conns.end()) { - auto cit = conns.find(fd); - if (cit == conns.end()) - { - continue; - } - Conn& c = *cit->second; - // Graceful close: flush any buffered response before tearing the - // connection down, so a large response queued just before - // close_socket() is not truncated. - c.close_after_flush = true; - if (!do_write(c)) - { - close_conn(fd); - continue; - } - finish_or_close(fd, c); continue; } - auto cit = conns.find(fd); - if (cit == conns.end()) + if (item.close) + { + cit->second->close_requested = true; + } + else + { + cit->second->last_active = std::chrono::steady_clock::now(); + cit->second->pending_commands.push_back(std::move(item)); + } + } + + for (auto& completion : completions) + { + const auto& conn = completion.conn; + auto it = conns.find(conn->fd); + if (it == conns.end() || it->second.get() != conn.get()) { continue; } - Conn& c = *cit->second; - c.outbuf.insert(c.outbuf.end(), item.data.begin(), item.data.end()); - c.last_active = std::chrono::steady_clock::now(); - if (!do_write(c)) + conn->worker_active = false; + conn->last_active = completion.last_active; + if (!completion.alive) + { + close_conn(conn->fd); + } + } + + for (auto& [fd, conn] : conns) + { + if (conn->worker_active) { - close_conn(fd); continue; } - finish_or_close(fd, c); + if ( + conn->close_requested || !conn->pending_commands.empty() || + conn->pending_events != 0) + { + dispatch_connection(conn); + } + else + { + update_interest(*conn); + } } } @@ -819,7 +989,7 @@ namespace asynchost std::vector to_close; for (const auto& [fd, c] : conns) { - if (now - c->last_active > *idle_timeout) + if (!c->worker_active && now - c->last_active > *idle_timeout) { to_close.push_back(fd); } @@ -827,7 +997,12 @@ namespace asynchost for (const int fd : to_close) { logf("closing idle connection on fd %d", fd); - close_conn(fd); + auto it = conns.find(fd); + if (it != conns.end()) + { + it->second->close_requested = true; + dispatch_connection(it->second); + } } } @@ -859,10 +1034,7 @@ namespace asynchost { self->request_stop_on_loop(); } - else - { - self->drain_pending_out(); - } + self->drain_pending_out(); } static void on_idle_timer(uv_timer_t* handle) @@ -918,9 +1090,19 @@ namespace asynchost ::close(listen_fd); listen_fd = -1; } - while (!conns.empty()) + for (auto& [fd, conn] : conns) { - close_conn(conns.begin()->first); + conn->close_requested = true; + dispatch_connection(conn); + } + finish_stopping_on_loop(); + } + + void finish_stopping_on_loop() + { + if (!stopping || !conns.empty()) + { + return; } if (idle_timer_initialised) { @@ -1005,7 +1187,8 @@ namespace asynchost bool bound_ok = false; for (addrinfo* ai = res; ai != nullptr; ai = ai->ai_next) { - listen_fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); + listen_fd = socket( + ai->ai_family, ai->ai_socktype | SOCK_CLOEXEC, ai->ai_protocol); if (listen_fd < 0) { continue; @@ -1172,6 +1355,11 @@ namespace asynchost return; } } + auto task = ccf::tasks::get_main_job_board().get_task(); + if (task != nullptr) + { + ccf::tasks::try_do_task(*task); + } (void)uv_run(loop, UV_RUN_NOWAIT); } } @@ -1181,7 +1369,20 @@ namespace asynchost request_stop_on_loop(); return; } - stopped_cv.wait(lock, [this]() { return stopped; }); + while (!stopped) + { + lock.unlock(); + auto task = ccf::tasks::get_main_job_board().get_task(); + if (task != nullptr) + { + ccf::tasks::try_do_task(*task); + } + lock.lock(); + if (!stopped && task == nullptr) + { + stopped_cv.wait_for(lock, std::chrono::milliseconds(1)); + } + } } // Thread-safe. Queue plaintext to be encrypted and written to `conn_id`. @@ -1218,37 +1419,6 @@ namespace asynchost wake(); } - // Peer certificate (DER) for `conn_id`, or empty. MUST be called on the - // loop thread (e.g. synchronously from within the OnData callback). - std::vector get_peer_cert(::tcp::ConnID conn_id) - { - auto fit = id_to_fd.find(conn_id); - if (fit == id_to_fd.end()) - { - return {}; - } - auto cit = conns.find(fit->second); - if (cit == conns.end() || cit->second->ssl == nullptr) - { - return {}; - } - X509* cert = SSL_get_peer_certificate(cit->second->ssl); - if (cert == nullptr) - { - return {}; - } - std::vector der; - const int len = i2d_X509(cert, nullptr); - if (len > 0) - { - der.resize(static_cast(len)); - unsigned char* p = der.data(); - i2d_X509(cert, &p); - } - X509_free(cert); - return der; - } - private: void cleanup() { @@ -1257,11 +1427,7 @@ namespace asynchost ::close(listen_fd); listen_fd = -1; } - if (ctx != nullptr) - { - SSL_CTX_free(ctx); - ctx = nullptr; - } + ctx.reset(); } }; } diff --git a/src/host/tls/openssl_session_manager.h b/src/host/tls/openssl_session_manager.h index 1b1606a99918..5630f41dd42d 100644 --- a/src/host/tls/openssl_session_manager.h +++ b/src/host/tls/openssl_session_manager.h @@ -13,11 +13,11 @@ // "make an HTTPServerSession for this interface"). Sessions are created lazily // on first inbound data and removed on close. // -// Threading: OpenSSLServer invokes on_data/on_close on its loop thread. The -// session may then process on OrderedTasks workers and reply via write_outbound -// from those threads, which forwards to OpenSSLServer's thread-safe -// send/close_connection. Every public method is therefore safe to call from any -// thread, and the sessions map is guarded by a mutex. +// Threading: OpenSSLServer invokes on_data on its TLS OrderedTasks worker and +// on_close on its loop thread. The session may dispatch again to its own +// OrderedTasks and reply via write_outbound from any worker. Every public +// method is therefore safe to call from any thread, and the sessions map is +// guarded by a mutex. #include "ccf/node/session.h" #include "enclave/session_writer.h" @@ -58,7 +58,10 @@ namespace asynchost std::mutex sessions_mutex; std::unordered_map<::tcp::ConnID, std::shared_ptr> sessions; - void on_data(::tcp::ConnID conn_id, std::vector data) + void on_data( + ::tcp::ConnID conn_id, + std::vector data, + const std::vector& peer_cert) { std::shared_ptr session; { @@ -66,11 +69,7 @@ namespace asynchost auto it = sessions.find(conn_id); if (it == sessions.end()) { - // Lazily create the session for a newly accepted connection. The - // peer certificate is fetched here (on the loop thread) from the - // handshaken connection. - auto peer_cert = server->get_peer_cert(conn_id); - session = factory(conn_id, *this, std::move(peer_cert)); + session = factory(conn_id, *this, peer_cert); if (session == nullptr) { // Factory refused (e.g. hard session cap) - tear the connection @@ -137,8 +136,11 @@ namespace asynchost key_pem, host, port, - [this](::tcp::ConnID id, std::vector data) { - on_data(id, std::move(data)); + [this]( + ::tcp::ConnID id, + std::vector data, + const std::vector& peer_cert) { + on_data(id, std::move(data), peer_cert); }, [this](::tcp::ConnID id) { on_close(id); }, alpn, From 85b11a40a9817ed7b9d0be59955019c8911ff93e Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Thu, 6 Aug 2026 14:22:08 +0000 Subject: [PATCH 45/59] Review tidying --- CHANGELOG.md | 8 + doc/architecture/tls_internals.rst | 23 +- include/ccf/node/session.h | 5 +- python/pyproject.toml | 2 +- src/enclave/session.h | 22 +- src/enclave/session_writer.h | 9 +- src/host/datagram_echo_session.h | 42 --- src/host/datagram_server.h | 104 +++++- src/host/rpc_connection_manager.h | 325 +++++++++++++---- src/host/test/openssl_server_test.cpp | 461 ++++++++++++++++++++++++- src/host/tls/openssl_server.h | 298 ++++++++++++---- src/host/tls/openssl_session_manager.h | 129 +++---- 12 files changed, 1147 insertions(+), 281 deletions(-) delete mode 100644 src/host/datagram_echo_session.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bf1b53cfdfc..0e6b9adb53ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [7.0.12] + +[7.0.12]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.12 + +### Changed + +- TLS is now terminated by OpenSSL directly on the socket, rather than being relayed over the ringbuffer and decrypted through a memory BIO. The session interfaces in `include/ccf/node/session.h` and `include/ccf/node/rpc/custom_protocol_subsystem_interface.h` have changed shape accordingly, and custom protocols are no longer supported on UDP interfaces (#8117). + ## [7.0.11] [7.0.11]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.11 diff --git a/doc/architecture/tls_internals.rst b/doc/architecture/tls_internals.rst index 4b01b4550798..214361bdfa56 100644 --- a/doc/architecture/tls_internals.rst +++ b/doc/architecture/tls_internals.rst @@ -6,7 +6,7 @@ Overview In CCF, the :term:`TLS` layer is implemented using OpenSSL (3.3 or later). -TLS is terminated in the **connection layer**: OpenSSL owns the socket file descriptor directly, while the existing host ``libuv`` loop drives the non-blocking handshake, reads, writes, graceful close and idle connection cleanup. Everything above the connection layer, including HTTP parsing and endpoint dispatch, only ever sees plaintext. +TLS is terminated in the **connection layer**: OpenSSL owns the socket file descriptor directly. The existing host ``libuv`` loop reports file descriptor readiness, and a per-connection worker drives the non-blocking handshake, reads, writes and graceful close. Everything above the connection layer, including HTTP parsing and endpoint dispatch, only ever sees plaintext. This document describes the connection layer and its interface to the session layer above it. @@ -22,7 +22,7 @@ A single RPC interface is served by these pieces: Because TLS lives below the session, there is no separate "encrypted session" type. The difference between a TLS interface and an ``UNSECURED`` one is a flag on the connection layer, not a different session class. -Note that the inbound and outbound paths are not symmetric. Inbound plaintext is pushed straight into the session, but a session cannot touch the socket: it hands bytes to a ``SessionWriter``, which queues them for the loop thread. This is what allows sessions to run on worker threads while all socket I/O stays on one thread per interface. +Note that the inbound and outbound paths are not symmetric. Inbound plaintext is pushed straight into the session, but a session cannot touch the socket: it hands bytes to a ``SessionWriter``, which queues them for the owning connection. This is what allows sessions to run on any worker thread while each socket is still only ever touched by one thread at a time. Listening ~~~~~~~~~ @@ -67,16 +67,18 @@ The server calls ``SSL_CTX_set_verify`` with ``SSL_VERIFY_PEER`` and an accept-a Reading ~~~~~~~ -When ``libuv`` reports a connection readable, the loop calls ``SSL_read`` repeatedly until it reports ``SSL_ERROR_WANT_READ``. Every chunk of decrypted bytes is passed to the ``OnData`` callback as it is produced, so a single readable event may yield several callbacks. +When ``libuv`` reports a connection readable, the loop hands the connection to its worker (see `Threading`_), which calls ``SSL_read`` repeatedly until it reports ``SSL_ERROR_WANT_READ``. Every chunk of decrypted bytes is passed to the ``OnData`` callback as it is produced, so a single readable event may yield several callbacks. -``OpenSSLSessionManager`` receives those bytes, finds or creates the session for that connection, and calls ``handle_incoming_data``. The session dispatches the actual parsing to a worker via ``OrderedTasks``, so the loop thread does not block on application work. +A single pass is capped at a fixed number of bytes so that one busy connection cannot monopolise its worker. Reaching that cap does not end the read: OpenSSL may still be holding buffered records, in which case the file descriptor is *not* readable and waiting for a further ``uv_poll`` event would stall the connection. The worker therefore reports ``SSL_pending()`` back to the loop, which immediately schedules another pass instead of re-arming ``UV_READABLE``. + +``OpenSSLSessionManager`` receives those bytes, finds or creates the session for that connection, and calls ``handle_incoming_data``. The session dispatches the actual parsing to its own ``OrderedTasks``, so neither the loop thread nor the connection worker blocks on application work. ``SSL_ERROR_WANT_WRITE`` on a read is not an error: a TLS 1.3 key update needs the socket to become writable, so the connection is left open with ``UV_WRITABLE`` armed. Any other result, whether a clean ``SSL_ERROR_ZERO_RETURN``, an unclean EOF, or a fatal error, closes the connection. Writing and backpressure ~~~~~~~~~~~~~~~~~~~~~~~~ -``send()`` is thread-safe. It appends the plaintext to a queue guarded by a mutex and signals an ``eventfd``, waking the loop thread, which drains the queue and attempts the write. +``send()`` is thread-safe. It appends the plaintext to a queue guarded by a mutex and signals a ``uv_async_t``, waking the loop thread, which moves the bytes onto the target connection and schedules a worker pass to attempt the write. Writes are where genuine backpressure appears. ``SSL_write`` on a non-blocking socket may report ``SSL_ERROR_WANT_WRITE`` after consuming only part of the buffer. The remainder stays buffered against the connection, ``UV_WRITABLE`` is armed, and the write resumes when the socket next becomes writable. Because the socket is owned by OpenSSL and is non-blocking, this reflects the real state of the :term:`TCP` send buffer rather than an internal approximation. @@ -92,11 +94,18 @@ Idle connections are closed separately. If an idle timeout is configured, a repe Threading ~~~~~~~~~ -All socket and ``SSL`` operations happen on the existing host ``libuv`` thread; an ``OpenSSLServer`` does not create a thread or private reactor. Work reaches the loop in one of two ways: file descriptor readiness reported through ``uv_poll_t``, or a cross-thread request (a queued write, a close, or a certificate update) posted to a queue and signalled through ``uv_async_t``. +``OpenSSLServer`` does not create a thread or a private reactor. It splits its work between the existing host ``libuv`` thread and the general task system, along a single dividing line: + +- The **loop thread** owns everything that touches the server's own state: accepting connections, the connection and identifier maps, ``uv_poll_t`` registration, building and replacing the ``SSL_CTX``, sweeping idle connections, and finally closing file descriptors. It performs no ``SSL`` operations. +- Each connection owns an :ccf_repo:`OrderedTasks ` queue, and every ``SSL`` operation for that connection - ``SSL_new``, ``SSL_accept``, ``SSL_read``, ``SSL_write``, ``SSL_shutdown``, ``SSL_free`` - runs there, as does the ``OnData`` callback. This keeps handshakes and bulk encryption off the loop thread. + +Work reaches the loop in one of two ways: file descriptor readiness reported through ``uv_poll_t``, or a cross-thread request (a queued write, a close, or a certificate update) posted to a mutex-guarded queue and signalled through ``uv_async_t``. Either way the loop only ever *schedules* a pass over the connection; it never performs the I/O itself. + +A connection is serviced by at most one worker pass at a time. Before dispatching, the loop calls ``uv_poll_stop`` for that connection, so no second pass can be scheduled while one is running. Readiness events and cross-thread commands that arrive meanwhile accumulate in loop-thread-only fields and are handed to the next pass. When a pass finishes it posts its result back to the loop, which re-arms polling, schedules another pass if more work accumulated, or tears the connection down. .. warning:: - OpenSSL's error queue is **thread-local**, and one loop thread services every connection on an interface. ``SSL_get_error`` consults that queue, so an error left behind by one connection can be misattributed to the next operation on a completely different connection. + OpenSSL's error queue is **thread-local**, and successive passes over the same connection may run on different worker threads. ``SSL_get_error`` consults that queue, so an error left behind by unrelated work on the same thread can be misattributed to this connection. Every ``SSL_accept``, ``SSL_read`` and ``SSL_write`` is therefore preceded by ``ERR_clear_error()``. Omitting this causes healthy keep-alive connections to be closed spuriously, because a stale error (for example a previous client disconnecting without ``close_notify``) is read as a fatal error on an unrelated connection. diff --git a/include/ccf/node/session.h b/include/ccf/node/session.h index 75d7ab1b05de..5df3b1b11c4a 100644 --- a/include/ccf/node/session.h +++ b/include/ccf/node/session.h @@ -15,9 +15,10 @@ namespace ccf // Inbound bytes for this session. `addr` is the source address of the // datagram for connectionless (UDP) transports, and is unused (default) for - // stream (TCP) transports. + // stream (TCP) transports. sockaddr_storage rather than sockaddr, because + // sockaddr is too small to hold an IPv6 address. virtual void handle_incoming_data( - std::span data, sockaddr addr = {}) = 0; + std::span data, sockaddr_storage addr = {}) = 0; virtual void send_data(std::vector&& data) = 0; virtual void close_session() = 0; }; diff --git a/python/pyproject.toml b/python/pyproject.toml index a8aa1716f792..3a07c6d4a9f8 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ccf" -version = "7.0.11" +version = "7.0.12" authors = [ { name="CCF Team", email="CCF-Sec@microsoft.com" }, ] diff --git a/src/enclave/session.h b/src/enclave/session.h index ff4a8d0abab3..391106cade71 100644 --- a/src/enclave/session.h +++ b/src/enclave/session.h @@ -88,7 +88,7 @@ namespace ccf // Implement Session::handle_incoming_data by dispatching a thread message // that eventually invokes the virtual handle_incoming_data_thread() void handle_incoming_data( - std::span data, sockaddr /*addr*/) override + std::span data, sockaddr_storage /*addr*/) override { task_scheduler->add_action( std::make_shared(data, shared_from_this())); @@ -132,6 +132,16 @@ namespace ccf ::tcp::ConnID session_id; ccf::SessionWriter& session_writer; std::vector peer_cert_; + // Set once parse() has reported that it will process no more data (a parse + // error, an oversized request, a protocol-level shutdown). The protocol + // session has already emitted its error response and closed the session by + // that point, but the transport may still deliver bytes which were already + // in flight. Feeding those to a parser which has errored produces + // duplicate responses on a socket which is closing, so drop them instead. + // + // Only touched from handle_incoming_data_thread(), which runs on this + // session's own OrderedTasks and is therefore serialised. + bool parsing_finished = false; PlaintextSession( ::tcp::ConnID session_id_, @@ -156,7 +166,15 @@ namespace ccf void handle_incoming_data_thread(std::vector&& data) override { - parse({data.data(), data.size()}); + if (parsing_finished) + { + return; + } + + if (!parse({data.data(), data.size()})) + { + parsing_finished = true; + } } void close_session_thread() override diff --git a/src/enclave/session_writer.h b/src/enclave/session_writer.h index 2075a4d65aa3..ccee77617ee1 100644 --- a/src/enclave/session_writer.h +++ b/src/enclave/session_writer.h @@ -25,8 +25,9 @@ namespace ccf // Queue bytes to be written to the socket associated with `id`. For // datagram protocols, `addr` identifies the destination peer; it is ignored - // for stream (TCP) connections. The bytes are copied, so the caller's - // buffer can be reused immediately. + // for stream (TCP) connections. sockaddr_storage rather than sockaddr, + // because sockaddr is too small to hold an IPv6 address. The bytes are + // copied, so the caller's buffer can be reused immediately. // // Fire-and-forget: there is currently no backpressure signal. // @@ -35,7 +36,9 @@ namespace ccf // (tracking per-connection queued bytes) and return a writable/would-block // status here. virtual void write_outbound( - ::tcp::ConnID id, std::span data, sockaddr addr = {}) = 0; + ::tcp::ConnID id, + std::span data, + sockaddr_storage addr = {}) = 0; // Tear down the connection: stop the underlying socket and drop the // session. diff --git a/src/host/datagram_echo_session.h b/src/host/datagram_echo_session.h deleted file mode 100644 index f729ea77b804..000000000000 --- a/src/host/datagram_echo_session.h +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the Apache 2.0 License. -#pragma once - -#include "ccf/node/session.h" -#include "enclave/session_writer.h" - -#include -#include - -namespace ccf -{ - class DatagramEchoSession : public Session - { - private: - ::tcp::ConnID session_id; - ccf::SessionWriter& writer; - - public: - DatagramEchoSession( - ::tcp::ConnID session_id_, ccf::SessionWriter& writer_) : - session_id(session_id_), - writer(writer_) - {} - - void handle_incoming_data( - std::span data, sockaddr addr = {}) override - { - writer.write_outbound(session_id, data, addr); - } - - void send_data(std::vector&& data) override - { - writer.write_outbound(session_id, {data.data(), data.size()}); - } - - void close_session() override - { - writer.close_socket(session_id); - } - }; -} \ No newline at end of file diff --git a/src/host/datagram_server.h b/src/host/datagram_server.h index b5cf77bd8fb8..4b6b92521768 100644 --- a/src/host/datagram_server.h +++ b/src/host/datagram_server.h @@ -54,6 +54,12 @@ namespace asynchost private: static constexpr size_t max_datagram = 65535; + // Datagrams handled per readable event. The handler runs inline on the + // loop thread, so an unbounded drain would let a UDP flood starve every + // other handle on the loop. The socket stays level-triggered, so any + // remainder is picked up on the next iteration. This replaces the read + // quota the previous ringbuffer-based UDP transport applied. + static constexpr size_t max_datagrams_per_event = 64; uv_loop_t* loop = nullptr; int sock = -1; @@ -69,6 +75,11 @@ namespace asynchost bool stopping = false; bool shutdown_started = false; bool stopped = false; + // Tracked separately from `started`, because start() can throw part way + // through initialisation and the handles which were initialised must + // still be closed. + bool socket_poll_initialised = false; + bool stop_handle_initialised = false; size_t pending_uv_closes = 0; std::thread::id initialising_thread_id; bool loop_thread_seen = false; @@ -92,7 +103,8 @@ namespace asynchost void drain() { - for (;;) + size_t handled = 0; + while (handled < max_datagrams_per_event) { uint8_t buf[max_datagram]; sockaddr_storage peer{}; @@ -112,11 +124,14 @@ namespace asynchost } if (errno == EINTR) { + // Interrupted before receiving anything, so this does not count + // against the quota. continue; } break; } + ++handled; if (on_datagram) { // === QUIC EXTENSION POINT === @@ -164,6 +179,8 @@ namespace asynchost void stop_on_loop() { + bool close_socket_poll = false; + bool close_stop_handle = false; { std::lock_guard guard(lifecycle_mutex); if (shutdown_started) @@ -172,17 +189,48 @@ namespace asynchost } stopping = true; shutdown_started = true; - pending_uv_closes = 2; + + // Only the handles start() actually initialised. Counting blindly + // would leave pending_uv_closes permanently non-zero (so stop() would + // never complete) if start() threw part way through. + close_socket_poll = socket_poll_initialised; + close_stop_handle = stop_handle_initialised; + socket_poll_initialised = false; + stop_handle_initialised = false; + pending_uv_closes = static_cast(close_socket_poll) + + static_cast(close_stop_handle); + + if (close_socket_poll) + { + (void)uv_poll_stop(&socket_poll); + } + // Closed under the lock so that a concurrent send_to() cannot be left + // holding a descriptor which has been closed (and possibly reused) + // underneath it. + if (sock >= 0) + { + ::close(sock); + sock = -1; + } + + if (pending_uv_closes == 0) + { + stopped = true; + stopped_cv.notify_all(); + return; + } } - (void)uv_poll_stop(&socket_poll); - if (sock >= 0) + if (close_socket_poll) { - ::close(sock); - sock = -1; + uv_close( + reinterpret_cast(&socket_poll), on_handle_closed); + } + if (close_stop_handle) + { + uv_close( + reinterpret_cast(&stop_handle), on_handle_closed); } - uv_close(reinterpret_cast(&socket_poll), on_handle_closed); - uv_close(reinterpret_cast(&stop_handle), on_handle_closed); } public: @@ -272,32 +320,42 @@ namespace asynchost { return; } + + // Marked started before any handle is initialised, so that a failure + // part way through still leaves a server whose destructor closes and + // drains the handles which were initialised (stop() is a no-op unless + // `started` is set). + started = true; + stopping = false; + shutdown_started = false; + stopped = false; + initialising_thread_id = std::this_thread::get_id(); + loop_thread_seen = false; + int rc = uv_poll_init_socket(loop, &socket_poll, sock); if (rc != 0) { throw std::runtime_error( std::string("uv_poll_init_socket(udp) failed: ") + uv_strerror(rc)); } + socket_poll_initialised = true; socket_poll.data = this; + rc = uv_async_init(loop, &stop_handle, on_stop); if (rc != 0) { throw std::runtime_error( std::string("uv_async_init(udp) failed: ") + uv_strerror(rc)); } + stop_handle_initialised = true; stop_handle.data = this; + rc = uv_poll_start(&socket_poll, UV_READABLE, on_socket_poll); if (rc != 0) { throw std::runtime_error( std::string("uv_poll_start(udp) failed: ") + uv_strerror(rc)); } - started = true; - stopping = false; - shutdown_started = false; - stopped = false; - initialising_thread_id = std::this_thread::get_id(); - loop_thread_seen = false; } void stop() @@ -310,7 +368,10 @@ namespace asynchost if (!stopping) { stopping = true; - (void)uv_async_send(&stop_handle); + if (stop_handle_initialised) + { + (void)uv_async_send(&stop_handle); + } } const bool loop_not_started_here = !loop_thread_seen && std::this_thread::get_id() == initialising_thread_id; @@ -328,6 +389,10 @@ namespace asynchost } } (void)uv_run(loop, UV_RUN_NOWAIT); + // uv_run(UV_RUN_NOWAIT) returns immediately, so without this the + // loop would spin at 100% CPU while waiting for the outstanding uv + // close callbacks. + std::this_thread::sleep_for(std::chrono::milliseconds(1)); } } if (std::this_thread::get_id() == loop_thread_id) @@ -344,12 +409,19 @@ namespace asynchost return bound_port; } + // Thread-safe: replies are sent from session workers, while the socket may + // be closed concurrently by shutdown on the loop thread. [[nodiscard]] bool send_to( const sockaddr_storage& peer, socklen_t peerlen, const uint8_t* data, - size_t len) const + size_t len) { + std::lock_guard guard(lifecycle_mutex); + if (sock < 0) + { + return false; + } const auto rc = ::sendto( sock, data, len, 0, reinterpret_cast(&peer), peerlen); return rc >= 0; diff --git a/src/host/rpc_connection_manager.h b/src/host/rpc_connection_manager.h index cb5f8b71e39b..0bfd8a3c8376 100644 --- a/src/host/rpc_connection_manager.h +++ b/src/host/rpc_connection_manager.h @@ -22,7 +22,6 @@ #include "enclave/abstract_rpc_sessions.h" #include "enclave/no_more_sessions.h" #include "enclave/rpc_map.h" -#include "host/datagram_echo_session.h" #include "host/datagram_server.h" #include "host/tls/openssl_session_manager.h" #include "http/error_reporter.h" @@ -51,6 +50,8 @@ namespace ccf static constexpr size_t ocm_max_open_sessions_hard_default = 1010; static const ccf::Endorsement ocm_endorsement_default = { ccf::Authority::SERVICE}; + // How often idle UDP sessions are swept, mirroring the TCP idle sweep. + static constexpr auto udp_idle_sweep_interval = std::chrono::seconds(1); class RPCConnectionManager : public std::enable_shared_from_this, @@ -73,8 +74,11 @@ namespace ccf std::atomic err_payload_too_large{0}; std::atomic err_header_too_large{0}; - // The transport for this interface (created on listen()). - std::unique_ptr bridge; + // The transport for this interface (created on listen()). Held by + // shared_ptr so that callers which snapshot it under interfaces_mutex + // and then use it without the lock (stop(), reply_async()) cannot race + // with it being replaced or destroyed. + std::shared_ptr bridge; }; class DatagramSessionWriter : public ccf::SessionWriter @@ -94,7 +98,7 @@ namespace ccf void write_outbound( ::tcp::ConnID id, std::span data, - sockaddr /*addr*/ = {}) override + sockaddr_storage /*addr*/ = {}) override { write(id, data); } @@ -105,18 +109,41 @@ namespace ccf } }; + struct UdpSession + { + std::shared_ptr session; + ::tcp::ConnID conn_id = 0; + std::chrono::steady_clock::time_point last_active = + std::chrono::steady_clock::now(); + }; + struct DatagramInterface { - std::unique_ptr server; + std::shared_ptr server; std::unique_ptr writer; - std::map> sessions_by_peer; + std::map sessions_by_peer; std::map<::tcp::ConnID, std::string> peer_by_id; + std::chrono::steady_clock::time_point last_idle_sweep = + std::chrono::steady_clock::now(); }; std::shared_ptr rpc_map; + + // Subsystems are installed by NodeState during node creation, which now + // happens after the interfaces are already listening, so they can be + // assigned while sessions are being created on TLS worker threads. Guarded + // by their own mutex rather than interfaces_mutex, so that reading them on + // the session-creation path does not contend with the interface map. + std::mutex subsystems_mutex; std::shared_ptr custom_protocol_subsystem; std::shared_ptr commit_callbacks_subsystem; + // Set at the start of stop(). Once set no further sessions are created: + // the transports are being torn down, and when stop() is invoked from the + // destructor shared_from_this() (used by error_reporter()) would throw + // std::bad_weak_ptr because the control block has already expired. + std::atomic stopping{false}; + std::mutex interfaces_mutex; std::map> interfaces; // UDP interface state, keyed by interface name. UDP "QUIC" interfaces use @@ -143,6 +170,18 @@ namespace ccf return shared_from_this(); } + std::shared_ptr get_custom_protocol_subsystem() + { + std::lock_guard guard(subsystems_mutex); + return custom_protocol_subsystem; + } + + std::shared_ptr get_commit_callbacks_subsystem() + { + std::lock_guard guard(subsystems_mutex); + return commit_callbacks_subsystem; + } + static std::string peer_key(const sockaddr_storage& peer, socklen_t peerlen) { return { @@ -187,53 +226,102 @@ namespace ccf } } - // Build the protocol session for a connection on `li`, applying caps. - // Returns nullptr to refuse (hard cap). Runs on the interface's loop - // thread. - std::shared_ptr make_session( - ListenInterface* li, - ::tcp::ConnID conn_id, - ccf::SessionWriter& writer, - std::vector peer_cert) + // Admission control, run by the transport for every accepted connection + // before any TLS state exists. + // + // Reserving here rather than when the first request arrives is what makes + // max_open_sessions_hard a bound on *connections*: a client which + // completes the TCP and TLS handshakes and then sends nothing still holds + // a file descriptor and TLS state on the node, and previously went + // entirely uncounted. + // + // Returns nullopt to refuse, otherwise whether the connection was admitted + // above the soft limit (and so should be answered with a 503). Runs on the + // interface's libuv loop thread. + std::optional admit_connection( + ListenInterface* li, ::tcp::ConnID conn_id) { + if (stopping.load()) + { + return std::nullopt; + } + const size_t open = li->open_sessions.fetch_add(1); if (open >= li->max_open_sessions_hard) { decrement_interface_sessions(li); LOG_INFO_FMT( - "Refusing session {} on interface {} - {} open, hard limit {}", + "Refusing connection {} on interface {} - {} open, hard limit {}", conn_id, li->name, open, li->max_open_sessions_hard); - return nullptr; + return std::nullopt; } - const size_t now_open = open + 1; - increment_interface_peak(li, now_open); + increment_interface_peak(li, open + 1); increment_active_sessions(); - if (open >= li->max_open_sessions_soft) + const bool soft_limited = open >= li->max_open_sessions_soft; + if (soft_limited) { LOG_INFO_FMT( - "Soft-refusing session {} (503) on interface {} - {} open, soft " + "Soft-refusing connection {} (503) on interface {} - {} open, soft " "limit {}", conn_id, li->name, open, li->max_open_sessions_soft); - return make_capped_session(li, conn_id, writer, std::move(peer_cert)); + } + return soft_limited; + } + + // Release the reservation taken by admit_connection. Invoked exactly once + // per admitted connection, when the transport has torn it down. + void release_connection(ListenInterface* li) + { + decrement_interface_sessions(li); + decrement_active_sessions(); + } + + // Build the protocol session for a connection on `li`, which has already + // been admitted (see admit_connection). Returns nullptr to close the + // connection. Runs on the connection's TLS worker (invoked from + // OpenSSLServer's OnData), not on the libuv loop thread, so it must be + // safe to call concurrently for other connections. + std::shared_ptr make_session( + ListenInterface* li, + ::tcp::ConnID conn_id, + ccf::SessionWriter& writer, + std::vector peer_cert, + bool soft_limited) + { + if (stopping.load()) + { + // Shutting down - do not build new sessions, and in particular do not + // call error_reporter() (see `stopping`). + return nullptr; } try { + if (soft_limited) + { + return make_capped_session(li, conn_id, writer, std::move(peer_cert)); + } return make_server_session(li, conn_id, writer, std::move(peer_cert)); } - catch (...) + catch (const std::exception& e) { - decrement_interface_sessions(li); - decrement_active_sessions(); - throw; + // Runs on a worker, so an escaping exception would be fatal. Closing + // the connection is the only useful response to being unable to build + // a session for it. + LOG_FAIL_FMT( + "Failed to create session {} on interface {}: {}", + conn_id, + li->name, + e.what()); + return nullptr; } } @@ -264,12 +352,12 @@ namespace ccf std::move(peer_cert), li->http_configuration, error_reporter(), - commit_callbacks_subsystem); + get_commit_callbacks_subsystem()); } - if (custom_protocol_subsystem != nullptr) + auto cpss = get_custom_protocol_subsystem(); + if (cpss != nullptr) { - return custom_protocol_subsystem->create_session( - li->app_protocol, conn_id, writer); + return cpss->create_session(li->app_protocol, conn_id, writer); } throw std::runtime_error(fmt::format( "Unknown application protocol '{}' and custom protocol subsystem " @@ -302,7 +390,7 @@ namespace ccf std::move(peer_cert), li->http_configuration, error_reporter(), - commit_callbacks_subsystem); + get_commit_callbacks_subsystem()); } void send_udp_reply( @@ -354,6 +442,49 @@ namespace ccf decrement_active_sessions(); } + // Drop UDP sessions which have seen no traffic for + // idle_connection_timeout. Unlike TCP there is no connection teardown to + // react to, and source addresses are trivially spoofable, so without this + // a UDP interface accumulates one session per distinct source address + // until it reaches max_open_sessions_hard and stops responding entirely. + // + // Sweeping here (on datagram arrival, rate-limited) rather than from a + // timer keeps the work where the growth happens and avoids a second timer + // per interface. Caller must hold interfaces_mutex. + void sweep_idle_udp_sessions(ListenInterface* li, DatagramInterface* udp) + { + if (!idle_connection_timeout.has_value()) + { + return; + } + + const auto now = std::chrono::steady_clock::now(); + if (now - udp->last_idle_sweep < udp_idle_sweep_interval) + { + return; + } + udp->last_idle_sweep = now; + + for (auto it = udp->sessions_by_peer.begin(); + it != udp->sessions_by_peer.end();) + { + if (now - it->second.last_active <= *idle_connection_timeout) + { + ++it; + continue; + } + + LOG_DEBUG_FMT( + "Closing idle UDP session {} on interface {}", + it->second.conn_id, + li->name); + udp->peer_by_id.erase(it->second.conn_id); + it = udp->sessions_by_peer.erase(it); + decrement_interface_sessions(li); + decrement_active_sessions(); + } + } + std::shared_ptr get_or_create_udp_session( ListenInterface* li, DatagramInterface* udp, @@ -362,14 +493,23 @@ namespace ccf socklen_t peerlen) { std::lock_guard guard(interfaces_mutex); + sweep_idle_udp_sessions(li, udp); + const auto key = peer_key(peer, peerlen); auto sit = udp->sessions_by_peer.find(key); if (sit != udp->sessions_by_peer.end()) { - return sit->second; + sit->second.last_active = std::chrono::steady_clock::now(); + return sit->second.session; + } + + if (stopping.load()) + { + return nullptr; } - if (li->app_protocol != "QUIC" && custom_protocol_subsystem == nullptr) + auto cpss = get_custom_protocol_subsystem(); + if (cpss == nullptr) { LOG_DEBUG_FMT( "Unknown UDP protocol '{}' and custom protocol subsystem missing", @@ -395,23 +535,19 @@ namespace ccf const auto conn_id = static_cast<::tcp::ConnID>(shared_conn_id.fetch_add(1)); std::shared_ptr session; - if (li->app_protocol == "QUIC") + try { - session = std::make_shared(conn_id, writer); + session = cpss->create_session(li->app_protocol, conn_id, writer); } - else + catch (const std::exception& e) { - try - { - session = custom_protocol_subsystem->create_session( - li->app_protocol, conn_id, writer); - } - catch (...) - { - decrement_interface_sessions(li); - decrement_active_sessions(); - throw; - } + decrement_interface_sessions(li); + decrement_active_sessions(); + LOG_FAIL_FMT( + "Failed to create UDP session on interface {}: {}", + li->name, + e.what()); + return nullptr; } if (session == nullptr) @@ -422,7 +558,8 @@ namespace ccf } udp->peer_by_id.emplace(conn_id, key); - udp->sessions_by_peer.emplace(key, session); + udp->sessions_by_peer.emplace( + key, UdpSession{session, conn_id, std::chrono::steady_clock::now()}); return session; } @@ -438,17 +575,42 @@ namespace ccf void stop() { - std::lock_guard guard(interfaces_mutex); - for (auto& [name, li] : interfaces) + // Refuse new sessions before tearing anything down. This is what makes + // it safe for the destructor to call stop(): make_session() will no + // longer reach error_reporter()/shared_from_this(). + stopping.store(true); + + // Snapshot the transports under the lock, but stop them without it. + // Stopping blocks until the libuv loop has torn down every connection, + // and the loop thread itself needs interfaces_mutex (for example in + // get_or_create_udp_session(), send_udp_reply() or a drained task + // calling report_parsing_error()), so holding it across the stop would + // deadlock. The snapshots are shared_ptr, so the transports stay alive + // for the duration even if the maps change. + std::vector> bridges; + std::vector> datagram_servers; { - if (li->bridge != nullptr) + std::lock_guard guard(interfaces_mutex); + for (auto& [name, li] : interfaces) { - li->bridge->stop(); + if (li->bridge != nullptr) + { + bridges.push_back(li->bridge); + } } + for (auto& [name, interface] : udp_interfaces) + { + datagram_servers.push_back(interface->server); + } + } + + for (const auto& bridge : bridges) + { + bridge->stop(); } - for (auto& [name, interface] : udp_interfaces) + for (const auto& server : datagram_servers) { - interface->server->stop(); + server->stop(); } } @@ -484,19 +646,21 @@ namespace ccf } } - auto factory = - [this, li]( - ::tcp::ConnID cid, ccf::SessionWriter& w, std::vector pc) { - return make_session(li, cid, w, std::move(pc)); - }; - auto on_closed = [this, li](::tcp::ConnID) { - decrement_active_sessions(); - decrement_interface_sessions(li); + auto on_accept = [this, li](::tcp::ConnID cid) { + return admit_connection(li, cid); + }; + auto factory = [this, li]( + ::tcp::ConnID cid, + ccf::SessionWriter& w, + std::vector pc, + bool soft_limited) { + return make_session(li, cid, w, std::move(pc), soft_limited); }; + auto on_closed = [this, li](::tcp::ConnID) { release_connection(li); }; const auto port_num = port.empty() ? 0 : static_cast(std::stoi(port)); - li->bridge = std::make_unique( + li->bridge = std::make_shared( cert_pem, key_pem, host, @@ -507,13 +671,14 @@ namespace ccf false, &shared_conn_id, on_closed, - idle_connection_timeout); + idle_connection_timeout, + on_accept); li->bridge->start(); return li->bridge->port(); } // Bind and start a UDP listener for `name` (interfaces with protocol - // "udp"). Incoming datagrams are routed to a per-peer session. + // "udp"). // // === QUIC EXTENSION POINT === // A real QUIC interface would, instead of echoing, hand each datagram to an @@ -542,7 +707,7 @@ namespace ccf }); auto* writer = udp->writer.get(); - udp->server = std::make_unique( + udp->server = std::make_shared( host, port.empty() ? 0 : static_cast(std::stoi(port)), [this, li, udp_ptr, writer]( @@ -550,14 +715,32 @@ namespace ccf size_t len, const sockaddr_storage& peer, socklen_t peerlen) { + if (li->app_protocol == "QUIC") + { + // Placeholder behaviour until OpenSSL-native QUIC: echo the + // datagram straight back. + // + // Deliberately stateless. UDP has no connection to close, and + // source addresses are trivially spoofable, so retaining anything + // per peer here would let an attacker grow the session map and + // consume the interface's session capacity permanently with a + // handful of forged packets. The echo needs no state, so it keeps + // none. + if (!udp_ptr->server->send_to(peer, peerlen, data, len)) + { + LOG_DEBUG_FMT( + "Failed to echo UDP datagram on interface {}", li->name); + } + return; + } + auto session = get_or_create_udp_session(li, udp_ptr, *writer, peer, peerlen); if (session == nullptr) { return; } - session->handle_incoming_data( - {data, len}, *reinterpret_cast(&peer)); + session->handle_incoming_data({data, len}, peer); }); udp->server->start(); const uint16_t bound = udp->server->port(); @@ -572,19 +755,19 @@ namespace ccf bool terminate_after_reply, std::vector&& data) override { - std::vector bridges; + std::vector> bridges; { std::lock_guard guard(interfaces_mutex); for (auto& [name, li] : interfaces) { if (li->bridge != nullptr) { - bridges.push_back(li->bridge.get()); + bridges.push_back(li->bridge); } } } - for (auto* bridge : bridges) + for (const auto& bridge : bridges) { auto session = bridge->get_session(id); if (session != nullptr) @@ -712,12 +895,14 @@ namespace ccf void set_custom_protocol_subsystem( std::shared_ptr cpss) override { + std::lock_guard guard(subsystems_mutex); custom_protocol_subsystem = std::move(cpss); } void set_commit_callbacks_subsystem( std::shared_ptr fcss) override { + std::lock_guard guard(subsystems_mutex); commit_callbacks_subsystem = std::move(fcss); } diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp index 2ded13c15a58..1967de4b0d97 100644 --- a/src/host/test/openssl_server_test.cpp +++ b/src/host/test/openssl_server_test.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -354,9 +355,10 @@ namespace host, static_cast(0), [this]( - uint64_t id, std::vector d, const std::vector&) { - server->send(id, d.data(), d.size()); - }); + uint64_t id, + std::vector d, + const std::vector&, + bool) { server->send(id, d.data(), d.size()); }); server->start(); loop.start(); } @@ -383,7 +385,7 @@ namespace {} void handle_incoming_data( - std::span data, sockaddr /*addr*/ = {}) override + std::span data, sockaddr_storage /*addr*/ = {}) override { writer.write_outbound(id, data); } @@ -412,7 +414,8 @@ namespace {} void handle_incoming_data( - std::span /*data*/, sockaddr /*addr*/ = {}) override + std::span /*data*/, + sockaddr_storage /*addr*/ = {}) override { writer.write_outbound(id, payload); writer.close_socket(id); @@ -423,6 +426,133 @@ namespace }; } +// Admission control must run at accept time, not when the first request +// arrives. A client which completes the TCP and TLS handshakes and then sends +// nothing still holds a file descriptor and TLS state on the node, so it has +// to be counted - and the count has to be released exactly once when the +// connection goes away, whether or not it ever produced a session. +TEST_CASE("Connections are admitted at accept time and released once") +{ + auto [cert, key] = make_server_cert(); + + std::mutex m; + std::condition_variable cv; + std::vector<::tcp::ConnID> admitted; + std::vector<::tcp::ConnID> closed; + size_t data_callbacks = 0; + // Refuse everything after the first two connections, as a hard cap would. + constexpr size_t cap = 2; + + OpenSSLServer server( + cert, + key, + "127.0.0.1", + static_cast(0), + [&]( + ::tcp::ConnID, std::vector, const std::vector&, bool) { + std::lock_guard guard(m); + ++data_callbacks; + cv.notify_all(); + }, + [&](::tcp::ConnID id) { + std::lock_guard guard(m); + closed.push_back(id); + cv.notify_all(); + }, + "", + false, + false, + nullptr, + std::nullopt, + [&](::tcp::ConnID id) -> std::optional { + std::lock_guard guard(m); + if (admitted.size() >= cap) + { + return std::nullopt; + } + admitted.push_back(id); + cv.notify_all(); + return false; + }); + UVLoopRunner loop; + server.start(); + const auto port = server.port(); + loop.start(); + + const auto connect_tls = [port](int& fd, SSL_CTX*& ctx, SSL*& ssl) { + fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + REQUIRE(fd >= 0); + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + REQUIRE(inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) == 1); + REQUIRE( + ::connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0); + ctx = SSL_CTX_new(TLS_client_method()); + REQUIRE(ctx != nullptr); + ssl = SSL_new(ctx); + REQUIRE(ssl != nullptr); + REQUIRE(SSL_set_fd(ssl, fd) == 1); + SSL_set_connect_state(ssl); + }; + + // Two silent clients: they complete the TLS handshake and then send nothing + // at all, so on_data is never invoked for them. + int fds[cap] = {-1, -1}; + SSL_CTX* ctxs[cap] = {nullptr, nullptr}; + SSL* ssls[cap] = {nullptr, nullptr}; + for (size_t i = 0; i < cap; ++i) + { + connect_tls(fds[i], ctxs[i], ssls[i]); + REQUIRE(SSL_connect(ssls[i]) == 1); + } + + { + std::unique_lock lock(m); + REQUIRE(cv.wait_for(lock, std::chrono::seconds(10), [&]() { + return admitted.size() == cap; + })); + // Nothing was sent, so nothing reached the session layer - which is + // precisely why counting there would have missed these connections. + REQUIRE(data_callbacks == 0); + REQUIRE(closed.empty()); + } + + // A third connection is refused at accept. It is closed without a TLS + // handshake, and must not be reported through on_close since it was never + // admitted. + { + int fd = -1; + SSL_CTX* ctx = nullptr; + SSL* ssl = nullptr; + connect_tls(fd, ctx, ssl); + REQUIRE(SSL_connect(ssl) != 1); + SSL_free(ssl); + SSL_CTX_free(ctx); + ::close(fd); + } + + // Now drop the two admitted connections; each must be released exactly once. + for (size_t i = 0; i < cap; ++i) + { + SSL_free(ssls[i]); + SSL_CTX_free(ctxs[i]); + ::close(fds[i]); + } + + { + std::unique_lock lock(m); + REQUIRE(cv.wait_for( + lock, std::chrono::seconds(10), [&]() { return closed.size() >= cap; })); + REQUIRE(closed.size() == cap); + REQUIRE( + std::set<::tcp::ConnID>(closed.begin(), closed.end()) == + std::set<::tcp::ConnID>(admitted.begin(), admitted.end())); + } + + server.stop(); +} + TEST_CASE("Transports stop cleanly before the libuv loop starts") { auto [cert, key] = make_server_cert(); @@ -431,7 +561,8 @@ TEST_CASE("Transports stop cleanly before the libuv loop starts") key, "127.0.0.1", 0, - [](::tcp::ConnID, std::vector, const std::vector&) {}); + [](::tcp::ConnID, std::vector, const std::vector&, bool) { + }); tcp_server.start(); tcp_server.stop(); @@ -462,7 +593,8 @@ TEST_CASE("Transport shutdown drains TLS tasks with no background workers") key, "127.0.0.1", 0, - [](::tcp::ConnID, std::vector, const std::vector&) {}); + [](::tcp::ConnID, std::vector, const std::vector&, bool) { + }); UVLoopRunner loop; server.start(); loop.start(); @@ -490,6 +622,136 @@ TEST_CASE("Transport shutdown drains TLS tasks with no background workers") ::close(fd); } +// Shutdown must not declare itself complete on a transient lull in the +// pending-close count. Connections close one at a time, and libuv runs close +// callbacks at the end of each loop iteration, so a connection which tears +// down promptly drives the count back to zero in an iteration where other +// connections - and the listener, timer and async handles - are still open. +// Reporting "stopped" there lets the caller destroy the server while the loop +// still owns its handles, and uv_loop_close() then fails with EBUSY. +// +// The stagger is produced by holding some connections' workers inside on_data +// while one connection is left idle and therefore closes immediately. +TEST_CASE("Shutdown with staggered connection closes releases every uv handle") +{ + auto [cert, key] = make_server_cert(); + + constexpr size_t num_conns = 3; + constexpr size_t num_blocked = 2; + + std::mutex m; + std::condition_variable cv; + std::map<::tcp::ConnID, bool> block_decision; + size_t arrived = 0; + bool release = false; + + auto server = std::make_unique( + cert, + key, + "127.0.0.1", + static_cast(0), + [&]( + ::tcp::ConnID id, + std::vector, + const std::vector&, + bool) { + bool should_block = false; + { + std::unique_lock lock(m); + auto it = block_decision.find(id); + if (it == block_decision.end()) + { + it = block_decision.emplace(id, block_decision.size() < num_blocked) + .first; + ++arrived; + cv.notify_all(); + } + should_block = it->second; + if (should_block) + { + cv.wait(lock, [&]() { return release; }); + } + } + }, + OpenSSLServer::OnClose{}, + "", + false, + false, + nullptr, + // Configure an idle timeout, so the timer handle is in play as well. + std::chrono::milliseconds(60000)); + UVLoopRunner loop; + server->start(); + const auto port = server->port(); + loop.start(); + + std::vector clients; + clients.reserve(num_conns); + for (size_t i = 0; i < num_conns; ++i) + { + clients.emplace_back([port, &m, &cv, &release]() { + const int fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + REQUIRE(fd >= 0); + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + REQUIRE(inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) == 1); + REQUIRE( + ::connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0); + + SSL_CTX* cctx = SSL_CTX_new(TLS_client_method()); + REQUIRE(cctx != nullptr); + SSL* ssl = SSL_new(cctx); + REQUIRE(ssl != nullptr); + REQUIRE(SSL_set_fd(ssl, fd) == 1); + SSL_set_connect_state(ssl); + REQUIRE(SSL_connect(ssl) == 1); + const uint8_t byte = 'x'; + REQUIRE(SSL_write(ssl, &byte, 1) == 1); + + // Hold the connection open until the blocked workers are released, so + // that shutdown genuinely has several live connections to tear down. + { + std::unique_lock lock(m); + cv.wait(lock, [&]() { return release; }); + } + SSL_free(ssl); + SSL_CTX_free(cctx); + ::close(fd); + }); + } + + { + std::unique_lock lock(m); + REQUIRE(cv.wait_for( + lock, std::chrono::seconds(10), [&]() { return arrived == num_conns; })); + } + + // Release the held workers only after shutdown has had time to close the + // one connection which is not blocked, which is the window in which the + // pending-close count transiently reaches zero. + std::thread releaser([&]() { + std::this_thread::sleep_for(std::chrono::milliseconds(250)); + std::lock_guard guard(m); + release = true; + cv.notify_all(); + }); + + server->stop(); + + // stop() has returned, so shutdown claims to be complete. That must mean + // every handle the server owned - per-connection polls, the listener poll, + // the idle timer and the async - has actually been closed. + REQUIRE(uv_loop_alive(uv_default_loop()) == 0); + + releaser.join(); + for (auto& t : clients) + { + t.join(); + } + server.reset(); +} + TEST_CASE("TCP connections use the legacy latency and keepalive options") { const int fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); @@ -543,7 +805,8 @@ TEST_CASE("TLS processing runs off the libuv thread") [&]( ::tcp::ConnID id, std::vector data, - const std::vector&) { + const std::vector&, + bool) { { std::lock_guard guard(callback_mutex); callback_thread = std::this_thread::get_id(); @@ -580,6 +843,101 @@ TEST_CASE("Large transfer exercises the backpressure path") REQUIRE(resp == payload); } +// A read pass stops after max_read_per_event (64KiB), so a client which sends +// far more than that in one go and then goes silent must still have all of it +// delivered - either because the socket is still readable, or because the +// server noticed OpenSSL's own buffered data (SSL_has_pending) and scheduled +// another pass. +// +// NB: with OpenSSL's default TLS settings (read_ahead off, one record per +// socket read) the second case is hard to provoke deliberately, so this test +// does not by itself prove the SSL_has_pending() continuation is load-bearing. +// It covers the multi-pass read path, which is the part that regresses easily. +TEST_CASE("A single large write past the per-pass read cap is fully delivered") +{ + auto [cert, key] = make_server_cert(); + + std::mutex m; + std::condition_variable cv; + size_t received = 0; + + OpenSSLServer server( + cert, + key, + "127.0.0.1", + static_cast(0), + [&]( + ::tcp::ConnID, + std::vector data, + const std::vector&, + bool) { + std::lock_guard guard(m); + received += data.size(); + cv.notify_all(); + }); + UVLoopRunner loop; + server.start(); + loop.start(); + + // Comfortably more than the 64KiB per-pass cap, written in one go and + // followed by no further traffic at all. + const size_t payload_size = 512 * 1024; + const auto payload = random_bytes(payload_size); + + std::thread client([&]() { + const int fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + REQUIRE(fd >= 0); + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(server.port()); + REQUIRE(inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) == 1); + REQUIRE( + ::connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0); + + SSL_CTX* cctx = SSL_CTX_new(TLS_client_method()); + REQUIRE(cctx != nullptr); + SSL* ssl = SSL_new(cctx); + REQUIRE(ssl != nullptr); + REQUIRE(SSL_set_fd(ssl, fd) == 1); + SSL_set_connect_state(ssl); + REQUIRE(SSL_connect(ssl) == 1); + + size_t off = 0; + while (off < payload.size()) + { + const int n = SSL_write( + ssl, payload.data() + off, static_cast(payload.size() - off)); + REQUIRE(n > 0); + off += static_cast(n); + } + + // Deliberately send nothing more, and hold the connection open, so the + // only way the server can see the rest is by noticing its own buffered + // data rather than waiting for readability. + { + std::unique_lock lock(m); + cv.wait_for(lock, std::chrono::seconds(10), [&]() { + return received >= payload_size; + }); + } + + SSL_free(ssl); + SSL_CTX_free(cctx); + ::close(fd); + }); + + { + std::unique_lock lock(m); + REQUIRE(cv.wait_for(lock, std::chrono::seconds(10), [&]() { + return received >= payload_size; + })); + REQUIRE(received == payload_size); + } + + client.join(); + server.stop(); +} + TEST_CASE("Concurrent connections") { auto [cert, key] = make_server_cert(); @@ -643,7 +1001,8 @@ TEST_CASE("Reply from a worker thread") key, "127.0.0.1", static_cast(0), - [&](uint64_t id, std::vector d, const std::vector&) { + [&]( + uint64_t id, std::vector d, const std::vector&, bool) { { std::lock_guard l(m); q.emplace_back(id, std::move(d)); @@ -712,6 +1071,77 @@ TEST_CASE("Datagram server round-trip on the libuv reactor") server.stop(); } +// The datagram handler runs inline on the libuv thread and, in the real +// manager, takes the same lock that shutdown needs. Shutting down while +// datagrams are still arriving must therefore not deadlock: shutdown waits on +// the loop, and the loop must never end up waiting on shutdown. +TEST_CASE("Datagram server stops cleanly while datagrams are still arriving") +{ + std::atomic handled{0}; + std::mutex handler_mutex; + + DatagramServer* server_ptr = nullptr; + DatagramServer server( + "127.0.0.1", + 0, + [&]( + const uint8_t* data, + size_t len, + const sockaddr_storage& peer, + socklen_t peerlen) { + // Stands in for RPCConnectionManager's interfaces_mutex, which the + // datagram path takes on the loop thread while stop() may be running on + // another thread. + std::lock_guard guard(handler_mutex); + handled.fetch_add(1); + (void)server_ptr->send_to(peer, peerlen, data, len); + }); + UVLoopRunner loop; + server_ptr = &server; + server.start(); + const auto port = server.port(); + loop.start(); + + std::atomic sending{true}; + std::thread flooder([port, &sending]() { + const int fd = ::socket(AF_INET, SOCK_DGRAM, 0); + REQUIRE(fd >= 0); + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_port = htons(port); + REQUIRE(inet_pton(AF_INET, "127.0.0.1", &address.sin_addr) == 1); + const std::vector message = {'u', 'd', 'p'}; + while (sending.load()) + { + (void)::sendto( + fd, + message.data(), + message.size(), + 0, + reinterpret_cast(&address), + sizeof(address)); + } + ::close(fd); + }); + + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(10); + while (handled.load() == 0 && std::chrono::steady_clock::now() < deadline) + { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + REQUIRE(handled.load() > 0); + + // Stops while the flood is still in flight. If this deadlocks, the test + // hangs rather than failing, which is the intended signal. + server.stop(); + + sending.store(false); + flooder.join(); + + REQUIRE(uv_loop_alive(uv_default_loop()) == 0); +} + TEST_CASE("Session bridge: round-trip via ccf::Session + SessionWriter") { auto [cert, key] = make_server_cert(); @@ -720,7 +1150,7 @@ TEST_CASE("Session bridge: round-trip via ccf::Session + SessionWriter") key, "127.0.0.1", static_cast(0), - [](::tcp::ConnID id, ccf::SessionWriter& w, std::vector) { + [](::tcp::ConnID id, ccf::SessionWriter& w, std::vector, bool) { return std::make_shared(id, w); }); UVLoopRunner loop; @@ -742,7 +1172,7 @@ TEST_CASE("Session bridge: large transfer via ccf::Session + SessionWriter") key, "127.0.0.1", static_cast(0), - [](::tcp::ConnID id, ccf::SessionWriter& w, std::vector) { + [](::tcp::ConnID id, ccf::SessionWriter& w, std::vector, bool) { return std::make_shared(id, w); }); UVLoopRunner loop; @@ -773,7 +1203,8 @@ TEST_CASE("Peer certificate is captured for inbound connections") key, "127.0.0.1", static_cast(0), - [&](::tcp::ConnID id, ccf::SessionWriter& w, std::vector pc) { + [&]( + ::tcp::ConnID id, ccf::SessionWriter& w, std::vector pc, bool) { { std::lock_guard l(m); captured = std::move(pc); @@ -813,7 +1244,8 @@ TEST_CASE("Client certificate is requested but not enforced") key, "127.0.0.1", static_cast(0), - [&](::tcp::ConnID id, ccf::SessionWriter& w, std::vector pc) { + [&]( + ::tcp::ConnID id, ccf::SessionWriter& w, std::vector pc, bool) { { std::lock_guard l(m); captured = std::move(pc); @@ -969,7 +1401,8 @@ TEST_CASE("Graceful close flushes buffered response without truncation") key, "127.0.0.1", static_cast(0), - [&payload](::tcp::ConnID id, ccf::SessionWriter& w, std::vector) { + [&payload]( + ::tcp::ConnID id, ccf::SessionWriter& w, std::vector, bool) { return std::make_shared(id, w, payload); }); UVLoopRunner loop; diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index f5dbb9720361..b071afc113bd 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -7,6 +7,7 @@ // per-connection OrderedTasks drive non-blocking handshake, reads, writes, and // graceful close away from the loop thread. +#include "ccf/crypto/openssl/openssl_wrappers.h" #include "ds/internal_logger.h" #include "tasks/ordered_tasks.h" #include "tasks/task_system.h" @@ -97,18 +98,35 @@ namespace asynchost class OpenSSLServer { public: - // Invoked on a worker with a complete chunk of decrypted bytes and the - // certificate captured during this connection's handshake. + // Invoked on a worker with a complete chunk of decrypted bytes, the + // certificate captured during this connection's handshake, and the + // soft-limit decision taken when the connection was admitted. using OnData = std::function data, - const std::vector& peer_cert)>; + const std::vector& peer_cert, + bool soft_limited)>; // Invoked on the libuv thread when a connection is torn down (peer // disconnect, error, or close_connection()). Lets an owner drop per- // connection state. using OnClose = std::function; + // Invoked on the libuv thread for each newly accepted connection, before + // any TLS state is created. Returning nullopt refuses the connection: the + // socket is closed immediately and OnClose is *not* invoked for it. + // + // Otherwise the returned bool is the "admitted above a soft limit" + // decision, recorded on the connection and handed back to OnData. It is + // decided here rather than when the first request arrives so that it + // reflects the connection's position in the admission order, and does not + // drift for a client which is slow to send. + // + // If this returns a value, OnClose is guaranteed to be invoked exactly + // once for that connection id. Admission control can therefore reserve a + // resource here and release it in OnClose. + using OnAccept = std::function(::tcp::ConnID conn_id)>; + private: static constexpr size_t read_chunk = 16384; static constexpr size_t max_read_per_event = read_chunk * 4; @@ -124,6 +142,8 @@ namespace asynchost std::shared_ptr accepted_ctx; SSL* ssl = nullptr; ::tcp::ConnID id = 0; + // Admission decision from OnAccept, passed to OnData. + bool soft_limited = false; enum State : uint8_t { Handshaking, @@ -170,6 +190,10 @@ namespace asynchost { std::shared_ptr conn; bool alive = true; + // The pass stopped at the per-pass read cap with bytes still available + // that will not produce a new readability event. The loop must schedule + // another pass rather than re-arm polling. + bool more_to_read = false; std::chrono::steady_clock::time_point last_active; }; @@ -205,6 +229,7 @@ namespace asynchost std::string alpn_wire; OnData on_data; OnClose on_close; + OnAccept on_accept; std::mutex out_mutex; std::mutex lifecycle_mutex; @@ -226,6 +251,10 @@ namespace asynchost bool started = false; bool stopping = false; bool shutdown_started = false; + // Set once every connection has gone *and* the server's own handles have + // been handed to uv_close(). Until then pending_uv_closes reaching zero is + // only a lull between connection closures, not the end of shutdown. + bool handles_closing = false; bool stopped = false; bool loop_thread_seen = false; @@ -255,40 +284,33 @@ namespace asynchost static bool load_cert_key( SSL_CTX* ctx, const std::string& cert_pem, const std::string& key_pem) { - BIO* cbio = - BIO_new_mem_buf(cert_pem.data(), static_cast(cert_pem.size())); - if (cbio == nullptr) - { - return false; - } - X509* cert = PEM_read_bio_X509(cbio, nullptr, nullptr, nullptr); - BIO_free(cbio); + namespace OpenSSL = ccf::crypto::OpenSSL; + + OpenSSL::Unique_BIO cbio( + cert_pem.data(), static_cast(cert_pem.size())); + // check_null defaults to false for this overload: a malformed PEM yields + // a null pointer rather than an exception. + OpenSSL::Unique_X509 cert(cbio, true); if (cert == nullptr) { return false; } - const bool cert_ok = SSL_CTX_use_certificate(ctx, cert) == 1; - X509_free(cert); - if (!cert_ok) + if (SSL_CTX_use_certificate(ctx, cert) != 1) { return false; } - BIO* kbio = - BIO_new_mem_buf(key_pem.data(), static_cast(key_pem.size())); - if (kbio == nullptr) - { - return false; - } - EVP_PKEY* pkey = PEM_read_bio_PrivateKey(kbio, nullptr, nullptr, nullptr); - BIO_free(kbio); + OpenSSL::Unique_BIO kbio( + key_pem.data(), static_cast(key_pem.size())); + OpenSSL::Unique_PKEY pkey( + PEM_read_bio_PrivateKey(kbio, nullptr, nullptr, nullptr), + EVP_PKEY_free, + false); if (pkey == nullptr) { return false; } - const bool key_ok = SSL_CTX_use_PrivateKey(ctx, pkey) == 1; - EVP_PKEY_free(pkey); - if (!key_ok) + if (SSL_CTX_use_PrivateKey(ctx, pkey) != 1) { return false; } @@ -360,8 +382,8 @@ namespace asynchost // Allow buffer to be relocated between WANT_WRITE retries, and do partial // writes if possible. do_write() retries SSL_write() from a std::vector - // that may have been appended to (and so reallocated) by - // drain_pending_out() since the previous attempt, so both are required. + // which drive_connection() may have appended to (and so reallocated) + // since the previous attempt, so both are required. SSL_CTX_set_mode( c, SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER | SSL_MODE_ENABLE_PARTIAL_WRITE); @@ -423,7 +445,7 @@ namespace asynchost } // Returns false if the connection should be closed. - bool do_handshake(Conn& c) + bool do_handshake(Conn& c, bool& more_to_read) { // The error queue is thread-local and SSL_get_error() consults it. Clear // it before each operation because successive actions for this @@ -450,7 +472,7 @@ namespace asynchost X509_free(cert); } logf("conn %llu: handshake complete", (unsigned long long)c.id); - return do_read(c) && do_write(c); + return do_read(c, more_to_read) && do_write(c); } const int e = SSL_get_error(c.ssl, r); @@ -469,7 +491,7 @@ namespace asynchost } // Returns false if the connection should be closed. - bool do_read_plaintext(Conn& c) + bool do_read_plaintext(Conn& c, bool& more_to_read) { size_t total_read = 0; while (total_read < max_read_per_event) @@ -484,7 +506,8 @@ namespace asynchost on_data( c.id, std::vector(buf, buf + static_cast(n)), - c.peer_cert); + c.peer_cert, + c.soft_limited); } continue; } @@ -502,6 +525,10 @@ namespace asynchost } return false; } + // Stopped at the cap rather than at EAGAIN, so there may be more in the + // socket buffer. Level-triggered polling would report it again, but ask + // for another pass directly rather than depend on that. + more_to_read = true; return true; } @@ -538,11 +565,11 @@ namespace asynchost } // Returns false if the connection should be closed. - bool do_read(Conn& c) + bool do_read(Conn& c, bool& more_to_read) { if (c.ssl == nullptr) { - return do_read_plaintext(c); + return do_read_plaintext(c, more_to_read); } size_t total_read = 0; while (total_read < max_read_per_event) @@ -555,7 +582,11 @@ namespace asynchost total_read += static_cast(n); if (on_data) { - on_data(c.id, std::vector(buf, buf + n), c.peer_cert); + on_data( + c.id, + std::vector(buf, buf + n), + c.peer_cert, + c.soft_limited); } continue; } @@ -575,6 +606,16 @@ namespace asynchost // (no close_notify), or a fatal error - in all cases close. return false; } + + // Stopped at the per-pass cap rather than at WANT_READ. OpenSSL may + // still be holding bytes it has already taken off the socket - either + // decrypted plaintext or a buffered record. Those bytes will never + // produce a readability event, so waiting for one here would stall the + // connection until the peer happened to send more. Ask for another pass + // instead. SSL_has_pending() covers both processed and unprocessed + // buffered data; if it is only a partial record, the next pass returns + // WANT_READ and polling resumes normally. + more_to_read = SSL_has_pending(c.ssl) == 1; return true; } @@ -622,13 +663,23 @@ namespace asynchost return true; } - void complete_drive(std::shared_ptr conn, bool alive) + void complete_drive( + std::shared_ptr conn, bool alive, bool more_to_read) { { std::lock_guard guard(out_mutex); completed_drives.push_back( - {std::move(conn), alive, std::chrono::steady_clock::now()}); - } + {std::move(conn), + alive, + more_to_read, + std::chrono::steady_clock::now()}); + } + // Unlike wake(), this must signal even while stopping: shutdown only + // completes once the loop has observed every outstanding completion and + // closed the corresponding connection. The wake handle is guaranteed to + // still be open here, because finish_stopping_on_loop() only closes it + // once `conns` is empty, and a connection with a running worker has not + // yet been removed from `conns`. std::lock_guard guard(lifecycle_mutex); if (wake_handle_initialised) { @@ -639,6 +690,7 @@ namespace asynchost void drive_connection(std::shared_ptr conn, DriveInput input) { bool alive = true; + bool more_to_read = false; if (conn->ssl == nullptr && conn->accepted_ctx != nullptr) { @@ -650,7 +702,7 @@ namespace asynchost SSL_free(conn->ssl); conn->ssl = nullptr; } - complete_drive(std::move(conn), false); + complete_drive(std::move(conn), false, false); return; } SSL_set_accept_state(conn->ssl); @@ -675,13 +727,13 @@ namespace asynchost if (conn->state == Conn::Handshaking) { - alive = do_handshake(*conn); + alive = do_handshake(*conn, more_to_read); } else { if ((input.events & (UV_READABLE | UV_DISCONNECT)) != 0) { - alive = do_read(*conn); + alive = do_read(*conn, more_to_read); } if (alive) { @@ -702,7 +754,7 @@ namespace asynchost SSL_free(conn->ssl); conn->ssl = nullptr; } - complete_drive(std::move(conn), alive); + complete_drive(std::move(conn), alive, more_to_read); } void dispatch_connection(const std::shared_ptr& conn) @@ -744,7 +796,10 @@ namespace asynchost conn->fd = -1; auto* raw = conn.get(); closing_conns.emplace(raw, std::move(conn)); - ++pending_uv_closes; + { + std::lock_guard guard(lifecycle_mutex); + ++pending_uv_closes; + } uv_close( reinterpret_cast(&raw->poll), on_connection_poll_closed); finish_stopping_on_loop(); @@ -787,11 +842,43 @@ namespace asynchost continue; } + const ::tcp::ConnID cid = (shared_next_id != nullptr) ? + shared_next_id->fetch_add(1) : + next_id++; + + // Admission control runs here, before any TLS context, SSL object or + // task queue is created for the connection. A refused connection + // therefore costs nothing beyond the accept itself, and - crucially - + // an admitted connection is counted from the moment it exists, not + // from the moment it first sends a request. A client which completes + // the TCP and TLS handshakes and then goes silent still holds a file + // descriptor and TLS state, and must count against the caps. + bool soft_limited = false; + if (on_accept) + { + const auto admission = on_accept(cid); + if (!admission.has_value()) + { + ::close(cfd); + continue; + } + soft_limited = *admission; + } + + // From here on the connection has been admitted, so every failure path + // must release it by invoking on_close. + const auto release_admitted = [this, cid]() { + if (on_accept && on_close) + { + on_close(cid); + } + }; + auto c = std::make_unique(); c->owner = this; c->fd = cfd; - c->id = (shared_next_id != nullptr) ? shared_next_id->fetch_add(1) : - next_id++; + c->id = cid; + c->soft_limited = soft_limited; c->tls_tasks = ccf::tasks::OrderedTasks::create( ccf::tasks::get_main_job_board(), "TLS connection " + std::to_string(c->id)); @@ -806,8 +893,13 @@ namespace asynchost if (ctx == nullptr) { // No server certificate yet - refuse the connection until one is - // supplied (see set_server_cert). + // supplied (see set_server_cert). Expected while a joining node + // waits for the service certificate, so this is not an error, but + // it is otherwise invisible, hence the log. + LOG_DEBUG_FMT( + "Refusing connection {}: no server certificate yet", cid); ::close(cfd); + release_admitted(); continue; } c->accepted_ctx = ctx; @@ -816,7 +908,9 @@ namespace asynchost const int poll_rc = uv_poll_init_socket(loop, &c->poll, cfd); if (poll_rc != 0) { + logf("uv_poll_init_socket error: %s", uv_strerror(poll_rc)); ::close(cfd); + release_admitted(); continue; } c->poll.data = c.get(); @@ -824,17 +918,21 @@ namespace asynchost uv_poll_start(&c->poll, UV_READABLE, on_connection_poll); if (start_rc != 0) { + logf("uv_poll_start error: %s", uv_strerror(start_rc)); ::close(cfd); - ++pending_uv_closes; c->fd = -1; auto* raw = c.get(); closing_conns.emplace(raw, std::move(c)); + { + std::lock_guard guard(lifecycle_mutex); + ++pending_uv_closes; + } uv_close( reinterpret_cast(&raw->poll), on_connection_poll_closed); + release_admitted(); continue; } - const auto cid = c->id; conns.emplace(cfd, std::move(c)); id_to_fd.emplace(cid, cfd); logf("accepted conn on fd %d", cfd); @@ -911,13 +1009,24 @@ namespace asynchost for (auto& [cert_pem, key_pem] : certs) { - auto nc = build_server_ctx(cert_pem, key_pem); - if (nc == nullptr) + // This runs inside a libuv callback, so nothing may escape: an + // operator-supplied cert which fails to parse must not terminate the + // process, it must leave the previous context in place. + try { - logf("set_server_cert: build context failed"); - continue; + auto nc = build_server_ctx(cert_pem, key_pem); + if (nc == nullptr) + { + LOG_FAIL_FMT("set_server_cert: failed to build TLS context"); + continue; + } + ctx = std::move(nc); + } + catch (const std::exception& e) + { + LOG_FAIL_FMT( + "set_server_cert: failed to build TLS context: {}", e.what()); } - ctx = std::move(nc); } for (auto& item : items) @@ -957,6 +1066,12 @@ namespace asynchost { close_conn(conn->fd); } + else if (completion.more_to_read) + { + // Data is buffered where polling cannot see it, so ask for another + // pass explicitly. The loop below dispatches on pending_events. + conn->pending_events |= UV_READABLE; + } } for (auto& [fd, conn] : conns) @@ -1057,13 +1172,22 @@ namespace asynchost { --pending_uv_closes; } - if (stopping && pending_uv_closes == 0) + // Only finish once finish_stopping_on_loop() has queued the listener, + // timer and async handles for closure. Connections close one at a time, + // so without the handles_closing gate the first connection to finish + // closing would drive the count to zero and declare shutdown complete + // while other connections, and all three server handles, were still + // open - leaving uv_loop_close() to fail with EBUSY. + if (stopping && handles_closing && pending_uv_closes == 0) { stopped = true; stopped_cv.notify_all(); } } + // Requires lifecycle_mutex to be held by the caller. uv_close() never runs + // its callback synchronously, so complete_uv_close() cannot re-enter the + // lock from here. void close_server_handle(uv_handle_t* handle) { if (uv_is_closing(handle) == 0) @@ -1084,7 +1208,12 @@ namespace asynchost stopping = true; shutdown_started = true; } - (void)uv_poll_stop(&listen_poll); + // May run before start() finished initialising, if start() threw part + // way through, so the handle may not exist yet. + if (listen_poll_initialised) + { + (void)uv_poll_stop(&listen_poll); + } if (listen_fd >= 0) { ::close(listen_fd); @@ -1098,29 +1227,43 @@ namespace asynchost finish_stopping_on_loop(); } + // Loop thread. Completes shutdown once every connection has gone: stops + // the idle timer and closes the server's own uv handles. + // + // Every flag touched here is also read from other threads (see wake() and + // complete_drive()), so the whole body runs under lifecycle_mutex, and + // each *_initialised flag is cleared *before* the corresponding uv_close(). + // Clearing afterwards would leave a window in which another thread sees + // the handle as usable and calls uv_async_send() on a closing handle. void finish_stopping_on_loop() { - if (!stopping || !conns.empty()) + std::lock_guard guard(lifecycle_mutex); + if (!stopping || handles_closing || !conns.empty()) { return; } + + // Every connection has gone. From here on there is nothing left to + // close but the server's own handles, so once the count reaches zero + // shutdown really is complete. + handles_closing = true; + if (idle_timer_initialised) { + idle_timer_initialised = false; (void)uv_timer_stop(&idle_timer); close_server_handle(reinterpret_cast(&idle_timer)); - idle_timer_initialised = false; } if (listen_poll_initialised) { - close_server_handle(reinterpret_cast(&listen_poll)); listen_poll_initialised = false; + close_server_handle(reinterpret_cast(&listen_poll)); } if (wake_handle_initialised) { - close_server_handle(reinterpret_cast(&wake_handle)); wake_handle_initialised = false; + close_server_handle(reinterpret_cast(&wake_handle)); } - std::lock_guard guard(lifecycle_mutex); if (pending_uv_closes == 0) { stopped = true; @@ -1141,12 +1284,14 @@ namespace asynchost bool verbose_ = false, std::atomic<::tcp::ConnID>* shared_next_id_ = nullptr, std::optional idle_timeout_ = std::nullopt, + OnAccept on_accept_ = {}, uv_loop_t* loop_ = uv_default_loop()) : loop(loop_), shared_next_id(shared_next_id_), idle_timeout(idle_timeout_), on_data(std::move(on_data_)), on_close(std::move(on_close_)), + on_accept(std::move(on_accept_)), plaintext(plaintext_), verbose(verbose_) { @@ -1273,6 +1418,21 @@ namespace asynchost { return; } + + // Mark the server started, and reset the lifecycle flags, *before* any + // handle is initialised. If one of the steps below throws, the handles + // which were already initialised are still registered with the loop and + // must be closed, or uv_loop_close() will fail with EBUSY. stop() does + // exactly that, but only when `started` is set - so setting it here is + // what makes a partially-initialised server safe to destroy. + stopped = false; + stopping = false; + shutdown_started = false; + handles_closing = false; + initialising_thread_id = std::this_thread::get_id(); + loop_thread_seen = false; + started = true; + int rc = uv_poll_init_socket(loop, &listen_poll, listen_fd); if (rc != 0) { @@ -1320,12 +1480,6 @@ namespace asynchost throw std::runtime_error( std::string("uv_poll_start(listen) failed: ") + uv_strerror(rc)); } - stopped = false; - stopping = false; - shutdown_started = false; - initialising_thread_id = std::this_thread::get_id(); - loop_thread_seen = false; - started = true; } void stop() @@ -1338,7 +1492,12 @@ namespace asynchost if (!stopping) { stopping = true; - (void)uv_async_send(&wake_handle); + // finish_stopping_on_loop() clears wake_handle_initialised before it + // closes the handle, so this cannot signal a handle already closing. + if (wake_handle_initialised) + { + (void)uv_async_send(&wake_handle); + } } const bool loop_not_started_here = !loop_thread_seen && std::this_thread::get_id() == initialising_thread_id; @@ -1361,6 +1520,13 @@ namespace asynchost ccf::tasks::try_do_task(*task); } (void)uv_run(loop, UV_RUN_NOWAIT); + if (task == nullptr) + { + // Nothing to do but wait for outstanding uv close callbacks. + // uv_run(UV_RUN_NOWAIT) returns immediately, so without this the + // loop below would spin at 100% CPU for the whole shutdown. + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } } } if (std::this_thread::get_id() == loop_thread_id) diff --git a/src/host/tls/openssl_session_manager.h b/src/host/tls/openssl_session_manager.h index 5630f41dd42d..ec813e34a3f9 100644 --- a/src/host/tls/openssl_session_manager.h +++ b/src/host/tls/openssl_session_manager.h @@ -13,10 +13,16 @@ // "make an HTTPServerSession for this interface"). Sessions are created lazily // on first inbound data and removed on close. // +// Note that admission control is deliberately *not* tied to the session: a +// connection is admitted (and counted) by the transport's OnAccept before any +// session exists, and released here in on_close, once per admitted connection. +// Tying it to the session would leave connections which complete the TLS +// handshake but never send a request entirely uncounted. +// // Threading: OpenSSLServer invokes on_data on its TLS OrderedTasks worker and // on_close on its loop thread. The session may dispatch again to its own // OrderedTasks and reply via write_outbound from any worker. Every public -// method is therefore safe to call from any thread, and the sessions map is +// method is therefore safe to call from any thread, and the connection map is // guarded by a mutex. #include "ccf/node/session.h" @@ -41,77 +47,79 @@ namespace asynchost // Creates the protocol session for a freshly seen connection. `writer` is // this manager - the session emits its (plaintext) output through it. // `peer_cert` is the client certificate (DER) captured at handshake, for - // caller authentication. + // caller authentication. `soft_limited` is the admission decision taken + // when the connection was accepted. using SessionFactory = std::function( ::tcp::ConnID conn_id, ccf::SessionWriter& writer, - std::vector peer_cert)>; + std::vector peer_cert, + bool soft_limited)>; private: std::unique_ptr server; SessionFactory factory; - // Invoked when a connection's session is dropped, so an owner can update - // per-interface counters/metrics. Called on the loop thread from on_close, - // or on a worker thread from close_socket, so it must be thread-safe. - std::function on_session_closed; + // Invoked when an admitted connection is torn down, so an owner can + // release whatever it reserved at accept time. Called on the loop thread + // from on_close, exactly once per admitted connection. + std::function on_connection_closed; - std::mutex sessions_mutex; - std::unordered_map<::tcp::ConnID, std::shared_ptr> sessions; + struct ConnState + { + std::shared_ptr session; + // The session asked for the connection to be closed. The transport + // teardown is asynchronous, so bytes already in flight may still arrive; + // they are dropped rather than being used to build a replacement + // session for a connection which is going away. + bool closing = false; + }; + + std::mutex conns_mutex; + std::unordered_map<::tcp::ConnID, ConnState> conns; void on_data( ::tcp::ConnID conn_id, std::vector data, - const std::vector& peer_cert) + const std::vector& peer_cert, + bool soft_limited) { std::shared_ptr session; { - std::lock_guard guard(sessions_mutex); - auto it = sessions.find(conn_id); - if (it == sessions.end()) + std::lock_guard guard(conns_mutex); + auto& state = conns[conn_id]; + if (state.closing) + { + return; + } + if (state.session == nullptr) { - session = factory(conn_id, *this, peer_cert); - if (session == nullptr) + state.session = factory(conn_id, *this, peer_cert, soft_limited); + if (state.session == nullptr) { - // Factory refused (e.g. hard session cap) - tear the connection - // down. + // Factory refused - tear the connection down. + state.closing = true; server->close_connection(conn_id); return; } - sessions.emplace(conn_id, session); - } - else - { - session = it->second; } + session = state.session; } - if (session != nullptr) - { - session->handle_incoming_data({data.data(), data.size()}); - } + session->handle_incoming_data({data.data(), data.size()}); } void on_close(::tcp::ConnID conn_id) { - std::shared_ptr session; - { - std::lock_guard guard(sessions_mutex); - auto it = sessions.find(conn_id); - if (it != sessions.end()) - { - session = it->second; - sessions.erase(it); - } - } - - if (session == nullptr) { - return; + std::lock_guard guard(conns_mutex); + conns.erase(conn_id); } - if (on_session_closed) + // Unconditional: the connection, not the session, is what was reserved + // at accept time, and a connection which never sent a request has no + // session to key off. + if (on_connection_closed) { - on_session_closed(conn_id); + on_connection_closed(conn_id); } } @@ -126,10 +134,11 @@ namespace asynchost bool plaintext = false, bool verbose = false, std::atomic<::tcp::ConnID>* shared_next_id = nullptr, - std::function on_session_closed_ = {}, - std::optional idle_timeout = std::nullopt) : + std::function on_connection_closed_ = {}, + std::optional idle_timeout = std::nullopt, + OpenSSLServer::OnAccept on_accept = {}) : factory(std::move(factory_)), - on_session_closed(std::move(on_session_closed_)) + on_connection_closed(std::move(on_connection_closed_)) { server = std::make_unique( cert_pem, @@ -139,23 +148,25 @@ namespace asynchost [this]( ::tcp::ConnID id, std::vector data, - const std::vector& peer_cert) { - on_data(id, std::move(data), peer_cert); + const std::vector& peer_cert, + bool soft_limited) { + on_data(id, std::move(data), peer_cert, soft_limited); }, [this](::tcp::ConnID id) { on_close(id); }, alpn, plaintext, verbose, shared_next_id, - idle_timeout); + idle_timeout, + std::move(on_accept)); } // The session for `id`, or nullptr. Thread-safe. std::shared_ptr get_session(::tcp::ConnID id) { - std::lock_guard guard(sessions_mutex); - auto it = sessions.find(id); - return it == sessions.end() ? nullptr : it->second; + std::lock_guard guard(conns_mutex); + auto it = conns.find(id); + return it == conns.end() ? nullptr : it->second.session; } // (Re)load this interface's server certificate (deferred cert / rotation). @@ -185,22 +196,24 @@ namespace asynchost void write_outbound( ::tcp::ConnID id, std::span data, - sockaddr /*addr*/ = {}) override + sockaddr_storage /*addr*/ = {}) override { server->send(id, data.data(), data.size()); } void close_socket(::tcp::ConnID id) override { - bool had_session = false; - { - std::lock_guard guard(sessions_mutex); - had_session = sessions.erase(id) > 0; - } - if (had_session && id >= 0 && on_session_closed) { - on_session_closed(id); + std::lock_guard guard(conns_mutex); + auto it = conns.find(id); + if (it != conns.end()) + { + it->second.closing = true; + it->second.session.reset(); + } } + // No release here: the connection's reservation is released by on_close + // when the transport has actually torn it down, exactly once. server->close_connection(id); } }; From c50ab93a4be8bbc8a9cfca6b6492d1af9aef6c46 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Thu, 6 Aug 2026 16:21:18 +0000 Subject: [PATCH 46/59] More fixes? Impossible to know at this point --- include/ccf/node/session.h | 20 +- src/enclave/abstract_rpc_sessions.h | 3 - src/enclave/enclave.h | 4 +- src/enclave/session.h | 2 +- src/enclave/session_writer.h | 10 +- src/host/datagram_server.h | 216 ++++----- src/host/rpc_connection_manager.h | 188 +++---- src/host/test/openssl_server_test.cpp | 229 +++++---- src/host/tls/openssl_server.h | 645 ++++++++++++++----------- src/host/tls/openssl_session_manager.h | 34 +- 10 files changed, 693 insertions(+), 658 deletions(-) diff --git a/include/ccf/node/session.h b/include/ccf/node/session.h index 5df3b1b11c4a..9d4c215ebeff 100644 --- a/include/ccf/node/session.h +++ b/include/ccf/node/session.h @@ -8,17 +8,27 @@ namespace ccf { + // A peer address for connectionless (datagram) transports. + // + // sockaddr_storage rather than sockaddr, because sockaddr is too small to + // hold an IPv6 address, and the length is carried with it because only the + // first `len` bytes are meaningful - and sendto() requires it. + struct SessionEndpoint + { + sockaddr_storage addr{}; + socklen_t len = 0; + }; + class Session { public: virtual ~Session() = default; - // Inbound bytes for this session. `addr` is the source address of the - // datagram for connectionless (UDP) transports, and is unused (default) for - // stream (TCP) transports. sockaddr_storage rather than sockaddr, because - // sockaddr is too small to hold an IPv6 address. + // Inbound bytes for this session. `peer` is the source address of the + // datagram for connectionless (UDP) transports, and is unused (default) + // for stream (TCP) transports. virtual void handle_incoming_data( - std::span data, sockaddr_storage addr = {}) = 0; + std::span data, const SessionEndpoint& peer = {}) = 0; virtual void send_data(std::vector&& data) = 0; virtual void close_session() = 0; }; diff --git a/src/enclave/abstract_rpc_sessions.h b/src/enclave/abstract_rpc_sessions.h index f8af9a9c8479..93c94f4b91be 100644 --- a/src/enclave/abstract_rpc_sessions.h +++ b/src/enclave/abstract_rpc_sessions.h @@ -24,9 +24,6 @@ namespace ccf public: ~AbstractRPCSessions() override = default; - [[nodiscard]] virtual ccf::ApplicationProtocol - get_app_protocol_main_interface() const = 0; - virtual ccf::SessionMetrics get_session_metrics() = 0; virtual void set_node_cert( diff --git a/src/enclave/enclave.h b/src/enclave/enclave.h index 790ebc30de2f..ff88e0814f9e 100644 --- a/src/enclave/enclave.h +++ b/src/enclave/enclave.h @@ -512,7 +512,9 @@ namespace ccf } LOG_INFO_FMT("Stopping RPC transports"); - rpcsessions->stop(); + // The host is still running the libuv loop at this point - it only + // exits once we send AdminMessage::stopped below. + rpcsessions->stop(asynchost::OpenSSLServer::LoopState::Running); LOG_INFO_FMT("Enclave stopped successfully. Stopping host..."); RINGBUFFER_WRITE_MESSAGE(AdminMessage::stopped, to_host); diff --git a/src/enclave/session.h b/src/enclave/session.h index 391106cade71..405ba7a2a3d9 100644 --- a/src/enclave/session.h +++ b/src/enclave/session.h @@ -88,7 +88,7 @@ namespace ccf // Implement Session::handle_incoming_data by dispatching a thread message // that eventually invokes the virtual handle_incoming_data_thread() void handle_incoming_data( - std::span data, sockaddr_storage /*addr*/) override + std::span data, const SessionEndpoint& /*peer*/) override { task_scheduler->add_action( std::make_shared(data, shared_from_this())); diff --git a/src/enclave/session_writer.h b/src/enclave/session_writer.h index ccee77617ee1..821db5e6ecf1 100644 --- a/src/enclave/session_writer.h +++ b/src/enclave/session_writer.h @@ -2,6 +2,7 @@ // Licensed under the Apache 2.0 License. #pragma once +#include "ccf/node/session.h" #include "tcp/msg_types.h" #include @@ -24,10 +25,9 @@ namespace ccf virtual ~SessionWriter() = default; // Queue bytes to be written to the socket associated with `id`. For - // datagram protocols, `addr` identifies the destination peer; it is ignored - // for stream (TCP) connections. sockaddr_storage rather than sockaddr, - // because sockaddr is too small to hold an IPv6 address. The bytes are - // copied, so the caller's buffer can be reused immediately. + // datagram protocols, `peer` identifies the destination; it is ignored for + // stream (TCP) connections. The bytes are copied, so the caller's buffer + // can be reused immediately. // // Fire-and-forget: there is currently no backpressure signal. // @@ -38,7 +38,7 @@ namespace ccf virtual void write_outbound( ::tcp::ConnID id, std::span data, - sockaddr_storage addr = {}) = 0; + const SessionEndpoint& peer = {}) = 0; // Tear down the connection: stop the underlying socket and drop the // session. diff --git a/src/host/datagram_server.h b/src/host/datagram_server.h index 4b6b92521768..66d43c863dba 100644 --- a/src/host/datagram_server.h +++ b/src/host/datagram_server.h @@ -52,6 +52,14 @@ namespace asynchost const sockaddr_storage& peer, socklen_t peerlen)>; + // Whether anything is running the libuv loop when stop() is called. See + // stop() for why this has to be stated rather than inferred. + enum class LoopState : uint8_t + { + Running, + NotRunning, + }; + private: static constexpr size_t max_datagram = 65535; // Datagrams handled per readable event. The handler runs inline on the @@ -63,26 +71,38 @@ namespace asynchost uv_loop_t* loop = nullptr; int sock = -1; - uv_poll_t socket_poll{}; - uv_async_t stop_handle{}; + // Heap-allocated and freed by their own close callbacks, so that this + // server can be destroyed without waiting for the loop to run them. See + // the equivalent note in host/tls/openssl_server.h. + uv_poll_t* socket_poll = nullptr; + uv_async_t* stop_handle = nullptr; uint16_t bound_port = 0; OnDatagram on_datagram; std::mutex lifecycle_mutex; - std::condition_variable stopped_cv; - std::thread::id loop_thread_id; + std::condition_variable teardown_cv; bool started = false; bool stopping = false; - bool shutdown_started = false; - bool stopped = false; - // Tracked separately from `started`, because start() can throw part way - // through initialisation and the handles which were initialised must - // still be closed. - bool socket_poll_initialised = false; - bool stop_handle_initialised = false; - size_t pending_uv_closes = 0; - std::thread::id initialising_thread_id; - bool loop_thread_seen = false; + bool torn_down = false; + + template + static void close_handle(THandle*& handle) + { + if (handle == nullptr) + { + return; + } + auto* as_handle = reinterpret_cast(handle); + handle = nullptr; + if (uv_is_closing(as_handle) != 0) + { + return; + } + uv_close(as_handle, [](uv_handle_t* h) { + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) + delete reinterpret_cast(h); + }); + } static bool set_nonblocking(int fd) { @@ -94,13 +114,6 @@ namespace asynchost return fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0; } - void mark_loop_thread() - { - std::lock_guard guard(lifecycle_mutex); - loop_thread_id = std::this_thread::get_id(); - loop_thread_seen = true; - } - void drain() { size_t handled = 0; @@ -144,10 +157,9 @@ namespace asynchost static void on_socket_poll(uv_poll_t* handle, int status, int events) { auto* self = static_cast(handle->data); - self->mark_loop_thread(); if (status < 0) { - self->stop_on_loop(); + self->tear_down_on_loop(); return; } if ((events & UV_READABLE) != 0) @@ -161,76 +173,39 @@ namespace asynchost static void on_stop(uv_async_t* handle) { auto* self = static_cast(handle->data); - self->mark_loop_thread(); - self->stop_on_loop(); + self->tear_down_on_loop(); } - static void on_handle_closed(uv_handle_t* handle) + // Close the socket and hand every handle to uv_close(). Must only run + // where it cannot race the loop - see stop(). Does not wait for the + // closes: the handles own themselves. + void tear_down_on_loop() { - auto* self = static_cast(handle->data); - std::lock_guard guard(self->lifecycle_mutex); - --self->pending_uv_closes; - if (self->pending_uv_closes == 0) - { - self->stopped = true; - self->stopped_cv.notify_all(); - } - } - - void stop_on_loop() - { - bool close_socket_poll = false; - bool close_stop_handle = false; + std::lock_guard guard(lifecycle_mutex); + if (torn_down) { - std::lock_guard guard(lifecycle_mutex); - if (shutdown_started) - { - return; - } - stopping = true; - shutdown_started = true; - - // Only the handles start() actually initialised. Counting blindly - // would leave pending_uv_closes permanently non-zero (so stop() would - // never complete) if start() threw part way through. - close_socket_poll = socket_poll_initialised; - close_stop_handle = stop_handle_initialised; - socket_poll_initialised = false; - stop_handle_initialised = false; - pending_uv_closes = static_cast(close_socket_poll) + - static_cast(close_stop_handle); - - if (close_socket_poll) - { - (void)uv_poll_stop(&socket_poll); - } - // Closed under the lock so that a concurrent send_to() cannot be left - // holding a descriptor which has been closed (and possibly reused) - // underneath it. - if (sock >= 0) - { - ::close(sock); - sock = -1; - } - - if (pending_uv_closes == 0) - { - stopped = true; - stopped_cv.notify_all(); - return; - } + return; } + stopping = true; - if (close_socket_poll) + if (socket_poll != nullptr) { - uv_close( - reinterpret_cast(&socket_poll), on_handle_closed); + (void)uv_poll_stop(socket_poll); } - if (close_stop_handle) + // Closed under the lock so that a concurrent send_to() cannot be left + // holding a descriptor which has been closed (and possibly reused) + // underneath it. + if (sock >= 0) { - uv_close( - reinterpret_cast(&stop_handle), on_handle_closed); + ::close(sock); + sock = -1; } + + close_handle(socket_poll); + close_handle(stop_handle); + + torn_down = true; + teardown_cv.notify_all(); } public: @@ -321,36 +296,32 @@ namespace asynchost return; } - // Marked started before any handle is initialised, so that a failure - // part way through still leaves a server whose destructor closes and - // drains the handles which were initialised (stop() is a no-op unless - // `started` is set). + // Marked started before any handle is created, so that a failure part + // way through still leaves a server whose destructor closes the handles + // which were created (stop() is a no-op unless `started` is set). started = true; stopping = false; - shutdown_started = false; - stopped = false; - initialising_thread_id = std::this_thread::get_id(); - loop_thread_seen = false; + torn_down = false; - int rc = uv_poll_init_socket(loop, &socket_poll, sock); + socket_poll = new uv_poll_t{}; // NOLINT(cppcoreguidelines-owning-memory) + socket_poll->data = this; + int rc = uv_poll_init_socket(loop, socket_poll, sock); if (rc != 0) { throw std::runtime_error( std::string("uv_poll_init_socket(udp) failed: ") + uv_strerror(rc)); } - socket_poll_initialised = true; - socket_poll.data = this; - rc = uv_async_init(loop, &stop_handle, on_stop); + stop_handle = new uv_async_t{}; // NOLINT(cppcoreguidelines-owning-memory) + stop_handle->data = this; + rc = uv_async_init(loop, stop_handle, on_stop); if (rc != 0) { throw std::runtime_error( std::string("uv_async_init(udp) failed: ") + uv_strerror(rc)); } - stop_handle_initialised = true; - stop_handle.data = this; - rc = uv_poll_start(&socket_poll, UV_READABLE, on_socket_poll); + rc = uv_poll_start(socket_poll, UV_READABLE, on_socket_poll); if (rc != 0) { throw std::runtime_error( @@ -358,50 +329,33 @@ namespace asynchost } } - void stop() + // Tear the server down. Idempotent, and safe to call from the destructor. + // See OpenSSLServer::stop() for why `loop_state` is stated rather than + // inferred. + void stop(LoopState loop_state = LoopState::NotRunning) { std::unique_lock lock(lifecycle_mutex); - if (!started || stopped) + if (!started || torn_down) { return; } - if (!stopping) + + if (loop_state == LoopState::NotRunning) { - stopping = true; - if (stop_handle_initialised) - { - (void)uv_async_send(&stop_handle); - } + lock.unlock(); + tear_down_on_loop(); + return; } - const bool loop_not_started_here = !loop_thread_seen && - std::this_thread::get_id() == initialising_thread_id; - if (loop_not_started_here) + + if (!stopping) { - lock.unlock(); - stop_on_loop(); - for (;;) + stopping = true; + if (stop_handle != nullptr) { - { - std::lock_guard guard(lifecycle_mutex); - if (stopped) - { - return; - } - } - (void)uv_run(loop, UV_RUN_NOWAIT); - // uv_run(UV_RUN_NOWAIT) returns immediately, so without this the - // loop would spin at 100% CPU while waiting for the outstanding uv - // close callbacks. - std::this_thread::sleep_for(std::chrono::milliseconds(1)); + (void)uv_async_send(stop_handle); } } - if (std::this_thread::get_id() == loop_thread_id) - { - lock.unlock(); - stop_on_loop(); - return; - } - stopped_cv.wait(lock, [this]() { return stopped; }); + teardown_cv.wait(lock, [this]() { return torn_down; }); } [[nodiscard]] uint16_t port() const diff --git a/src/host/rpc_connection_manager.h b/src/host/rpc_connection_manager.h index 0bfd8a3c8376..44d6c37c87b8 100644 --- a/src/host/rpc_connection_manager.h +++ b/src/host/rpc_connection_manager.h @@ -6,10 +6,12 @@ // // Owns one OpenSSL transport per listening interface (TLS terminated in the // connection, see host/tls/openssl_server.h), creates the protocol session for -// each connection, applies per-interface session caps and certificates, and -// exposes outbound client creation. It implements ccf::AbstractRPCSessions so -// the node (NodeState/frontends) reaches it without depending on the transport -// backend. +// each connection, and applies per-interface session caps and certificates. It +// implements ccf::AbstractRPCSessions so the node (NodeState/frontends) reaches +// it without depending on the transport backend. +// +// Inbound only: outbound requests (quote endorsements, JWT refresh, node join) +// are made with libcurl and do not come through here. // // Cert-deferred listening: interfaces bind at startup even before their // certificate exists (a joining node receives the service cert later). A TLS @@ -53,10 +55,57 @@ namespace ccf // How often idle UDP sessions are swept, mirroring the TCP idle sweep. static constexpr auto udp_idle_sweep_interval = std::chrono::seconds(1); - class RPCConnectionManager - : public std::enable_shared_from_this, - public ccf::AbstractRPCSessions, - public ::http::ErrorReporter + // Per-interface error counters, deliberately kept out of the manager. + // + // Sessions are handed an ErrorReporter and hold it for their whole lifetime. + // If that were the manager itself, the manager would own the interfaces, + // which own the transport, which owns the sessions, which own the manager - + // a cycle keeping the manager alive for as long as any session exists. + // Owning the counts separately breaks it, and they are all a session + // actually needs. + class InterfaceErrorCounts : public ::http::ErrorReporter + { + public: + struct Counts + { + size_t parsing = 0; + size_t payload_too_large = 0; + size_t header_too_large = 0; + }; + + Counts get(const ccf::ListenInterfaceID& id) + { + std::lock_guard guard(mutex); + auto it = counts.find(id); + return it == counts.end() ? Counts{} : it->second; + } + + void report_parsing_error(const ccf::ListenInterfaceID& id) override + { + std::lock_guard guard(mutex); + ++counts[id].parsing; + } + + void report_request_payload_too_large_error( + const ccf::ListenInterfaceID& id) override + { + std::lock_guard guard(mutex); + ++counts[id].payload_too_large; + } + + void report_request_header_too_large_error( + const ccf::ListenInterfaceID& id) override + { + std::lock_guard guard(mutex); + ++counts[id].header_too_large; + } + + private: + std::mutex mutex; + std::map counts; + }; + + class RPCConnectionManager : public ccf::AbstractRPCSessions { private: struct ListenInterface @@ -70,9 +119,6 @@ namespace ccf std::atomic open_sessions{0}; std::atomic peak_sessions{0}; - std::atomic err_parsing{0}; - std::atomic err_payload_too_large{0}; - std::atomic err_header_too_large{0}; // The transport for this interface (created on listen()). Held by // shared_ptr so that callers which snapshot it under interfaces_mutex @@ -98,7 +144,7 @@ namespace ccf void write_outbound( ::tcp::ConnID id, std::span data, - sockaddr_storage /*addr*/ = {}) override + const ccf::SessionEndpoint& /*peer*/ = {}) override { write(id, data); } @@ -139,16 +185,14 @@ namespace ccf std::shared_ptr commit_callbacks_subsystem; // Set at the start of stop(). Once set no further sessions are created: - // the transports are being torn down, and when stop() is invoked from the - // destructor shared_from_this() (used by error_reporter()) would throw - // std::bad_weak_ptr because the control block has already expired. + // the transports are being torn down. std::atomic stopping{false}; std::mutex interfaces_mutex; std::map> interfaces; - // UDP interface state, keyed by interface name. UDP "QUIC" interfaces use - // a built-in datagram echo session until OpenSSL-native QUIC is available; - // other UDP protocols are routed to custom sessions, one session per peer. + // UDP interface state, keyed by interface name. Only custom UDP protocols + // hold state here, one session per peer; "QUIC" interfaces are echoed + // statelessly (see listen_udp) and so have no entries at all. std::map> udp_interfaces; // cert/key PEM per endorsement authority (for cert-deferred listening). std::map> certs; @@ -158,16 +202,18 @@ namespace ccf std::atomic<::tcp::ConnID> shared_conn_id{1}; std::atomic active_sessions{0}; std::atomic peak_sessions{0}; - // Outbound client sessions use the negative range, matching the historical - // convention relied upon by forwarding. // How long an idle connection is kept before being closed (nullopt = // never). Applied to each interface transport at listen() time. std::optional idle_connection_timeout; + // Outlives this manager if a session does - see InterfaceErrorCounts. + std::shared_ptr error_counts = + std::make_shared(); + std::shared_ptr<::http::ErrorReporter> error_reporter() { - return shared_from_this(); + return error_counts; } std::shared_ptr get_custom_protocol_subsystem() @@ -570,10 +616,19 @@ namespace ccf ~RPCConnectionManager() override { - stop(); + // By this point either stop() has already run (the normal path, from + // Enclave::run() while the loop was still going), in which case this is + // a no-op, or node startup failed before the event loop was ever + // entered - which is exactly LoopState::NotRunning. + stop(asynchost::OpenSSLServer::LoopState::NotRunning); } - void stop() + // Tear down every transport. `loop_state` says whether another thread is + // running the libuv loop, which the transports cannot determine for + // themselves - see OpenSSLServer::stop(). + void stop( + asynchost::OpenSSLServer::LoopState loop_state = + asynchost::OpenSSLServer::LoopState::NotRunning) { // Refuse new sessions before tearing anything down. This is what makes // it safe for the destructor to call stop(): make_session() will no @@ -606,17 +661,21 @@ namespace ccf for (const auto& bridge : bridges) { - bridge->stop(); + bridge->stop(loop_state); } for (const auto& server : datagram_servers) { - server->stop(); + server->stop( + loop_state == asynchost::OpenSSLServer::LoopState::Running ? + asynchost::DatagramServer::LoopState::Running : + asynchost::DatagramServer::LoopState::NotRunning); } } // Bind and start listening on `name` (which must have been configured via - // update_listening_interface_options). Returns the bound port (supports - // ephemeral port 0), or 0 on failure. + // update_listening_interface_options). Returns the bound port, which for a + // configured port of 0 is the ephemeral port the OS assigned. Throws if the + // interface is unconfigured, or if the socket cannot be bound. uint16_t listen( const std::string& name, const std::string& host, const std::string& port) { @@ -658,20 +717,20 @@ namespace ccf }; auto on_closed = [this, li](::tcp::ConnID) { release_connection(li); }; - const auto port_num = + const uint16_t port_num = port.empty() ? 0 : static_cast(std::stoi(port)); li->bridge = std::make_shared( - cert_pem, - key_pem, - host, - port_num, + asynchost::OpenSSLServer::Config{ + .host = host, + .port = port_num, + .cert_pem = cert_pem, + .key_pem = key_pem, + .alpn = alpn, + .plaintext = plaintext, + .idle_timeout = idle_connection_timeout, + .shared_next_id = &shared_conn_id}, factory, - alpn, - plaintext, - false, - &shared_conn_id, on_closed, - idle_connection_timeout, on_accept); li->bridge->start(); return li->bridge->port(); @@ -740,7 +799,7 @@ namespace ccf { return; } - session->handle_incoming_data({data, len}, peer); + session->handle_incoming_data({data, len}, {peer, peerlen}); }); udp->server->start(); const uint16_t bound = udp->server->port(); @@ -784,28 +843,17 @@ namespace ccf return false; } - ccf::ApplicationProtocol get_app_protocol_main_interface() const override - { - // NB: const_cast to lock - the mutex is logically mutable here. - auto& self = const_cast(*this); - std::lock_guard guard(self.interfaces_mutex); - if (self.interfaces.empty()) - { - throw std::logic_error("No listening interface for this node"); - } - return self.interfaces.begin()->second->app_protocol; - } - ccf::SessionMetrics get_session_metrics() override { ccf::SessionMetrics sm; std::lock_guard guard(interfaces_mutex); for (auto& [name, li] : interfaces) { + const auto counts = error_counts->get(name); ccf::SessionMetrics::Errors errs{}; - errs.parsing = li->err_parsing.load(); - errs.request_payload_too_large = li->err_payload_too_large.load(); - errs.request_header_too_large = li->err_header_too_large.load(); + errs.parsing = counts.parsing; + errs.request_payload_too_large = counts.payload_too_large; + errs.request_header_too_large = counts.header_too_large; sm.interfaces[name] = { li->open_sessions.load(), @@ -905,39 +953,5 @@ namespace ccf std::lock_guard guard(subsystems_mutex); commit_callbacks_subsystem = std::move(fcss); } - - // ----- ErrorReporter ---------------------------------------------------- - - void report_parsing_error(const ccf::ListenInterfaceID& id) override - { - std::lock_guard guard(interfaces_mutex); - auto it = interfaces.find(id); - if (it != interfaces.end()) - { - it->second->err_parsing++; - } - } - - void report_request_payload_too_large_error( - const ccf::ListenInterfaceID& id) override - { - std::lock_guard guard(interfaces_mutex); - auto it = interfaces.find(id); - if (it != interfaces.end()) - { - it->second->err_payload_too_large++; - } - } - - void report_request_header_too_large_error( - const ccf::ListenInterfaceID& id) override - { - std::lock_guard guard(interfaces_mutex); - auto it = interfaces.find(id); - if (it != interfaces.end()) - { - it->second->err_header_too_large++; - } - } }; } diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp index 1967de4b0d97..b9820397907c 100644 --- a/src/host/test/openssl_server_test.cpp +++ b/src/host/test/openssl_server_test.cpp @@ -341,7 +341,7 @@ namespace struct EchoServer { - std::unique_ptr server; + std::shared_ptr server; UVLoopRunner loop; EchoServer( @@ -349,11 +349,8 @@ namespace const std::string& key, const std::string& host = "127.0.0.1") { - server = std::make_unique( - cert, - key, - host, - static_cast(0), + server = std::make_shared( + OpenSSLServer::Config{.host = host, .cert_pem = cert, .key_pem = key}, [this]( uint64_t id, std::vector d, @@ -365,7 +362,7 @@ namespace ~EchoServer() { - server->stop(); + server->stop(OpenSSLServer::LoopState::Running); } uint16_t port() const @@ -385,7 +382,8 @@ namespace {} void handle_incoming_data( - std::span data, sockaddr_storage /*addr*/ = {}) override + std::span data, + const ccf::SessionEndpoint& /*peer*/ = {}) override { writer.write_outbound(id, data); } @@ -415,7 +413,7 @@ namespace void handle_incoming_data( std::span /*data*/, - sockaddr_storage /*addr*/ = {}) override + const ccf::SessionEndpoint& /*peer*/ = {}) override { writer.write_outbound(id, payload); writer.close_socket(id); @@ -443,11 +441,9 @@ TEST_CASE("Connections are admitted at accept time and released once") // Refuse everything after the first two connections, as a hard cap would. constexpr size_t cap = 2; - OpenSSLServer server( - cert, - key, - "127.0.0.1", - static_cast(0), + auto server = std::make_shared( + OpenSSLServer::Config{ + .host = "127.0.0.1", .cert_pem = cert, .key_pem = key}, [&]( ::tcp::ConnID, std::vector, const std::vector&, bool) { std::lock_guard guard(m); @@ -459,11 +455,6 @@ TEST_CASE("Connections are admitted at accept time and released once") closed.push_back(id); cv.notify_all(); }, - "", - false, - false, - nullptr, - std::nullopt, [&](::tcp::ConnID id) -> std::optional { std::lock_guard guard(m); if (admitted.size() >= cap) @@ -475,8 +466,8 @@ TEST_CASE("Connections are admitted at accept time and released once") return false; }); UVLoopRunner loop; - server.start(); - const auto port = server.port(); + server->start(); + const auto port = server->port(); loop.start(); const auto connect_tls = [port](int& fd, SSL_CTX*& ctx, SSL*& ssl) { @@ -550,29 +541,50 @@ TEST_CASE("Connections are admitted at accept time and released once") std::set<::tcp::ConnID>(admitted.begin(), admitted.end())); } - server.stop(); + server->stop(OpenSSLServer::LoopState::Running); } -TEST_CASE("Transports stop cleanly before the libuv loop starts") +// Node startup can fail after the interfaces are listening but before the +// event loop is ever entered, so stopping without a running loop has to be +// safe. The servers do not drive the loop themselves: they hand their handles +// to uv_close and return. Each handle owns itself, so the servers can then be +// destroyed immediately, and the loop reclaims the handles whenever it next +// runs - or never, for a process on its way out. +TEST_CASE("Transports stop and are destroyed before the libuv loop ever runs") { auto [cert, key] = make_server_cert(); - OpenSSLServer tcp_server( - cert, - key, - "127.0.0.1", - 0, - [](::tcp::ConnID, std::vector, const std::vector&, bool) { - }); - tcp_server.start(); - tcp_server.stop(); + { + auto tcp_server = std::make_shared( + OpenSSLServer::Config{ + .host = "127.0.0.1", .cert_pem = cert, .key_pem = key}, + []( + ::tcp::ConnID, + std::vector, + const std::vector&, + bool) {}); + tcp_server->start(); + tcp_server->stop(); + + DatagramServer udp_server( + "127.0.0.1", + 0, + [](const uint8_t*, size_t, const sockaddr_storage&, socklen_t) {}); + udp_server.start(); + udp_server.stop(); - DatagramServer udp_server( - "127.0.0.1", - 0, - [](const uint8_t*, size_t, const sockaddr_storage&, socklen_t) {}); - udp_server.start(); - udp_server.stop(); + // Both servers go out of scope here, while their handles are still + // registered with the loop and awaiting their close callbacks. + } + // Which the loop can now run without touching the destroyed servers. This + // mirrors the drain run.cpp performs after its event loop exits. + constexpr size_t max_iterations = 100; + size_t iterations = 0; + while (uv_loop_alive(uv_default_loop()) != 0 && iterations < max_iterations) + { + uv_run(uv_default_loop(), UV_RUN_NOWAIT); + ++iterations; + } REQUIRE(uv_loop_alive(uv_default_loop()) == 0); } @@ -588,22 +600,20 @@ TEST_CASE("Transport shutdown drains TLS tasks with no background workers") ccf::tasks::set_task_threads(0); auto [cert, key] = make_server_cert(); - OpenSSLServer server( - cert, - key, - "127.0.0.1", - 0, + auto server = std::make_shared( + OpenSSLServer::Config{ + .host = "127.0.0.1", .cert_pem = cert, .key_pem = key}, [](::tcp::ConnID, std::vector, const std::vector&, bool) { }); UVLoopRunner loop; - server.start(); + server->start(); loop.start(); const int fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); REQUIRE(fd >= 0); sockaddr_in addr{}; addr.sin_family = AF_INET; - addr.sin_port = htons(server.port()); + addr.sin_port = htons(server->port()); REQUIRE(inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) == 1); REQUIRE(::connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0); const uint8_t byte = 0; @@ -618,17 +628,16 @@ TEST_CASE("Transport shutdown drains TLS tasks with no background workers") } REQUIRE(ccf::tasks::get_main_job_board().get_summary().pending_tasks > 0); - server.stop(); + server->stop(OpenSSLServer::LoopState::Running); ::close(fd); } // Shutdown must not declare itself complete on a transient lull in the // pending-close count. Connections close one at a time, and libuv runs close // callbacks at the end of each loop iteration, so a connection which tears -// down promptly drives the count back to zero in an iteration where other -// connections - and the listener, timer and async handles - are still open. +// down promptly can look like the last one while others are still open. // Reporting "stopped" there lets the caller destroy the server while the loop -// still owns its handles, and uv_loop_close() then fails with EBUSY. +// is still going to use its handles. // // The stagger is produced by holding some connections' workers inside on_data // while one connection is left idle and therefore closes immediately. @@ -645,11 +654,13 @@ TEST_CASE("Shutdown with staggered connection closes releases every uv handle") size_t arrived = 0; bool release = false; - auto server = std::make_unique( - cert, - key, - "127.0.0.1", - static_cast(0), + auto server = std::make_shared( + OpenSSLServer::Config{ + .host = "127.0.0.1", + .cert_pem = cert, + .key_pem = key, + // Configure an idle timeout, so the timer handle is in play as well. + .idle_timeout = std::chrono::milliseconds(60000)}, [&]( ::tcp::ConnID id, std::vector, @@ -672,14 +683,7 @@ TEST_CASE("Shutdown with staggered connection closes releases every uv handle") cv.wait(lock, [&]() { return release; }); } } - }, - OpenSSLServer::OnClose{}, - "", - false, - false, - nullptr, - // Configure an idle timeout, so the timer handle is in play as well. - std::chrono::milliseconds(60000)); + }); UVLoopRunner loop; server->start(); const auto port = server->port(); @@ -737,19 +741,23 @@ TEST_CASE("Shutdown with staggered connection closes releases every uv handle") cv.notify_all(); }); - server->stop(); - - // stop() has returned, so shutdown claims to be complete. That must mean - // every handle the server owned - per-connection polls, the listener poll, - // the idle timer and the async - has actually been closed. - REQUIRE(uv_loop_alive(uv_default_loop()) == 0); + server->stop(OpenSSLServer::LoopState::Running); releaser.join(); for (auto& t : clients) { t.join(); } + + // stop() has returned, so nothing refers to the handles any more and the + // server can be destroyed even though the loop has not yet reclaimed them. server.reset(); + + // The loop then finishes of its own accord, which it can only do once every + // handle has been closed - so if any had been missed, or if a close callback + // touched the destroyed server, this would hang or crash rather than pass. + loop.thread.join(); + REQUIRE(uv_loop_alive(uv_default_loop()) == 0); } TEST_CASE("TCP connections use the legacy latency and keepalive options") @@ -797,11 +805,9 @@ TEST_CASE("TLS processing runs off the libuv thread") std::mutex callback_mutex; std::thread::id callback_thread; OpenSSLServer* server_ptr = nullptr; - OpenSSLServer server( - cert, - key, - "127.0.0.1", - 0, + auto server = std::make_shared( + OpenSSLServer::Config{ + .host = "127.0.0.1", .cert_pem = cert, .key_pem = key}, [&]( ::tcp::ConnID id, std::vector data, @@ -814,19 +820,19 @@ TEST_CASE("TLS processing runs off the libuv thread") server_ptr->send(id, data.data(), data.size()); }); UVLoopRunner loop; - server_ptr = &server; - server.start(); + server_ptr = server.get(); + server->start(); loop.start(); const std::vector msg = {'w', 'o', 'r', 'k', 'e', 'r'}; - REQUIRE(tls_client_exchange(server.port(), msg, msg.size()) == msg); + REQUIRE(tls_client_exchange(server->port(), msg, msg.size()) == msg); { std::lock_guard guard(callback_mutex); REQUIRE(callback_thread != std::thread::id{}); REQUIRE(callback_thread != loop.thread.get_id()); } - server.stop(); + server->stop(OpenSSLServer::LoopState::Running); } TEST_CASE("Large transfer exercises the backpressure path") @@ -861,11 +867,9 @@ TEST_CASE("A single large write past the per-pass read cap is fully delivered") std::condition_variable cv; size_t received = 0; - OpenSSLServer server( - cert, - key, - "127.0.0.1", - static_cast(0), + auto server = std::make_shared( + OpenSSLServer::Config{ + .host = "127.0.0.1", .cert_pem = cert, .key_pem = key}, [&]( ::tcp::ConnID, std::vector data, @@ -876,7 +880,7 @@ TEST_CASE("A single large write past the per-pass read cap is fully delivered") cv.notify_all(); }); UVLoopRunner loop; - server.start(); + server->start(); loop.start(); // Comfortably more than the 64KiB per-pass cap, written in one go and @@ -889,7 +893,7 @@ TEST_CASE("A single large write past the per-pass read cap is fully delivered") REQUIRE(fd >= 0); sockaddr_in addr{}; addr.sin_family = AF_INET; - addr.sin_port = htons(server.port()); + addr.sin_port = htons(server->port()); REQUIRE(inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) == 1); REQUIRE( ::connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0); @@ -935,7 +939,7 @@ TEST_CASE("A single large write past the per-pass read cap is fully delivered") } client.join(); - server.stop(); + server->stop(OpenSSLServer::LoopState::Running); } TEST_CASE("Concurrent connections") @@ -996,11 +1000,9 @@ TEST_CASE("Reply from a worker thread") } }); - OpenSSLServer server( - cert, - key, - "127.0.0.1", - static_cast(0), + auto server = std::make_shared( + OpenSSLServer::Config{ + .host = "127.0.0.1", .cert_pem = cert, .key_pem = key}, [&]( uint64_t id, std::vector d, const std::vector&, bool) { { @@ -1010,14 +1012,14 @@ TEST_CASE("Reply from a worker thread") cv.notify_one(); }); UVLoopRunner loop; - sp = &server; - server.start(); + sp = server.get(); + server->start(); loop.start(); const std::vector msg = {'w', 'o', 'r', 'k', 'e', 'r'}; - REQUIRE(tls_client_exchange(server.port(), msg, msg.size()) == msg); + REQUIRE(tls_client_exchange(server->port(), msg, msg.size()) == msg); - server.stop(); + server->stop(OpenSSLServer::LoopState::Running); { std::lock_guard l(m); stop.store(true); @@ -1068,7 +1070,7 @@ TEST_CASE("Datagram server round-trip on the libuv reactor") REQUIRE(response == message); ::close(fd); - server.stop(); + server.stop(DatagramServer::LoopState::Running); } // The datagram handler runs inline on the libuv thread and, in the real @@ -1134,7 +1136,7 @@ TEST_CASE("Datagram server stops cleanly while datagrams are still arriving") // Stops while the flood is still in flight. If this deadlocks, the test // hangs rather than failing, which is the intended signal. - server.stop(); + server.stop(DatagramServer::LoopState::Running); sending.store(false); flooder.join(); @@ -1146,10 +1148,7 @@ TEST_CASE("Session bridge: round-trip via ccf::Session + SessionWriter") { auto [cert, key] = make_server_cert(); OpenSSLSessionManager mgr( - cert, - key, - "127.0.0.1", - static_cast(0), + {.host = "127.0.0.1", .cert_pem = cert, .key_pem = key}, [](::tcp::ConnID id, ccf::SessionWriter& w, std::vector, bool) { return std::make_shared(id, w); }); @@ -1161,17 +1160,14 @@ TEST_CASE("Session bridge: round-trip via ccf::Session + SessionWriter") const std::vector msg = {'b', 'r', 'i', 'd', 'g', 'e'}; REQUIRE(tls_client_exchange(mgr.port(), msg, msg.size()) == msg); - mgr.stop(); + mgr.stop(OpenSSLServer::LoopState::Running); } TEST_CASE("Session bridge: large transfer via ccf::Session + SessionWriter") { auto [cert, key] = make_server_cert(); OpenSSLSessionManager mgr( - cert, - key, - "127.0.0.1", - static_cast(0), + {.host = "127.0.0.1", .cert_pem = cert, .key_pem = key}, [](::tcp::ConnID id, ccf::SessionWriter& w, std::vector, bool) { return std::make_shared(id, w); }); @@ -1183,7 +1179,7 @@ TEST_CASE("Session bridge: large transfer via ccf::Session + SessionWriter") const auto resp = tls_client_exchange(mgr.port(), payload, payload.size()); REQUIRE(resp == payload); - mgr.stop(); + mgr.stop(OpenSSLServer::LoopState::Running); } // The server must request the client certificate during the handshake so it is @@ -1199,10 +1195,7 @@ TEST_CASE("Peer certificate is captured for inbound connections") std::atomic got{false}; OpenSSLSessionManager mgr( - cert, - key, - "127.0.0.1", - static_cast(0), + {.host = "127.0.0.1", .cert_pem = cert, .key_pem = key}, [&]( ::tcp::ConnID id, ccf::SessionWriter& w, std::vector pc, bool) { { @@ -1225,7 +1218,7 @@ TEST_CASE("Peer certificate is captured for inbound connections") std::lock_guard l(m); REQUIRE(!captured.empty()); - mgr.stop(); + mgr.stop(OpenSSLServer::LoopState::Running); } // The server deliberately does not enforce client certificate validity: it @@ -1240,10 +1233,7 @@ TEST_CASE("Client certificate is requested but not enforced") std::atomic got{false}; OpenSSLSessionManager mgr( - cert, - key, - "127.0.0.1", - static_cast(0), + {.host = "127.0.0.1", .cert_pem = cert, .key_pem = key}, [&]( ::tcp::ConnID id, ccf::SessionWriter& w, std::vector pc, bool) { { @@ -1264,7 +1254,7 @@ TEST_CASE("Client certificate is requested but not enforced") std::lock_guard l(m); REQUIRE(captured.empty()); - mgr.stop(); + mgr.stop(OpenSSLServer::LoopState::Running); } // The server certificate must be verifiable by a client that trusts the CA @@ -1397,10 +1387,7 @@ TEST_CASE("Graceful close flushes buffered response without truncation") const auto payload = random_bytes(4 * 1024 * 1024); OpenSSLSessionManager mgr( - cert, - key, - "127.0.0.1", - static_cast(0), + {.host = "127.0.0.1", .cert_pem = cert, .key_pem = key}, [&payload]( ::tcp::ConnID id, ccf::SessionWriter& w, std::vector, bool) { return std::make_shared(id, w, payload); @@ -1414,7 +1401,7 @@ TEST_CASE("Graceful close flushes buffered response without truncation") REQUIRE(resp.size() == payload.size()); REQUIRE(resp == payload); - mgr.stop(); + mgr.stop(OpenSSLServer::LoopState::Running); } // Multiple sequential requests on a single kept-alive TLS connection - the node diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index b071afc113bd..b3aa41087bc6 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -20,9 +20,7 @@ #include #include #include -#include #include -#include #include #include #include @@ -95,7 +93,7 @@ namespace asynchost } } - class OpenSSLServer + class OpenSSLServer : public std::enable_shared_from_this { public: // Invoked on a worker with a complete chunk of decrypted bytes, the @@ -127,17 +125,110 @@ namespace asynchost // resource here and release it in OnClose. using OnAccept = std::function(::tcp::ConnID conn_id)>; + // Whether anything is running the libuv loop when stop() is called. See + // stop() for why this has to be stated rather than inferred. + enum class LoopState : uint8_t + { + Running, + NotRunning, + }; + + // Everything about a server which is not a callback. Aggregate-initialised + // at the call site so each setting is named, rather than being a run of + // positional arguments where a transposition would compile silently. + // + // Every field has a default member initialiser, so a call site can name + // only the settings it cares about without tripping + // -Wmissing-field-initializers. That is also why the `= {}` on the string + // members cannot be dropped, despite looking redundant on its own. + // NOLINTBEGIN(readability-redundant-member-init) + struct Config + { + // Address to bind. Resolved with getaddrinfo, so hostnames ("localhost") + // and IPv6 literals ("::1") work, not just IPv4 literals. Port 0 + // requests an ephemeral port, which port() reports back once bound. + std::string host = {}; + uint16_t port = 0; + + // Server certificate and key. May be empty for a TLS interface whose + // certificate is not yet known - the interface then refuses connections + // until set_server_cert() supplies one. Ignored when `plaintext` is set. + std::string cert_pem = {}; + std::string key_pem = {}; + + // ALPN protocol to advertise, e.g. "h2" or "http/1.1". Empty disables + // ALPN. + std::string alpn = {}; + + // UNSECURED interface: no TLS at all, raw socket reads and writes. + bool plaintext = false; + + // Close a connection after this much inactivity. nullopt keeps idle + // connections open indefinitely. + std::optional idle_timeout = std::nullopt; + + // Shared connection-id source, so that servers on different interfaces + // allocate ids from a single space - required for a global session + // registry and reply routing. Null uses a per-server counter. + std::atomic<::tcp::ConnID>* shared_next_id = nullptr; + + // Loop to register this server's handles on. + uv_loop_t* loop = uv_default_loop(); + }; + // NOLINTEND(readability-redundant-member-init) + private: static constexpr size_t read_chunk = 16384; static constexpr size_t max_read_per_event = read_chunk * 4; + // Heap-allocate a libuv handle whose lifetime is independent of this + // server, and close it so that it frees itself. + // + // uv_close() is asynchronous: the close callback only runs when the loop + // next runs, which may be long after the server has been destroyed - or + // never, if node startup failed before the event loop was entered. If + // handles were members, destruction would have to block until the loop had + // drained them, which in turn would force the server to drive the loop + // itself. Owning each handle separately means shutdown is fire-and-forget: + // request the close, drop the handle, and let the loop reclaim it whenever + // it next runs. + // + // Nothing dereferences handle->data after uv_close(), because libuv + // guarantees no further callbacks for a handle beyond its close callback, + // and that callback does nothing but free the handle. + template + static THandle* new_handle() + { + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) + return new THandle{}; + } + + template + static void close_handle(THandle*& handle) + { + if (handle == nullptr) + { + return; + } + auto* as_handle = reinterpret_cast(handle); + handle = nullptr; + if (uv_is_closing(as_handle) != 0) + { + return; + } + uv_close(as_handle, [](uv_handle_t* h) { + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) + delete reinterpret_cast(h); + }); + } + struct OutItem; struct Conn { OpenSSLServer* owner = nullptr; int fd = -1; - uv_poll_t poll{}; + uv_poll_t* poll = nullptr; std::shared_ptr tls_tasks; std::shared_ptr accepted_ctx; SSL* ssl = nullptr; @@ -206,9 +297,6 @@ namespace asynchost std::atomic<::tcp::ConnID>* shared_next_id = nullptr; std::chrono::steady_clock::time_point last_idle_sweep = std::chrono::steady_clock::now(); - size_t pending_uv_closes = 0; - std::thread::id initialising_thread_id; - std::thread::id loop_thread_id; // Close a connection after this much inactivity (no I/O); nullopt disables // idle closure. A libuv timer wakes every idle_sweep_interval_ms to check. @@ -233,42 +321,56 @@ namespace asynchost std::mutex out_mutex; std::mutex lifecycle_mutex; - std::condition_variable stopped_cv; + std::condition_variable teardown_cv; std::unordered_map> conns; - std::unordered_map> closing_conns; std::unordered_map<::tcp::ConnID, int> id_to_fd; - uv_async_t wake_handle{}; - uv_timer_t idle_timer{}; - uv_poll_t listen_poll{}; + // Owned by the loop once closed - see new_handle()/close_handle(). + uv_async_t* wake_handle = nullptr; + uv_timer_t* idle_timer = nullptr; + uv_poll_t* listen_poll = nullptr; int listen_fd = -1; uint16_t bound_port = 0; // Plaintext (UNSECURED) interface: no TLS, raw socket I/O. bool plaintext = false; - bool listen_poll_initialised = false; - bool wake_handle_initialised = false; - bool idle_timer_initialised = false; - bool verbose = false; bool started = false; bool stopping = false; - bool shutdown_started = false; - // Set once every connection has gone *and* the server's own handles have - // been handed to uv_close(). Until then pending_uv_closes reaching zero is - // only a lull between connection closures, not the end of shutdown. - bool handles_closing = false; - bool stopped = false; - bool loop_thread_seen = false; - - void logf(const char* fmt, ...) const + // Set once the teardown has run: every connection dropped, every handle + // handed to uv_close(), and the listening socket closed. The handles may + // not have been reclaimed by the loop yet, but nothing here refers to + // them any more, so the server is safe to destroy. + bool torn_down = false; + + // Describe a failed SSL operation. SSL_get_error() only gives the + // category: for SSL_ERROR_SSL the detail is in the (thread-local) error + // queue, and for SSL_ERROR_SYSCALL it may be in errno instead. Consuming + // the queue entry here also keeps it from being misattributed to the next + // operation this worker performs. + static std::string ssl_error_string(int ssl_error) { - if (!verbose) + switch (ssl_error) { - return; + case SSL_ERROR_ZERO_RETURN: + return "peer closed the TLS connection"; + + case SSL_ERROR_SSL: + case SSL_ERROR_SYSCALL: + { + const auto err = ERR_get_error(); + if (err != 0) + { + return ccf::crypto::OpenSSL::error_string(err); + } + if (ssl_error == SSL_ERROR_SYSCALL) + { + return fmt::format( + "syscall failed: {}", std::generic_category().message(errno)); + } + return "protocol error"; + } + + default: + return fmt::format("SSL_get_error {}", ssl_error); } - va_list args; // NOLINT - va_start(args, fmt); - (void)std::vfprintf(stderr, fmt, args); - (void)std::fputc('\n', stderr); - va_end(args); } static bool set_nonblocking(int fd) @@ -406,11 +508,18 @@ namespace asynchost void update_interest(Conn& c) const { + if (c.poll == nullptr) + { + return; + } const int events = UV_READABLE | (c.want_write ? UV_WRITABLE : 0); - const int rc = uv_poll_start(&c.poll, events, on_connection_poll); + const int rc = uv_poll_start(c.poll, events, on_connection_poll); if (rc != 0) { - logf("uv_poll_start error: %s", uv_strerror(rc)); + // Should not happen. If it does the connection will make no further + // progress, so it is worth reporting loudly rather than hiding. + LOG_FAIL_FMT( + "uv_poll_start failed for connection {}: {}", c.id, uv_strerror(rc)); } } @@ -471,7 +580,7 @@ namespace asynchost } X509_free(cert); } - logf("conn %llu: handshake complete", (unsigned long long)c.id); + LOG_TRACE_FMT("Connection {}: handshake complete", c.id); return do_read(c, more_to_read) && do_write(c); } @@ -486,7 +595,11 @@ namespace asynchost c.want_write = true; return true; } - logf("conn %llu: handshake error %d", (unsigned long long)c.id, e); + // Entirely client-controlled (an unsupported cipher, a rejected + // certificate, or simply a port scan), so this must stay at a level + // which cannot be used to flood the log. + LOG_DEBUG_FMT( + "Connection {}: handshake failed: {}", c.id, ssl_error_string(e)); return false; } @@ -652,7 +765,10 @@ namespace asynchost // A renegotiation needs to read before we can write more. return true; } - logf("conn %llu: write err %d", (unsigned long long)c.id, e); + // Usually just the peer having gone away mid-response, so again a + // level which a client cannot use to flood the log. + LOG_DEBUG_FMT( + "Connection {}: write failed: {}", c.id, ssl_error_string(e)); return false; } @@ -676,14 +792,11 @@ namespace asynchost } // Unlike wake(), this must signal even while stopping: shutdown only // completes once the loop has observed every outstanding completion and - // closed the corresponding connection. The wake handle is guaranteed to - // still be open here, because finish_stopping_on_loop() only closes it - // once `conns` is empty, and a connection with a running worker has not - // yet been removed from `conns`. + // closed the corresponding connection. std::lock_guard guard(lifecycle_mutex); - if (wake_handle_initialised) + if (wake_handle != nullptr) { - (void)uv_async_send(&wake_handle); + (void)uv_async_send(wake_handle); } } @@ -763,15 +876,23 @@ namespace asynchost { return; } - (void)uv_poll_stop(&conn->poll); + if (conn->poll != nullptr) + { + (void)uv_poll_stop(conn->poll); + } conn->worker_active = true; DriveInput input; input.events = std::exchange(conn->pending_events, 0); input.commands.swap(conn->pending_commands); input.close_requested = std::exchange(conn->close_requested, false); + // The worker keeps the server alive for the whole pass. complete_drive() + // posts its result and only then touches lifecycle_mutex and + // wake_handle; without this the loop could consume that result, finish + // the teardown and let stop() return in between, destroying the server + // underneath the worker. conn->tls_tasks->add_action(ccf::tasks::make_basic_action( - [this, conn, input = std::move(input)]() mutable { - drive_connection(conn, std::move(input)); + [self = shared_from_this(), conn, input = std::move(input)]() mutable { + self->drive_connection(conn, std::move(input)); }, "OpenSSLServer::drive_connection")); } @@ -783,26 +904,24 @@ namespace asynchost { return; } + auto conn = it->second; if (on_close) { - on_close(it->second->id); + on_close(conn->id); } - id_to_fd.erase(it->second->id); - auto conn = it->second; + id_to_fd.erase(conn->id); conns.erase(it); - (void)uv_poll_stop(&conn->poll); + + if (conn->poll != nullptr) + { + (void)uv_poll_stop(conn->poll); + } assert(conn->ssl == nullptr); + // The poll handle frees itself, so the Conn can be dropped here rather + // than being parked until the close callback runs. + close_handle(conn->poll); ::close(fd); conn->fd = -1; - auto* raw = conn.get(); - closing_conns.emplace(raw, std::move(conn)); - { - std::lock_guard guard(lifecycle_mutex); - ++pending_uv_closes; - } - uv_close( - reinterpret_cast(&raw->poll), on_connection_poll_closed); - finish_stopping_on_loop(); } void accept_all() @@ -827,8 +946,12 @@ namespace asynchost continue; } const auto err = errno; - logf( - "accept error: %s", std::generic_category().message(err).c_str()); + // The listening socket is level-triggered, so a persistent failure + // (notably EMFILE once the process is out of file descriptors) is + // re-reported on every loop iteration. Keep it out of the default + // log for that reason. + LOG_DEBUG_FMT( + "accept4 failed: {}", std::generic_category().message(err)); break; } @@ -905,37 +1028,40 @@ namespace asynchost c->accepted_ctx = ctx; } - const int poll_rc = uv_poll_init_socket(loop, &c->poll, cfd); + c->poll = new_handle(); + const int poll_rc = uv_poll_init_socket(loop, c->poll, cfd); if (poll_rc != 0) { - logf("uv_poll_init_socket error: %s", uv_strerror(poll_rc)); + LOG_FAIL_FMT( + "uv_poll_init_socket failed for connection {}: {}", + cid, + uv_strerror(poll_rc)); + // Never registered with the loop, so free it directly rather than + // going through uv_close. + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) + delete c->poll; + c->poll = nullptr; ::close(cfd); release_admitted(); continue; } - c->poll.data = c.get(); + c->poll->data = c.get(); const int start_rc = - uv_poll_start(&c->poll, UV_READABLE, on_connection_poll); + uv_poll_start(c->poll, UV_READABLE, on_connection_poll); if (start_rc != 0) { - logf("uv_poll_start error: %s", uv_strerror(start_rc)); + LOG_FAIL_FMT( + "uv_poll_start failed for connection {}: {}", + cid, + uv_strerror(start_rc)); + close_handle(c->poll); ::close(cfd); - c->fd = -1; - auto* raw = c.get(); - closing_conns.emplace(raw, std::move(c)); - { - std::lock_guard guard(lifecycle_mutex); - ++pending_uv_closes; - } - uv_close( - reinterpret_cast(&raw->poll), - on_connection_poll_closed); release_admitted(); continue; } conns.emplace(cfd, std::move(c)); id_to_fd.emplace(cid, cfd); - logf("accepted conn on fd %d", cfd); + LOG_TRACE_FMT("Accepted connection {} on fd {}", cid, cfd); } } @@ -952,18 +1078,10 @@ namespace asynchost dispatch_connection(c); } - void mark_loop_thread() - { - std::lock_guard guard(lifecycle_mutex); - loop_thread_id = std::this_thread::get_id(); - loop_thread_seen = true; - } - static void on_connection_poll(uv_poll_t* handle, int status, int events) { auto* conn = static_cast(handle->data); auto* self = conn->owner; - self->mark_loop_thread(); if (status < 0) { auto it = self->conns.find(conn->fd); @@ -977,20 +1095,12 @@ namespace asynchost self->on_conn_event(conn->fd, events); } - static void on_connection_poll_closed(uv_handle_t* handle) - { - auto* conn = static_cast(handle->data); - auto* self = conn->owner; - self->closing_conns.erase(conn); - self->complete_uv_close(); - } - void wake() { std::lock_guard guard(lifecycle_mutex); - if (wake_handle_initialised && !stopping) + if (wake_handle != nullptr && !stopping) { - (void)uv_async_send(&wake_handle); + (void)uv_async_send(wake_handle); } } @@ -1091,6 +1201,18 @@ namespace asynchost update_interest(*conn); } } + + bool shutting_down = false; + { + std::lock_guard guard(lifecycle_mutex); + shutting_down = stopping && !torn_down; + } + if (shutting_down) + { + // Some of those connections may have just gone, which may be the last + // thing shutdown was waiting for. + tear_down_on_loop(); + } } // Close connections idle longer than idle_timeout (loop thread). @@ -1111,10 +1233,10 @@ namespace asynchost } for (const int fd : to_close) { - logf("closing idle connection on fd %d", fd); auto it = conns.find(fd); if (it != conns.end()) { + LOG_DEBUG_FMT("Closing idle connection {}", it->second->id); it->second->close_requested = true; dispatch_connection(it->second); } @@ -1124,10 +1246,9 @@ namespace asynchost static void on_listen_poll(uv_poll_t* handle, int status, int events) { auto* self = static_cast(handle->data); - self->mark_loop_thread(); if (status < 0) { - self->request_stop_on_loop(); + self->tear_down_on_loop(); return; } if ((events & UV_READABLE) != 0) @@ -1139,174 +1260,148 @@ namespace asynchost static void on_wake(uv_async_t* handle) { auto* self = static_cast(handle->data); - self->mark_loop_thread(); - bool should_stop = false; - { - std::lock_guard guard(self->lifecycle_mutex); - should_stop = self->stopping; - } - if (should_stop) - { - self->request_stop_on_loop(); - } + // Always drain, including while shutting down: shutdown completes only + // once every connection worker's completion has been processed and its + // connection closed, and drain_pending_out() resumes the teardown once + // it has done so. self->drain_pending_out(); } static void on_idle_timer(uv_timer_t* handle) { auto* self = static_cast(handle->data); - self->mark_loop_thread(); self->sweep_idle(); } - static void on_server_handle_closed(uv_handle_t* handle) - { - auto* self = static_cast(handle->data); - self->complete_uv_close(); - } - - void complete_uv_close() - { - std::lock_guard guard(lifecycle_mutex); - if (pending_uv_closes > 0) - { - --pending_uv_closes; - } - // Only finish once finish_stopping_on_loop() has queued the listener, - // timer and async handles for closure. Connections close one at a time, - // so without the handles_closing gate the first connection to finish - // closing would drive the count to zero and declare shutdown complete - // while other connections, and all three server handles, were still - // open - leaving uv_loop_close() to fail with EBUSY. - if (stopping && handles_closing && pending_uv_closes == 0) - { - stopped = true; - stopped_cv.notify_all(); - } - } - - // Requires lifecycle_mutex to be held by the caller. uv_close() never runs - // its callback synchronously, so complete_uv_close() cannot re-enter the - // lock from here. - void close_server_handle(uv_handle_t* handle) - { - if (uv_is_closing(handle) == 0) - { - ++pending_uv_closes; - uv_close(handle, on_server_handle_closed); - } - } - - void request_stop_on_loop() + // Begin, or resume, shutdown on the loop thread. + // + // The listener closes immediately and every live connection is asked to + // close, but the server's own handles can only go once those connections + // have actually gone: a connection whose worker is still running is still + // reading and writing its socket, so neither its fd nor its poll handle + // may be touched here. drain_pending_out() calls back in as each worker + // completes, and the last call finishes the job. + // + // Nothing here waits for the uv close callbacks. Each handle owns itself + // (see new_handle()), so once torn_down is set nothing refers to them any + // more and the server is safe to destroy, whether or not the loop ever + // runs again. + void tear_down_on_loop() { { std::lock_guard guard(lifecycle_mutex); - if (shutdown_started) + if (torn_down) { return; } stopping = true; - shutdown_started = true; } - // May run before start() finished initialising, if start() threw part - // way through, so the handle may not exist yet. - if (listen_poll_initialised) + + if (listen_poll != nullptr) { - (void)uv_poll_stop(&listen_poll); + (void)uv_poll_stop(listen_poll); + close_handle(listen_poll); } if (listen_fd >= 0) { ::close(listen_fd); listen_fd = -1; } + for (auto& [fd, conn] : conns) { conn->close_requested = true; dispatch_connection(conn); } - finish_stopping_on_loop(); - } - - // Loop thread. Completes shutdown once every connection has gone: stops - // the idle timer and closes the server's own uv handles. - // - // Every flag touched here is also read from other threads (see wake() and - // complete_drive()), so the whole body runs under lifecycle_mutex, and - // each *_initialised flag is cleared *before* the corresponding uv_close(). - // Clearing afterwards would leave a window in which another thread sees - // the handle as usable and calls uv_async_send() on a closing handle. - void finish_stopping_on_loop() - { - std::lock_guard guard(lifecycle_mutex); - if (!stopping || handles_closing || !conns.empty()) + if (!conns.empty()) { + // Workers still own these connections. Finish when they report back. return; } - // Every connection has gone. From here on there is nothing left to - // close but the server's own handles, so once the count reaches zero - // shutdown really is complete. - handles_closing = true; - - if (idle_timer_initialised) + if (idle_timer != nullptr) { - idle_timer_initialised = false; - (void)uv_timer_stop(&idle_timer); - close_server_handle(reinterpret_cast(&idle_timer)); + (void)uv_timer_stop(idle_timer); + close_handle(idle_timer); } - if (listen_poll_initialised) - { - listen_poll_initialised = false; - close_server_handle(reinterpret_cast(&listen_poll)); - } - if (wake_handle_initialised) + { - wake_handle_initialised = false; - close_server_handle(reinterpret_cast(&wake_handle)); + // Cleared under the lock, because send()/close_connection() and + // complete_drive() consult it from other threads. Clearing it before + // the uv_close() means no other thread can observe the handle as + // usable once it is closing. + std::lock_guard guard(lifecycle_mutex); + auto* wake = wake_handle; + wake_handle = nullptr; + close_handle(wake); + + torn_down = true; + teardown_cv.notify_all(); } - if (pending_uv_closes == 0) + } + + // Drive shutdown to completion when nothing is running the loop. Only this + // server's own queues need servicing - no libuv work is required, and no + // other thread can be inside the loop - so this never runs the loop + // itself. + void tear_down_without_loop() + { + for (;;) { - stopped = true; - stopped_cv.notify_all(); + tear_down_on_loop(); + { + std::lock_guard guard(lifecycle_mutex); + if (torn_down) + { + return; + } + } + + // A connection worker is still running. Service the task board so it + // can finish, then pick up its completion. + auto task = ccf::tasks::get_main_job_board().get_task(); + if (task != nullptr) + { + ccf::tasks::try_do_task(*task); + } + else + { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + drain_pending_out(); } } public: + // `on_data` is required; `on_close` and `on_accept` are optional. These + // stay as explicit parameters rather than joining Config so that on_data + // cannot be omitted, and so a transposition is a compile error (their + // signatures differ). OpenSSLServer( - const std::string& cert_pem, - const std::string& key_pem, - const std::string& host, - uint16_t port, + Config config, OnData on_data_, OnClose on_close_ = {}, - const std::string& alpn = "", - bool plaintext_ = false, - bool verbose_ = false, - std::atomic<::tcp::ConnID>* shared_next_id_ = nullptr, - std::optional idle_timeout_ = std::nullopt, - OnAccept on_accept_ = {}, - uv_loop_t* loop_ = uv_default_loop()) : - loop(loop_), - shared_next_id(shared_next_id_), - idle_timeout(idle_timeout_), + OnAccept on_accept_ = {}) : + loop(config.loop), + shared_next_id(config.shared_next_id), + idle_timeout(config.idle_timeout), on_data(std::move(on_data_)), on_close(std::move(on_close_)), on_accept(std::move(on_accept_)), - plaintext(plaintext_), - verbose(verbose_) + plaintext(config.plaintext) { - if (!alpn.empty()) + if (!config.alpn.empty()) { - alpn_wire.push_back(static_cast(alpn.size())); - alpn_wire.append(alpn); + alpn_wire.push_back(static_cast(config.alpn.size())); + alpn_wire.append(config.alpn); } // Plaintext interfaces have no TLS context. TLS interfaces build their // context now if the cert is already available, or defer until // set_server_cert() (e.g. a joining node receiving the service cert). - if (!plaintext && !cert_pem.empty()) + if (!plaintext && !config.cert_pem.empty()) { - ctx = build_server_ctx(cert_pem, key_pem); + ctx = build_server_ctx(config.cert_pem, config.key_pem); if (ctx == nullptr) { throw std::runtime_error("Failed to load server cert/key"); @@ -1321,11 +1416,11 @@ namespace asynchost hints.ai_socktype = SOCK_STREAM; hints.ai_flags = AI_PASSIVE; addrinfo* res = nullptr; - const std::string port_str = std::to_string(port); - if (getaddrinfo(host.c_str(), port_str.c_str(), &hints, &res) != 0) + const std::string port_str = std::to_string(config.port); + if (getaddrinfo(config.host.c_str(), port_str.c_str(), &hints, &res) != 0) { cleanup(); - throw std::runtime_error("getaddrinfo failed for " + host); + throw std::runtime_error("getaddrinfo failed for " + config.host); } const int one = 1; @@ -1364,7 +1459,7 @@ namespace asynchost if (!bound_ok) { cleanup(); - throw std::runtime_error("bind() failed for " + host); + throw std::runtime_error("bind() failed for " + config.host); } if (listen(listen_fd, SOMAXCONN) != 0) { @@ -1419,51 +1514,45 @@ namespace asynchost return; } - // Mark the server started, and reset the lifecycle flags, *before* any - // handle is initialised. If one of the steps below throws, the handles - // which were already initialised are still registered with the loop and - // must be closed, or uv_loop_close() will fail with EBUSY. stop() does - // exactly that, but only when `started` is set - so setting it here is - // what makes a partially-initialised server safe to destroy. - stopped = false; + // Mark the server started before any handle is created. If one of the + // steps below throws, the handles which were already created are still + // registered with the loop and must be closed, and stop() is a no-op + // unless `started` is set. + torn_down = false; stopping = false; - shutdown_started = false; - handles_closing = false; - initialising_thread_id = std::this_thread::get_id(); - loop_thread_seen = false; started = true; - int rc = uv_poll_init_socket(loop, &listen_poll, listen_fd); + listen_poll = new_handle(); + listen_poll->data = this; + int rc = uv_poll_init_socket(loop, listen_poll, listen_fd); if (rc != 0) { throw std::runtime_error( std::string("uv_poll_init_socket(listen) failed: ") + uv_strerror(rc)); } - listen_poll_initialised = true; - listen_poll.data = this; - rc = uv_async_init(loop, &wake_handle, on_wake); + wake_handle = new_handle(); + wake_handle->data = this; + rc = uv_async_init(loop, wake_handle, on_wake); if (rc != 0) { throw std::runtime_error( std::string("uv_async_init failed: ") + uv_strerror(rc)); } - wake_handle_initialised = true; - wake_handle.data = this; if (idle_timeout.has_value()) { - rc = uv_timer_init(loop, &idle_timer); + idle_timer = new_handle(); + idle_timer->data = this; + rc = uv_timer_init(loop, idle_timer); if (rc != 0) { throw std::runtime_error( std::string("uv_timer_init failed: ") + uv_strerror(rc)); } - idle_timer_initialised = true; - idle_timer.data = this; rc = uv_timer_start( - &idle_timer, + idle_timer, on_idle_timer, idle_sweep_interval_ms, idle_sweep_interval_ms); @@ -1474,7 +1563,7 @@ namespace asynchost } } - rc = uv_poll_start(&listen_poll, UV_READABLE, on_listen_poll); + rc = uv_poll_start(listen_poll, UV_READABLE, on_listen_poll); if (rc != 0) { throw std::runtime_error( @@ -1482,60 +1571,54 @@ namespace asynchost } } - void stop() + // Tear the server down. Idempotent, and safe to call from the destructor. + // + // The teardown itself must run where it cannot race the loop, and that is + // not something this class can work out for itself: a loop which is + // running only reveals itself when it first invokes a callback, and for an + // interface which has seen no connections that may never happen. So the + // caller states it. + // + // `loop_state` == Running: another thread is running the loop, so the + // teardown is posted to it and this blocks until it has run. + // + // `loop_state` == NotRunning: nothing is running the loop and nothing + // will - node startup failed before the event loop was entered, or this is + // a test which never started one. The teardown runs inline, which is safe + // precisely because no other thread can be inside the loop, and returns + // without waiting: the handles own themselves, so their close callbacks + // simply never run. + void stop(LoopState loop_state = LoopState::NotRunning) { std::unique_lock lock(lifecycle_mutex); - if (!started || stopped) + if (!started || torn_down) { return; } - if (!stopping) + + if (loop_state == LoopState::NotRunning) { - stopping = true; - // finish_stopping_on_loop() clears wake_handle_initialised before it - // closes the handle, so this cannot signal a handle already closing. - if (wake_handle_initialised) - { - (void)uv_async_send(&wake_handle); - } + lock.unlock(); + tear_down_without_loop(); + return; } - const bool loop_not_started_here = !loop_thread_seen && - std::this_thread::get_id() == initialising_thread_id; - if (loop_not_started_here) + + if (!stopping) { - lock.unlock(); - request_stop_on_loop(); - for (;;) + stopping = true; + // tear_down_on_loop() clears wake_handle under this same lock before + // closing it, so this cannot signal a handle which is already closing. + if (wake_handle != nullptr) { - { - std::lock_guard guard(lifecycle_mutex); - if (stopped) - { - return; - } - } - auto task = ccf::tasks::get_main_job_board().get_task(); - if (task != nullptr) - { - ccf::tasks::try_do_task(*task); - } - (void)uv_run(loop, UV_RUN_NOWAIT); - if (task == nullptr) - { - // Nothing to do but wait for outstanding uv close callbacks. - // uv_run(UV_RUN_NOWAIT) returns immediately, so without this the - // loop below would spin at 100% CPU for the whole shutdown. - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - } + (void)uv_async_send(wake_handle); } } - if (std::this_thread::get_id() == loop_thread_id) - { - lock.unlock(); - request_stop_on_loop(); - return; - } - while (!stopped) + + // The loop performs the teardown. Keep servicing the task board while + // waiting, because an in-flight connection worker may need to complete + // before the loop can finish with it, and this thread may be one of the + // few able to run it. + while (!torn_down) { lock.unlock(); auto task = ccf::tasks::get_main_job_board().get_task(); @@ -1544,9 +1627,9 @@ namespace asynchost ccf::tasks::try_do_task(*task); } lock.lock(); - if (!stopped && task == nullptr) + if (!torn_down && task == nullptr) { - stopped_cv.wait_for(lock, std::chrono::milliseconds(1)); + teardown_cv.wait_for(lock, std::chrono::milliseconds(1)); } } } diff --git a/src/host/tls/openssl_session_manager.h b/src/host/tls/openssl_session_manager.h index ec813e34a3f9..63c81258bfc7 100644 --- a/src/host/tls/openssl_session_manager.h +++ b/src/host/tls/openssl_session_manager.h @@ -56,7 +56,7 @@ namespace asynchost bool soft_limited)>; private: - std::unique_ptr server; + std::shared_ptr server; SessionFactory factory; // Invoked when an admitted connection is torn down, so an owner can // release whatever it reserved at accept time. Called on the loop thread @@ -124,27 +124,18 @@ namespace asynchost } public: + // Takes the transport's own Config verbatim, so there is a single place + // where a listening interface is described. OpenSSLSessionManager( - const std::string& cert_pem, - const std::string& key_pem, - const std::string& host, - uint16_t port, + OpenSSLServer::Config config, SessionFactory factory_, - const std::string& alpn = "", - bool plaintext = false, - bool verbose = false, - std::atomic<::tcp::ConnID>* shared_next_id = nullptr, std::function on_connection_closed_ = {}, - std::optional idle_timeout = std::nullopt, OpenSSLServer::OnAccept on_accept = {}) : factory(std::move(factory_)), on_connection_closed(std::move(on_connection_closed_)) { - server = std::make_unique( - cert_pem, - key_pem, - host, - port, + server = std::make_shared( + std::move(config), [this]( ::tcp::ConnID id, std::vector data, @@ -153,11 +144,6 @@ namespace asynchost on_data(id, std::move(data), peer_cert, soft_limited); }, [this](::tcp::ConnID id) { on_close(id); }, - alpn, - plaintext, - verbose, - shared_next_id, - idle_timeout, std::move(on_accept)); } @@ -181,9 +167,11 @@ namespace asynchost server->start(); } - void stop() + void stop( + OpenSSLServer::LoopState loop_state = + OpenSSLServer::LoopState::NotRunning) { - server->stop(); + server->stop(loop_state); } uint16_t port() const @@ -196,7 +184,7 @@ namespace asynchost void write_outbound( ::tcp::ConnID id, std::span data, - sockaddr_storage /*addr*/ = {}) override + const ccf::SessionEndpoint& /*peer*/ = {}) override { server->send(id, data.data(), data.size()); } From 46a4df27cf949eafbed9ddf414923e9c5bd30c22 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Fri, 7 Aug 2026 10:28:12 +0000 Subject: [PATCH 47/59] Reduce copies --- src/enclave/session.h | 10 ++++++++++ src/host/tls/openssl_server.h | 31 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/enclave/session.h b/src/enclave/session.h index 405ba7a2a3d9..cd757d16457c 100644 --- a/src/enclave/session.h +++ b/src/enclave/session.h @@ -26,12 +26,22 @@ namespace ccf std::vector data; std::shared_ptr self; + // Inbound: the transport owns the buffer it hands over and reuses it as + // soon as this returns, so the bytes must be copied. SessionDataTask( std::span d, std::shared_ptr s) : self(std::move(s)) { data.assign(d.begin(), d.end()); } + + // Outbound: the caller has already built a buffer for us, so take it + // rather than copying a response which may be arbitrarily large. + SessionDataTask( + std::vector&& d, std::shared_ptr s) : + data(std::move(d)), + self(std::move(s)) + {} }; struct HandleIncomingDataTask : public SessionDataTask diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index b3aa41087bc6..cc67c919933c 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -663,6 +663,7 @@ namespace asynchost if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { c.want_write = true; + compact_outbuf(c); return true; } if (n < 0 && errno == EINTR) @@ -732,6 +733,27 @@ namespace asynchost return true; } + // Release the already-written prefix of a partially flushed buffer, so a + // large response which is draining slowly does not keep holding the bytes + // the peer has already received. erase() would retain the original + // capacity, so rebuild into a right-sized buffer instead. Called only when + // a write has just stalled and the remainder is about to be held until the + // socket becomes writable again - compacting a buffer we are about to + // flush anyway would be pure overhead. Only worth the copy once most of + // the buffer is consumed, which also bounds the total copying for one + // response to O(its size). + static void compact_outbuf(Conn& c) + { + if (c.out_off == 0 || c.out_off <= c.outbuf.size() / 2) + { + return; + } + std::vector remaining( + c.outbuf.data() + c.out_off, c.outbuf.data() + c.outbuf.size()); + c.outbuf = std::move(remaining); + c.out_off = 0; + } + // Returns false if the connection should be closed. Implements // backpressure: a WANT_WRITE leaves the remaining plaintext buffered and // arms UV_WRITABLE. @@ -758,6 +780,7 @@ namespace asynchost if (e == SSL_ERROR_WANT_WRITE) { c.want_write = true; + compact_outbuf(c); return true; } if (e == SSL_ERROR_WANT_READ) @@ -827,6 +850,14 @@ namespace asynchost { conn->close_after_flush = true; } + else if (conn->outbuf.empty()) + { + // The common case is a single response with nothing still draining, + // so take ownership of the buffer instead of copying it again. + // outbuf is only ever emptied alongside out_off being reset, so + // there is no consumed prefix to preserve here. + conn->outbuf = std::move(command.data); + } else { conn->outbuf.insert( From d7d2ea4bdcfce3c26da047cfa03adf2ca8cec74b Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Fri, 7 Aug 2026 12:56:11 +0000 Subject: [PATCH 48/59] Restore inbound backpressure --- doc/architecture/tls_internals.rst | 13 ++ src/enclave/session.h | 24 +++ src/enclave/session_writer.h | 12 ++ src/host/rpc_connection_manager.h | 32 ++-- src/host/test/openssl_server_test.cpp | 210 +++++++++++++++++++++++++ src/host/tls/inbound_admission.h | 135 ++++++++++++++++ src/host/tls/openssl_server.h | 72 ++++++++- src/host/tls/openssl_session_manager.h | 60 ++++++- 8 files changed, 542 insertions(+), 16 deletions(-) create mode 100644 src/host/tls/inbound_admission.h diff --git a/doc/architecture/tls_internals.rst b/doc/architecture/tls_internals.rst index 214361bdfa56..d7b684c415c5 100644 --- a/doc/architecture/tls_internals.rst +++ b/doc/architecture/tls_internals.rst @@ -75,6 +75,19 @@ A single pass is capped at a fixed number of bytes so that one busy connection c ``SSL_ERROR_WANT_WRITE`` on a read is not an error: a TLS 1.3 key update needs the socket to become writable, so the connection is left open with ``UV_WRITABLE`` armed. Any other result, whether a clean ``SSL_ERROR_ZERO_RETURN``, an unclean EOF, or a fatal error, closes the connection. +Inbound flow control +~~~~~~~~~~~~~~~~~~~~ + +How much the node reads is bounded by how far behind the application is. ``OpenSSLSessionManager`` charges every chunk it hands to a session against a node-wide budget, and releases it once the session reports that chunk processed. Because request execution happens inside the parse task, "processed" means the request has actually run, so the budget measures unexecuted work rather than merely unparsed bytes. + +While the budget is exhausted, no interface arms ``UV_READABLE``. The sending client's own :term:`TCP` window then closes, and it cannot queue more work than the node can retire. Nothing is dropped or refused: reads simply pause and resume. + +The budget is node-wide rather than per-connection, so a single connection can exhaust it and pause the others. That is deliberate: a per-connection limit would have to be smaller than one maximum-sized request before it could bound total node memory to the same figure. + +Releasing the budget wakes every registered transport, not only the one whose session made room, so an interface with no traffic of its own does not stay paused. A session which never reports - a custom protocol, or one whose queued work is cancelled as it is destroyed - cannot strand the budget either: whatever is still outstanding for a connection is released when that connection is torn down. + +A connection paused this way is performing no I/O, so it ages towards the idle timeout in the usual way. That is intentional: a connection which cannot make progress in either direction for the whole idle period is one the node is too far behind to serve. + Writing and backpressure ~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/enclave/session.h b/src/enclave/session.h index cd757d16457c..364d8f4b6a22 100644 --- a/src/enclave/session.h +++ b/src/enclave/session.h @@ -50,6 +50,19 @@ namespace ccf void do_action() override { + // The transport is holding this data against a node-wide budget until + // we say we are done with it, so report on every exit path - including + // the early return below, and an exception out of the parser. + struct ReportConsumed + { + ThreadedSession& session; + size_t bytes; + ~ReportConsumed() + { + session.on_inbound_consumed(bytes); + } + } report{*self, data.size()}; + if (self->is_closing.load()) { return; @@ -106,6 +119,12 @@ namespace ccf virtual void handle_incoming_data_thread(std::vector&& data) = 0; + // Called once the data from a single handle_incoming_data() has been + // processed, so that the transport can resume reading. Sessions which have + // no transport to report to leave this as a no-op; the transport then + // reclaims their share when the connection closes. + virtual void on_inbound_consumed(size_t /*bytes*/) {} + // Implement Session::sent_data by dispatching a thread message // that eventually invokes the virtual send_data_thread() void send_data(std::vector&& data) override @@ -187,6 +206,11 @@ namespace ccf } } + void on_inbound_consumed(size_t bytes) override + { + session_writer.inbound_consumed(session_id, bytes); + } + void close_session_thread() override { session_writer.close_socket(session_id); diff --git a/src/enclave/session_writer.h b/src/enclave/session_writer.h index 821db5e6ecf1..1c94d78791b5 100644 --- a/src/enclave/session_writer.h +++ b/src/enclave/session_writer.h @@ -43,5 +43,17 @@ namespace ccf // Tear down the connection: stop the underlying socket and drop the // session. virtual void close_socket(::tcp::ConnID id) = 0; + + // Report that `bytes` of previously delivered inbound data have now been + // processed. The transport uses this to decide when it may read more: it + // stops reading once the node is holding more unprocessed inbound data + // than it is willing to, and resumes as sessions catch up. Without it a + // client could make the node queue work faster than it retires it, for as + // long as it liked. + // + // A session which does not report is not penalised beyond its own + // connection - the transport releases whatever is still outstanding when + // the connection closes - so this defaults to a no-op. + virtual void inbound_consumed(::tcp::ConnID /*id*/, size_t /*bytes*/) {} }; } diff --git a/src/host/rpc_connection_manager.h b/src/host/rpc_connection_manager.h index 44d6c37c87b8..b75709cd8bae 100644 --- a/src/host/rpc_connection_manager.h +++ b/src/host/rpc_connection_manager.h @@ -48,10 +48,13 @@ namespace ccf { - static constexpr size_t ocm_max_open_sessions_soft_default = 1000; - static constexpr size_t ocm_max_open_sessions_hard_default = 1010; - static const ccf::Endorsement ocm_endorsement_default = { - ccf::Authority::SERVICE}; + static constexpr size_t max_open_sessions_soft_default = 1000; + static constexpr size_t max_open_sessions_hard_default = 1010; + // Node-wide bound on inbound data which has been read off sockets but not + // yet processed. Sized well above any legitimate working set - clients are + // expected to pipeline deeply - so that it only engages under overload. + static constexpr size_t inbound_queue_limit = 16UL * 1024 * 1024; + static const ccf::Endorsement endorsement_default = {ccf::Authority::SERVICE}; // How often idle UDP sessions are swept, mirroring the TCP idle sweep. static constexpr auto udp_idle_sweep_interval = std::chrono::seconds(1); @@ -111,9 +114,9 @@ namespace ccf struct ListenInterface { std::string name; - size_t max_open_sessions_soft = ocm_max_open_sessions_soft_default; - size_t max_open_sessions_hard = ocm_max_open_sessions_hard_default; - ccf::Endorsement endorsement = ocm_endorsement_default; + size_t max_open_sessions_soft = max_open_sessions_soft_default; + size_t max_open_sessions_hard = max_open_sessions_hard_default; + ccf::Endorsement endorsement = endorsement_default; http::ParserConfiguration http_configuration; ccf::ApplicationProtocol app_protocol = "HTTP1"; @@ -207,6 +210,11 @@ namespace ccf // never). Applied to each interface transport at listen() time. std::optional idle_connection_timeout; + // Shared by every interface transport, so the bound is on the node rather + // than on any one interface or connection. + std::shared_ptr inbound_admission = + std::make_shared(inbound_queue_limit); + // Outlives this manager if a session does - see InterfaceErrorCounts. std::shared_ptr error_counts = std::make_shared(); @@ -728,7 +736,8 @@ namespace ccf .alpn = alpn, .plaintext = plaintext, .idle_timeout = idle_connection_timeout, - .shared_next_id = &shared_conn_id}, + .shared_next_id = &shared_conn_id, + .inbound_admission = inbound_admission}, factory, on_closed, on_accept); @@ -920,11 +929,10 @@ namespace ccf auto* li = it->second.get(); li->max_open_sessions_soft = interface.max_open_sessions_soft.value_or( - ocm_max_open_sessions_soft_default); + max_open_sessions_soft_default); li->max_open_sessions_hard = interface.max_open_sessions_hard.value_or( - ocm_max_open_sessions_hard_default); - li->endorsement = - interface.endorsement.value_or(ocm_endorsement_default); + max_open_sessions_hard_default); + li->endorsement = interface.endorsement.value_or(endorsement_default); li->http_configuration = interface.http_configuration.value_or(http::ParserConfiguration{}); li->app_protocol = interface.app_protocol.value_or("HTTP1"); diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp index b9820397907c..c2428e4f17ab 100644 --- a/src/host/test/openssl_server_test.cpp +++ b/src/host/test/openssl_server_test.cpp @@ -1550,3 +1550,213 @@ TEST_CASE( } } } + +// A client which sends faster than the node can execute must not be able to +// make it queue unbounded work. Reads pause once the node-wide budget is +// exhausted and resume once it is released, without dropping or truncating +// anything. +TEST_CASE("Reads pause while the node-wide inbound budget is exhausted") +{ + auto [cert, key] = make_server_cert(); + + constexpr size_t limit = 64 * 1024; + constexpr size_t to_send = 4 * 1024 * 1024; + auto admission = std::make_shared(limit); + + std::atomic received{0}; + + // Stands in for the bridge, which charges the budget as it hands data to a + // session and releases it once the session reports the data processed. This + // session never reports, so the budget stays charged until the test frees it. + auto server = std::make_shared( + OpenSSLServer::Config{ + .host = "127.0.0.1", + .cert_pem = cert, + .key_pem = key, + .inbound_admission = admission}, + [&]( + ::tcp::ConnID, + std::vector d, + const std::vector&, + bool) { + received += d.size(); + admission->queued(d.size()); + }); + + UVLoopRunner loop; + server->start(); + const auto port = server->port(); + loop.start(); + + // The client has to run on its own thread: once the server stops reading, + // the socket buffers fill and SSL_write blocks. It must also stay connected + // until the server has read everything - closing a socket which still has + // unread data resets the connection and discards it. + std::atomic client_may_close{false}; + std::thread client([port, &client_may_close]() { + const int fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + REQUIRE(fd >= 0); + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + REQUIRE(inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) == 1); + REQUIRE( + ::connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0); + + SSL_CTX* cctx = SSL_CTX_new(TLS_client_method()); + REQUIRE(cctx != nullptr); + SSL* ssl = SSL_new(cctx); + REQUIRE(ssl != nullptr); + REQUIRE(SSL_set_fd(ssl, fd) == 1); + SSL_set_connect_state(ssl); + REQUIRE(SSL_connect(ssl) == 1); + + const std::vector chunk(16 * 1024, 'x'); + size_t sent = 0; + while (sent < to_send) + { + const int n = + SSL_write(ssl, chunk.data(), static_cast(chunk.size())); + if (n <= 0) + { + break; + } + sent += static_cast(n); + } + + while (!client_may_close.load()) + { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + + SSL_free(ssl); + SSL_CTX_free(cctx); + ::close(fd); + }); + + // Wait for the gate to engage. + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(10); + while (!admission->saturated() && std::chrono::steady_clock::now() < deadline) + { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + REQUIRE(admission->saturated()); + + // Give the server every chance to keep reading if the gate does not hold. + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + + // A pass which was already in flight when the gate engaged may read up to + // its per-pass cap, and the kernel and OpenSSL hold buffers of their own, so + // some overshoot is expected. What must not happen is the whole 4MiB + // arriving: without the gate this reaches to_send almost immediately. + const size_t stalled_at = received.load(); + REQUIRE(stalled_at < to_send); + REQUIRE(stalled_at <= limit + (1024 * 1024)); + + // Releasing the budget must wake the transport and let the rest through - + // the gate pauses reads, it does not drop or truncate anything. + admission->consumed(admission->bytes_pending()); + REQUIRE_FALSE(admission->saturated()); + + const auto resume_deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(20); + while (received.load() < to_send && + std::chrono::steady_clock::now() < resume_deadline) + { + // The test is standing in for the bridge, so it also has to keep releasing + // as the server reads more. + admission->consumed(admission->bytes_pending()); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + REQUIRE(received.load() == to_send); + + client_may_close.store(true); + client.join(); + server->stop(OpenSSLServer::LoopState::Running); + server.reset(); + loop.thread.join(); +} + +// A session which never reports the data it was given - a custom protocol, or +// one whose queued work is cancelled as it is destroyed - must not be able to +// strand part of the node-wide budget. The bridge releases whatever is still +// outstanding when the connection is torn down. +TEST_CASE("Inbound budget is released when a connection closes unreported") +{ + auto [cert, key] = make_server_cert(); + + constexpr size_t limit = 1024 * 1024; + auto admission = std::make_shared(limit); + + OpenSSLSessionManager bridge( + OpenSSLServer::Config{ + .host = "127.0.0.1", + .cert_pem = cert, + .key_pem = key, + .inbound_admission = admission}, + [&](::tcp::ConnID id, ccf::SessionWriter& w, std::vector, bool) + -> std::shared_ptr { + // EchoSession is a plain ccf::Session, so it never calls + // inbound_consumed - exactly the case this test is about. + return std::make_shared(id, w); + }); + + UVLoopRunner loop; + bridge.start(); + const auto port = bridge.port(); + loop.start(); + + { + const int fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + REQUIRE(fd >= 0); + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + REQUIRE(inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) == 1); + REQUIRE( + ::connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0); + + SSL_CTX* cctx = SSL_CTX_new(TLS_client_method()); + REQUIRE(cctx != nullptr); + SSL* ssl = SSL_new(cctx); + REQUIRE(ssl != nullptr); + REQUIRE(SSL_set_fd(ssl, fd) == 1); + SSL_set_connect_state(ssl); + REQUIRE(SSL_connect(ssl) == 1); + + const std::vector payload(32 * 1024, 'y'); + REQUIRE( + SSL_write(ssl, payload.data(), static_cast(payload.size())) == + static_cast(payload.size())); + + // Wait for the bytes to be charged to the budget. + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(10); + while (admission->bytes_pending() < payload.size() && + std::chrono::steady_clock::now() < deadline) + { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + REQUIRE(admission->bytes_pending() >= payload.size()); + + SSL_free(ssl); + SSL_CTX_free(cctx); + ::close(fd); + } + + // Closing the connection must hand the whole charge back, or a node would + // leak budget on every connection which used a non-reporting session and + // would eventually stop reading altogether. + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(10); + while (admission->bytes_pending() != 0 && + std::chrono::steady_clock::now() < deadline) + { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + REQUIRE(admission->bytes_pending() == 0); + + bridge.stop(OpenSSLServer::LoopState::Running); + loop.thread.join(); +} diff --git a/src/host/tls/inbound_admission.h b/src/host/tls/inbound_admission.h new file mode 100644 index 000000000000..80880f528516 --- /dev/null +++ b/src/host/tls/inbound_admission.h @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +#include "ds/internal_logger.h" + +#include +#include +#include +#include +#include +#include + +namespace asynchost +{ + // A node-wide bound on inbound data which has been read off sockets but not + // yet processed by the sessions it was handed to. + // + // Without such a bound, a client which sends faster than the node can + // execute grows node memory without limit: nothing else in the read path + // consults how far behind the application is. + // + // The bound is node-wide rather than per-connection, so a single connection + // can exhaust the budget and pause the others. That is deliberate: a + // per-connection limit would have to be smaller than one maximum-sized + // request before it could bound total node memory to the same figure. + // + // Accounting is in bytes rather than in queued items because bytes are the + // resource being defended. The two happen to be within a constant factor + // today (the transport hands over at most one read chunk at a time), but + // that is an artefact of the current read loop rather than a property to + // rely on. + class InboundAdmission + { + public: + // Called to ask a transport to re-examine its read interest. + using Waker = std::function; + + explicit InboundAdmission(size_t limit_) : limit(limit_) {} + + // True while the node has more unprocessed inbound data than it is willing + // to hold. Transports stop reading (but do not close anything) until this + // goes false again. + [[nodiscard]] bool saturated() const + { + return pending.load(std::memory_order_relaxed) >= limit; + } + + [[nodiscard]] size_t bytes_pending() const + { + return pending.load(std::memory_order_relaxed); + } + + // Bytes have been handed to a session and are not yet processed. + void queued(size_t n) + { + const size_t after = pending.fetch_add(n) + n; + if (after >= limit && !reported_saturated.exchange(true)) + { + LOG_INFO_FMT( + "Inbound queue limit reached ({} bytes queued, limit {}); pausing " + "reads until the node catches up", + after, + limit); + } + } + + // A session has finished with bytes previously passed to queued(). If this + // brings the node back under the limit, every registered transport is + // woken, not just the one which happened to make room - otherwise an + // interface with no traffic of its own would stay paused indefinitely. + void consumed(size_t n) + { + if (n == 0) + { + return; + } + const size_t before = pending.fetch_sub(n); + if (before >= limit && (before - n) < limit) + { + reported_saturated.store(false); + LOG_INFO_FMT("Inbound queue back under limit; resuming reads"); + wake_all(); + } + } + + // Transports register while they are running and unregister as they stop, + // so that wake_all() never touches a server which is tearing down. Returns + // a token to pass to unregister_waker(). + size_t register_waker(Waker waker) + { + const std::lock_guard guard(wakers_mutex); + const size_t token = next_token++; + wakers.emplace_back(token, std::move(waker)); + return token; + } + + void unregister_waker(size_t token) + { + const std::lock_guard guard(wakers_mutex); + std::erase_if( + wakers, [token](const auto& entry) { return entry.first == token; }); + } + + private: + void wake_all() + { + // Copied out so that no transport's own locks are taken while holding + // this one - consumed() is called from session workers, and unregister + // happens during a transport's shutdown. + std::vector to_wake; + { + const std::lock_guard guard(wakers_mutex); + to_wake.reserve(wakers.size()); + for (const auto& [token, waker] : wakers) + { + to_wake.push_back(waker); + } + } + for (const auto& waker : to_wake) + { + waker(); + } + } + + std::atomic pending{0}; + const size_t limit; + // Only so that a sustained overload does not log on every crossing. + std::atomic reported_saturated{false}; + + std::mutex wakers_mutex; + std::vector> wakers; + size_t next_token = 0; + }; +} diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index cc67c919933c..af85125b68d3 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -9,6 +9,7 @@ #include "ccf/crypto/openssl/openssl_wrappers.h" #include "ds/internal_logger.h" +#include "host/tls/inbound_admission.h" #include "tasks/ordered_tasks.h" #include "tasks/task_system.h" #include "tasks/worker.h" @@ -172,6 +173,11 @@ namespace asynchost // registry and reply routing. Null uses a per-server counter. std::atomic<::tcp::ConnID>* shared_next_id = nullptr; + // Node-wide bound on inbound data which has been read but not yet + // processed, shared with every other interface's transport. While it is + // saturated this server stops reading. Null disables the gate. + std::shared_ptr inbound_admission = nullptr; + // Loop to register this server's handles on. uv_loop_t* loop = uv_default_loop(); }; @@ -303,6 +309,28 @@ namespace asynchost static constexpr int idle_sweep_interval_ms = 1000; std::optional idle_timeout; + // Node-wide inbound budget, shared with every other interface's transport. + // Null disables the gate. + std::shared_ptr inbound_admission; + std::optional admission_token; + + [[nodiscard]] bool inbound_saturated() const + { + return inbound_admission != nullptr && inbound_admission->saturated(); + } + + // The subset of a connection's pending events which may be acted on now. + // Read interest is withheld while the node holds more unprocessed inbound + // data than it is willing to. + [[nodiscard]] int actionable_events(const Conn& c) const + { + if (!inbound_saturated()) + { + return c.pending_events; + } + return c.pending_events & ~UV_READABLE; + } + // Cross-thread outbound queue: send()/close_connection() append here from // any thread and wake the loop, which drains it on the libuv thread. std::vector pending_out; @@ -512,7 +540,19 @@ namespace asynchost { return; } - const int events = UV_READABLE | (c.want_write ? UV_WRITABLE : 0); + int events = c.want_write ? UV_WRITABLE : 0; + if (!inbound_saturated()) + { + events |= UV_READABLE; + } + if (events == 0) + { + // uv_poll_start rejects an empty mask, and there is genuinely nothing + // to wait for: this connection resumes when the node drops back under + // its inbound budget, at which point every transport is woken. + (void)uv_poll_stop(c.poll); + return; + } const int rc = uv_poll_start(c.poll, events, on_connection_poll); if (rc != 0) { @@ -913,7 +953,12 @@ namespace asynchost } conn->worker_active = true; DriveInput input; - input.events = std::exchange(conn->pending_events, 0); + input.events = actionable_events(*conn); + // Read interest which the inbound budget is currently withholding stays + // pending rather than being discarded: it may be the only record that + // OpenSSL is holding buffered data which will never produce another + // readability event. + conn->pending_events &= ~input.events; input.commands.swap(conn->pending_commands); input.close_requested = std::exchange(conn->close_requested, false); // The worker keeps the server alive for the whole pass. complete_drive() @@ -1223,7 +1268,7 @@ namespace asynchost } if ( conn->close_requested || !conn->pending_commands.empty() || - conn->pending_events != 0) + actionable_events(*conn) != 0) { dispatch_connection(conn); } @@ -1416,6 +1461,7 @@ namespace asynchost loop(config.loop), shared_next_id(config.shared_next_id), idle_timeout(config.idle_timeout), + inbound_admission(std::move(config.inbound_admission)), on_data(std::move(on_data_)), on_close(std::move(on_close_)), on_accept(std::move(on_accept_)), @@ -1600,6 +1646,20 @@ namespace asynchost throw std::runtime_error( std::string("uv_poll_start(listen) failed: ") + uv_strerror(rc)); } + + if (inbound_admission != nullptr) + { + // Woken when the node drops back under its inbound budget, so that + // this interface re-arms its reads even if the bytes which freed the + // budget belonged to a different one. + admission_token = + inbound_admission->register_waker([weak = weak_from_this()]() { + if (auto self = weak.lock()) + { + self->wake(); + } + }); + } } // Tear the server down. Idempotent, and safe to call from the destructor. @@ -1627,6 +1687,12 @@ namespace asynchost return; } + if (admission_token.has_value()) + { + inbound_admission->unregister_waker(*admission_token); + admission_token.reset(); + } + if (loop_state == LoopState::NotRunning) { lock.unlock(); diff --git a/src/host/tls/openssl_session_manager.h b/src/host/tls/openssl_session_manager.h index 63c81258bfc7..4ad9d8725f0d 100644 --- a/src/host/tls/openssl_session_manager.h +++ b/src/host/tls/openssl_session_manager.h @@ -29,6 +29,7 @@ #include "enclave/session_writer.h" #include "host/tls/openssl_server.h" +#include #include #include #include @@ -58,6 +59,11 @@ namespace asynchost private: std::shared_ptr server; SessionFactory factory; + // Node-wide budget for inbound data which has been delivered to a session + // but not yet processed. Charged here rather than in the transport so that + // every path which drops data instead of delivering it is visible in one + // function. Null disables the accounting. + std::shared_ptr inbound_admission; // Invoked when an admitted connection is torn down, so an owner can // release whatever it reserved at accept time. Called on the loop thread // from on_close, exactly once per admitted connection. @@ -71,6 +77,11 @@ namespace asynchost // they are dropped rather than being used to build a replacement // session for a connection which is going away. bool closing = false; + // Bytes delivered to the session which it has not yet reported as + // processed. Released to the node-wide budget when this connection is + // torn down, so that a session which never reports (or whose queued work + // is cancelled) cannot strand part of the budget forever. + size_t inbound_outstanding = 0; }; std::mutex conns_mutex; @@ -102,6 +113,13 @@ namespace asynchost } } session = state.session; + // Charged only now that the data is definitely being delivered. + state.inbound_outstanding += data.size(); + } + + if (inbound_admission != nullptr) + { + inbound_admission->queued(data.size()); } session->handle_incoming_data({data.data(), data.size()}); @@ -109,9 +127,23 @@ namespace asynchost void on_close(::tcp::ConnID conn_id) { + size_t to_release = 0; { std::lock_guard guard(conns_mutex); - conns.erase(conn_id); + auto it = conns.find(conn_id); + if (it != conns.end()) + { + to_release = it->second.inbound_outstanding; + conns.erase(it); + } + } + + // The transport only tears a connection down once its worker has + // finished, so no further data can be delivered for it and anything + // still outstanding will never be reported as processed. + if (inbound_admission != nullptr) + { + inbound_admission->consumed(to_release); } // Unconditional: the connection, not the session, is what was reserved @@ -132,6 +164,7 @@ namespace asynchost std::function on_connection_closed_ = {}, OpenSSLServer::OnAccept on_accept = {}) : factory(std::move(factory_)), + inbound_admission(config.inbound_admission), on_connection_closed(std::move(on_connection_closed_)) { server = std::make_shared( @@ -204,5 +237,30 @@ namespace asynchost // when the transport has actually torn it down, exactly once. server->close_connection(id); } + + void inbound_consumed(::tcp::ConnID id, size_t bytes) override + { + size_t to_release = 0; + { + std::lock_guard guard(conns_mutex); + auto it = conns.find(id); + if (it == conns.end()) + { + // Already torn down, and on_close released everything which was + // outstanding for it. + return; + } + // Clamped rather than trusted: releasing more than was charged would + // underflow the node-wide counter and pause every interface's reads + // permanently. + to_release = std::min(bytes, it->second.inbound_outstanding); + it->second.inbound_outstanding -= to_release; + } + + if (inbound_admission != nullptr) + { + inbound_admission->consumed(to_release); + } + } }; } From 4e960843e487f904b5c1f0e2c6521c650fc0bc85 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Fri, 7 Aug 2026 15:28:16 +0000 Subject: [PATCH 49/59] More review tidy-up --- CHANGELOG.md | 2 +- doc/architecture/tls_internals.rst | 6 +- include/ccf/node/session.h | 23 ++---- src/enclave/enclave.h | 4 +- src/enclave/session.h | 22 ++---- src/enclave/session_writer.h | 13 +-- src/host/rpc_connection_manager.h | 67 +++++++++++----- src/host/run.cpp | 28 +++++++ src/host/test/openssl_server_test.cpp | 84 ++++++++++++++++++-- src/host/tls/openssl_server.h | 105 +++++++++++++++++-------- src/host/tls/openssl_session_manager.h | 26 +++--- 11 files changed, 265 insertions(+), 115 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e6b9adb53ad..0e96c6bec137 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed -- TLS is now terminated by OpenSSL directly on the socket, rather than being relayed over the ringbuffer and decrypted through a memory BIO. The session interfaces in `include/ccf/node/session.h` and `include/ccf/node/rpc/custom_protocol_subsystem_interface.h` have changed shape accordingly, and custom protocols are no longer supported on UDP interfaces (#8117). +- TLS is now terminated by OpenSSL directly on the socket, rather than being relayed over the ringbuffer and decrypted through a memory BIO. The session interfaces in `include/ccf/node/session.h` and `include/ccf/research/custom_protocol_subsystem_interface.h` have changed shape accordingly: a session now receives and emits plaintext, taking ownership of the inbound buffer, and writes its output through a `ccf::SessionWriter` rather than a `tls::Context` (#8117). ## [7.0.11] diff --git a/doc/architecture/tls_internals.rst b/doc/architecture/tls_internals.rst index d7b684c415c5..2353a2237512 100644 --- a/doc/architecture/tls_internals.rst +++ b/doc/architecture/tls_internals.rst @@ -69,7 +69,7 @@ Reading When ``libuv`` reports a connection readable, the loop hands the connection to its worker (see `Threading`_), which calls ``SSL_read`` repeatedly until it reports ``SSL_ERROR_WANT_READ``. Every chunk of decrypted bytes is passed to the ``OnData`` callback as it is produced, so a single readable event may yield several callbacks. -A single pass is capped at a fixed number of bytes so that one busy connection cannot monopolise its worker. Reaching that cap does not end the read: OpenSSL may still be holding buffered records, in which case the file descriptor is *not* readable and waiting for a further ``uv_poll`` event would stall the connection. The worker therefore reports ``SSL_pending()`` back to the loop, which immediately schedules another pass instead of re-arming ``UV_READABLE``. +A single pass is capped at a fixed number of bytes so that one busy connection cannot monopolise its worker. Reaching that cap does not end the read: OpenSSL may still be holding buffered records, in which case the file descriptor is *not* readable and waiting for a further ``uv_poll`` event would stall the connection. The worker therefore reports ``SSL_has_pending()`` back to the loop, which immediately schedules another pass instead of re-arming ``UV_READABLE``. ``SSL_has_pending()`` rather than ``SSL_pending()``, because it also covers bytes which have been read off the socket but not yet processed into plaintext. ``OpenSSLSessionManager`` receives those bytes, finds or creates the session for that connection, and calls ``handle_incoming_data``. The session dispatches the actual parsing to its own ``OrderedTasks``, so neither the loop thread nor the connection worker blocks on application work. @@ -102,6 +102,8 @@ Closing is deferred rather than immediate. A close requested while output is sti This matters because the common pattern is to write a response and immediately close. Closing eagerly truncates any response large enough to have been backpressured, which the client observes as a connection reset partway through the body rather than as a well-formed response. +Server shutdown is the exception: there the close is forced rather than deferred. Each connection takes exactly one further worker pass, writing whatever the socket will accept, and then goes. Waiting for the flush would let a peer which has stopped reading hold the whole node's shutdown open indefinitely. + Idle connections are closed separately. If an idle timeout is configured, a repeating ``uv_timer_t`` periodically sweeps connections whose last I/O is older than the timeout. This retains the once-per-second scheduling used by the previous RPC transport while comparing actual ``steady_clock`` timestamps rather than counting timer ticks. Threading @@ -154,4 +156,4 @@ QUIC is not yet implemented. Server-side QUIC requires OpenSSL 3.5 or later, whi :ccf_repo:`DatagramServer ` exists as the substrate for that work. It is deliberately shaped as the UDP socket a QUIC server operates on: socket creation, binding, ``uv_poll_t`` readiness and per-datagram dispatch are all reusable as-is. The points that change for QUIC are marked ``QUIC EXTENSION POINT`` inline, and consist of wrapping the socket with ``BIO_new_dgram``/``SSL_set_fd`` on a listener ``SSL``, adding the OpenSSL event timeout, and replacing the datagram callback with ``SSL_handle_events``. -Until then, a UDP interface uses a built-in datagram echo session. +Until then, an interface whose ``app_protocol`` is ``QUIC`` echoes each datagram straight back, statelessly. Other UDP interfaces are served by the custom protocol subsystem (:ccf_repo:`custom_protocol_subsystem_interface.h `) in the usual way, with one session per source address, swept on the same idle timeout as TCP connections. diff --git a/include/ccf/node/session.h b/include/ccf/node/session.h index 9d4c215ebeff..dd7e658ff1fe 100644 --- a/include/ccf/node/session.h +++ b/include/ccf/node/session.h @@ -3,32 +3,19 @@ #pragma once #include -#include -#include +#include namespace ccf { - // A peer address for connectionless (datagram) transports. - // - // sockaddr_storage rather than sockaddr, because sockaddr is too small to - // hold an IPv6 address, and the length is carried with it because only the - // first `len` bytes are meaningful - and sendto() requires it. - struct SessionEndpoint - { - sockaddr_storage addr{}; - socklen_t len = 0; - }; - class Session { public: virtual ~Session() = default; - // Inbound bytes for this session. `peer` is the source address of the - // datagram for connectionless (UDP) transports, and is unused (default) - // for stream (TCP) transports. - virtual void handle_incoming_data( - std::span data, const SessionEndpoint& peer = {}) = 0; + // Inbound bytes for this session. Ownership is transferred, so that the + // buffer the transport has already built is moved through the session + // rather than copied again. + virtual void handle_incoming_data(std::vector&& data) = 0; virtual void send_data(std::vector&& data) = 0; virtual void close_session() = 0; }; diff --git a/src/enclave/enclave.h b/src/enclave/enclave.h index ff88e0814f9e..5ebe219eacf8 100644 --- a/src/enclave/enclave.h +++ b/src/enclave/enclave.h @@ -218,7 +218,9 @@ namespace ccf // assigned here, so the resolved addresses are reported back to the host // (which writes the rpc addresses file). { - nlohmann::json resolved_rpc_addresses; + // An object rather than a default-constructed (null) json, so that a + // node with no RPC interfaces still writes a well-formed, empty map. + nlohmann::json resolved_rpc_addresses = nlohmann::json::object(); // Bind interfaces with an explicit (non-zero) port before those // requesting an ephemeral port (port 0 or unspecified). Multiple diff --git a/src/enclave/session.h b/src/enclave/session.h index 364d8f4b6a22..1955e96b0401 100644 --- a/src/enclave/session.h +++ b/src/enclave/session.h @@ -26,17 +26,8 @@ namespace ccf std::vector data; std::shared_ptr self; - // Inbound: the transport owns the buffer it hands over and reuses it as - // soon as this returns, so the bytes must be copied. - SessionDataTask( - std::span d, std::shared_ptr s) : - self(std::move(s)) - { - data.assign(d.begin(), d.end()); - } - - // Outbound: the caller has already built a buffer for us, so take it - // rather than copying a response which may be arbitrarily large. + // The caller has already built a buffer for us, so take it rather than + // copying a request or response which may be arbitrarily large. SessionDataTask( std::vector&& d, std::shared_ptr s) : data(std::move(d)), @@ -110,11 +101,10 @@ namespace ccf // Implement Session::handle_incoming_data by dispatching a thread message // that eventually invokes the virtual handle_incoming_data_thread() - void handle_incoming_data( - std::span data, const SessionEndpoint& /*peer*/) override + void handle_incoming_data(std::vector&& data) override { - task_scheduler->add_action( - std::make_shared(data, shared_from_this())); + task_scheduler->add_action(std::make_shared( + std::move(data), shared_from_this())); } virtual void handle_incoming_data_thread(std::vector&& data) = 0; @@ -190,7 +180,7 @@ namespace ccf void send_data_thread(std::vector&& data) override { - session_writer.write_outbound(session_id, {data.data(), data.size()}); + session_writer.write_outbound(session_id, std::move(data)); } void handle_incoming_data_thread(std::vector&& data) override diff --git a/src/enclave/session_writer.h b/src/enclave/session_writer.h index 1c94d78791b5..dab2625c74c6 100644 --- a/src/enclave/session_writer.h +++ b/src/enclave/session_writer.h @@ -6,8 +6,6 @@ #include "tcp/msg_types.h" #include -#include -#include #include namespace ccf @@ -24,10 +22,9 @@ namespace ccf public: virtual ~SessionWriter() = default; - // Queue bytes to be written to the socket associated with `id`. For - // datagram protocols, `peer` identifies the destination; it is ignored for - // stream (TCP) connections. The bytes are copied, so the caller's buffer - // can be reused immediately. + // Queue bytes to be written to the socket associated with `id`. Ownership + // is transferred, so that a response which may be arbitrarily large is + // moved through to the transport rather than copied again. // // Fire-and-forget: there is currently no backpressure signal. // @@ -36,9 +33,7 @@ namespace ccf // (tracking per-connection queued bytes) and return a writable/would-block // status here. virtual void write_outbound( - ::tcp::ConnID id, - std::span data, - const SessionEndpoint& peer = {}) = 0; + ::tcp::ConnID id, std::vector&& data) = 0; // Tear down the connection: stop the underlying socket and drop the // session. diff --git a/src/host/rpc_connection_manager.h b/src/host/rpc_connection_manager.h index b75709cd8bae..dc119552be2b 100644 --- a/src/host/rpc_connection_manager.h +++ b/src/host/rpc_connection_manager.h @@ -34,6 +34,7 @@ #include #include +#include #include #include #include @@ -43,6 +44,7 @@ #include #include #include +#include #include #include @@ -145,9 +147,7 @@ namespace ccf {} void write_outbound( - ::tcp::ConnID id, - std::span data, - const ccf::SessionEndpoint& /*peer*/ = {}) override + ::tcp::ConnID id, std::vector&& data) override { write(id, data); } @@ -243,6 +243,31 @@ namespace ccf std::min(peerlen, sizeof(peer))}; } + // Ports reach here from operator configuration, so a malformed or + // out-of-range value must be reported rather than narrowed to whatever it + // happens to alias ("70000" would otherwise bind port 4464). An empty port + // requests an ephemeral one. + static uint16_t parse_port(const std::string& name, const std::string& port) + { + if (port.empty()) + { + return 0; + } + + uint16_t parsed = 0; + const auto* const end = port.data() + port.size(); + const auto [read_to, ec] = std::from_chars(port.data(), end, parsed); + if (ec != std::errc() || read_to != end) + { + throw std::logic_error(fmt::format( + "Invalid port '{}' for interface '{}' - expected a number in " + "[0, 65535]", + port, + name)); + } + return parsed; + } + void increment_active_sessions() { const size_t now_active = ++active_sessions; @@ -255,11 +280,7 @@ namespace ccf void decrement_active_sessions() { - size_t expected = active_sessions.load(); - while (expected > 0 && - !active_sessions.compare_exchange_weak(expected, expected - 1)) - { - } + active_sessions.fetch_sub(1); } void increment_interface_peak(ListenInterface* li, size_t now_open) @@ -273,11 +294,7 @@ namespace ccf void decrement_interface_sessions(ListenInterface* li) { - size_t expected = li->open_sessions.load(); - while (expected > 0 && - !li->open_sessions.compare_exchange_weak(expected, expected - 1)) - { - } + li->open_sessions.fetch_sub(1); } // Admission control, run by the transport for every accepted connection @@ -597,7 +614,10 @@ namespace ccf { decrement_interface_sessions(li); decrement_active_sessions(); - LOG_FAIL_FMT( + // Driven entirely by unauthenticated datagrams from a trivially + // spoofable source, so this must stay at a level which cannot be used + // to flood the log. + LOG_DEBUG_FMT( "Failed to create UDP session on interface {}: {}", li->name, e.what()); @@ -725,12 +745,12 @@ namespace ccf }; auto on_closed = [this, li](::tcp::ConnID) { release_connection(li); }; - const uint16_t port_num = - port.empty() ? 0 : static_cast(std::stoi(port)); + LOG_INFO_FMT( + "Registering RPC interface {}, on tcp {}:{}", name, host, port); li->bridge = std::make_shared( asynchost::OpenSSLServer::Config{ .host = host, - .port = port_num, + .port = parse_port(name, port), .cert_pem = cert_pem, .key_pem = key_pem, .alpn = alpn, @@ -742,7 +762,10 @@ namespace ccf on_closed, on_accept); li->bridge->start(); - return li->bridge->port(); + const uint16_t bound = li->bridge->port(); + LOG_INFO_FMT( + "Registered RPC interface {}, on tcp {}:{}", name, host, bound); + return bound; } // Bind and start a UDP listener for `name` (interfaces with protocol @@ -775,9 +798,11 @@ namespace ccf }); auto* writer = udp->writer.get(); + LOG_INFO_FMT( + "Registering RPC interface {}, on udp {}:{}", name, host, port); udp->server = std::make_shared( host, - port.empty() ? 0 : static_cast(std::stoi(port)), + parse_port(name, port), [this, li, udp_ptr, writer]( const uint8_t* data, size_t len, @@ -808,11 +833,13 @@ namespace ccf { return; } - session->handle_incoming_data({data, len}, {peer, peerlen}); + session->handle_incoming_data(std::vector(data, data + len)); }); udp->server->start(); const uint16_t bound = udp->server->port(); udp_interfaces.emplace(name, std::move(udp)); + LOG_INFO_FMT( + "Registered RPC interface {}, on udp {}:{}", name, host, bound); return bound; } diff --git a/src/host/run.cpp b/src/host/run.cpp index 77deb1ee1554..31ef5d661fca 100644 --- a/src/host/run.cpp +++ b/src/host/run.cpp @@ -615,6 +615,34 @@ namespace ccf auto curl_libuv_context = curl::CurlmLibuvContextSingleton(uv_default_loop()); + // Validate and normalise the configured RPC addresses here, at the input + // boundary, so that everything downstream sees a well-formed "host:port" + // with a port in range. ccf::split_net_address, used to bind them, is + // deliberately lenient and does no validation of its own. + for (auto& [name, interface] : config.network.rpc_interfaces) + { + try + { + const auto [rpc_host, rpc_port] = + cli::validate_address(interface.bind_address); + interface.bind_address = ccf::make_net_address(rpc_host, rpc_port); + + if (!interface.published_address.empty()) + { + const auto [pub_host, pub_port] = + cli::validate_address(interface.published_address); + interface.published_address = + ccf::make_net_address(pub_host, pub_port); + } + } + catch (const std::exception& e) + { + LOG_FATAL_FMT( + "Invalid address for RPC interface {}: {}. Exiting.", name, e.what()); + return static_cast(CLI::ExitCodes::ValidationError); + } + } + // Prepare startup configuration const size_t certificate_size = 4096; std::vector node_cert(certificate_size); diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp index c2428e4f17ab..8c980dc7f15a 100644 --- a/src/host/test/openssl_server_test.cpp +++ b/src/host/test/openssl_server_test.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -381,11 +382,9 @@ namespace EchoSession(::tcp::ConnID id_, ccf::SessionWriter& w) : id(id_), writer(w) {} - void handle_incoming_data( - std::span data, - const ccf::SessionEndpoint& /*peer*/ = {}) override + void handle_incoming_data(std::vector&& data) override { - writer.write_outbound(id, data); + writer.write_outbound(id, std::move(data)); } void send_data(std::vector&& /*data*/) override {} @@ -411,11 +410,9 @@ namespace payload(std::move(p)) {} - void handle_incoming_data( - std::span /*data*/, - const ccf::SessionEndpoint& /*peer*/ = {}) override + void handle_incoming_data(std::vector&& /*data*/) override { - writer.write_outbound(id, payload); + writer.write_outbound(id, std::vector(payload)); writer.close_socket(id); } @@ -760,6 +757,77 @@ TEST_CASE("Shutdown with staggered connection closes releases every uv handle") REQUIRE(uv_loop_alive(uv_default_loop()) == 0); } +// Shutdown must not be hostage to a peer which has stopped reading. A +// connection with output it cannot flush is closed after a single further +// pass, rather than being re-dispatched until the socket becomes writable - +// which, for a client advertising a zero window, is never. +TEST_CASE("Shutdown completes while a connection cannot flush its output") +{ + auto [cert, key] = make_server_cert(); + + // Far larger than any socket buffer, so the write is guaranteed to stall + // with most of the payload still queued. + constexpr size_t payload_size = 32 * 1024 * 1024; + const std::vector payload(payload_size, 'z'); + + std::shared_ptr server; + server = std::make_shared( + OpenSSLServer::Config{ + .host = "127.0.0.1", .cert_pem = cert, .key_pem = key}, + [&]( + ::tcp::ConnID id, + std::vector, + const std::vector&, + bool) { server->send(id, std::vector(payload)); }); + UVLoopRunner loop; + server->start(); + const auto port = server->port(); + loop.start(); + + const int fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + REQUIRE(fd >= 0); + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + REQUIRE(inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) == 1); + REQUIRE(::connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0); + + SSL_CTX* cctx = SSL_CTX_new(TLS_client_method()); + REQUIRE(cctx != nullptr); + SSL* ssl = SSL_new(cctx); + REQUIRE(ssl != nullptr); + REQUIRE(SSL_set_fd(ssl, fd) == 1); + SSL_set_connect_state(ssl); + REQUIRE(SSL_connect(ssl) == 1); + + const uint8_t request = 'x'; + REQUIRE(SSL_write(ssl, &request, 1) == 1); + + // Read just enough to know the response has started, then stop reading. The + // server's remaining output has nowhere to go from here on. + std::vector chunk(1024); + REQUIRE(SSL_read(ssl, chunk.data(), static_cast(chunk.size())) > 0); + + auto stopped = std::async(std::launch::async, [&]() { + server->stop(OpenSSLServer::LoopState::Running); + }); + const bool completed = + stopped.wait_for(std::chrono::seconds(10)) == std::future_status::ready; + + // Unblock the server if it did not stop, so that the failure is reported + // rather than hanging the test binary. + SSL_free(ssl); + SSL_CTX_free(cctx); + ::close(fd); + + stopped.get(); + REQUIRE(completed); + + server.reset(); + loop.thread.join(); + REQUIRE(uv_loop_alive(uv_default_loop()) == 0); +} + TEST_CASE("TCP connections use the legacy latency and keepalive options") { const int fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index af85125b68d3..1e2004e66add 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -267,6 +267,11 @@ namespace asynchost int pending_events = 0; bool worker_active = false; bool close_requested = false; + // Set once, when the server is being torn down. Unlike close_requested + // it is sticky, and it does not wait for buffered output to drain: a + // peer which has stopped reading must not be able to keep the connection + // (and so the shutdown) alive indefinitely. + bool force_close = false; }; struct OutItem @@ -281,6 +286,7 @@ namespace asynchost int events = 0; std::vector commands; bool close_requested = false; + bool force_close = false; }; struct DriveResult @@ -449,7 +455,8 @@ namespace asynchost } // Build a server SSL_CTX (min TLS 1.2, ALPN if configured) and load the - // cert/key. Returns nullptr on failure. Called on the loop thread. + // cert/key. Returns nullptr on failure, having logged which step failed and + // why. Called on the loop thread. // // This is the only place CCF's inbound TLS policy is defined. It is // asserted from the wire by src/host/test/openssl_server_test.cpp and, for @@ -457,16 +464,24 @@ namespace asynchost std::shared_ptr build_server_ctx( const std::string& cert_pem, const std::string& key_pem) { - SSL_CTX* c = SSL_CTX_new(TLS_server_method()); - if (c == nullptr) - { - return {}; - } + // So that anything reported below comes from this function rather than + // from unrelated work previously done on this thread. + ERR_clear_error(); + + const auto fail = [](const char* step) { + LOG_FAIL_FMT( + "Failed to build TLS context ({}): {}", + step, + ccf::crypto::OpenSSL::error_string(ERR_get_error())); + return std::shared_ptr{}; + }; + + ccf::crypto::OpenSSL::Unique_SSL_CTX c(TLS_server_method()); + // Require at least TLS 1.2, support up to 1.3 if (SSL_CTX_set_min_proto_version(c, TLS1_2_VERSION) != 1) { - SSL_CTX_free(c); - return {}; + return fail("SSL_CTX_set_min_proto_version"); } // Disable renegotiation to avoid DoS @@ -484,8 +499,7 @@ namespace asynchost "ECDHE-RSA-AES128-GCM-SHA256"; if (SSL_CTX_set_cipher_list(c, cipher_list) != 1) { - SSL_CTX_free(c); - return {}; + return fail("SSL_CTX_set_cipher_list"); } // Set cipher for TLS 1.3 @@ -494,8 +508,7 @@ namespace asynchost "TLS_AES_128_GCM_SHA256"; if (SSL_CTX_set_ciphersuites(c, ciphersuites) != 1) { - SSL_CTX_free(c); - return {}; + return fail("SSL_CTX_set_ciphersuites"); } // Prefer hybrid post-quantum groups when available, while retaining the @@ -506,8 +519,7 @@ namespace asynchost "?SecP384r1MLKEM1024:?SecP256r1MLKEM768:?X25519MLKEM768:" "P-521:P-384:P-256") != 1) { - SSL_CTX_free(c); - return {}; + return fail("SSL_CTX_set1_groups_list"); } // Allow buffer to be relocated between WANT_WRITE retries, and do partial @@ -528,10 +540,9 @@ namespace asynchost } if (!load_cert_key(c, cert_pem, key_pem)) { - SSL_CTX_free(c); - return {}; + return fail("loading certificate and key"); } - return {c, SSL_CTX_free}; + return {c.release(), SSL_CTX_free}; } void update_interest(Conn& c) const @@ -605,7 +616,7 @@ namespace asynchost { c.state = Conn::Ready; c.want_write = false; - X509* cert = SSL_get_peer_certificate(c.ssl); + X509* cert = SSL_get1_peer_certificate(c.ssl); if (cert != nullptr) { const int len = i2d_X509(cert, nullptr); @@ -870,9 +881,14 @@ namespace asynchost if (conn->ssl == nullptr && conn->accepted_ctx != nullptr) { + ERR_clear_error(); conn->ssl = SSL_new(conn->accepted_ctx.get()); if (conn->ssl == nullptr || SSL_set_fd(conn->ssl, conn->fd) != 1) { + LOG_FAIL_FMT( + "Connection {}: failed to create SSL state: {}", + conn->id, + ccf::crypto::OpenSSL::error_string(ERR_get_error())); if (conn->ssl != nullptr) { SSL_free(conn->ssl); @@ -931,6 +947,12 @@ namespace asynchost { alive = false; } + if (input.force_close) + { + // The server is going away. Whatever could be written above has been, + // but the connection is not held open waiting for more room. + alive = false; + } if (!alive && conn->ssl != nullptr) { ERR_clear_error(); @@ -961,6 +983,7 @@ namespace asynchost conn->pending_events &= ~input.events; input.commands.swap(conn->pending_commands); input.close_requested = std::exchange(conn->close_requested, false); + input.force_close = conn->force_close; // The worker keeps the server alive for the whole pass. complete_drive() // posts its result and only then touches lifecycle_mutex and // wake_handle; without this the loop could consume that result, finish @@ -1200,10 +1223,10 @@ namespace asynchost // process, it must leave the previous context in place. try { + // build_server_ctx() reports why it failed. auto nc = build_server_ctx(cert_pem, key_pem); if (nc == nullptr) { - LOG_FAIL_FMT("set_server_cert: failed to build TLS context"); continue; } ctx = std::move(nc); @@ -1267,8 +1290,8 @@ namespace asynchost continue; } if ( - conn->close_requested || !conn->pending_commands.empty() || - actionable_events(*conn) != 0) + conn->force_close || conn->close_requested || + !conn->pending_commands.empty() || actionable_events(*conn) != 0) { dispatch_connection(conn); } @@ -1351,12 +1374,16 @@ namespace asynchost // Begin, or resume, shutdown on the loop thread. // - // The listener closes immediately and every live connection is asked to - // close, but the server's own handles can only go once those connections - // have actually gone: a connection whose worker is still running is still - // reading and writing its socket, so neither its fd nor its poll handle - // may be touched here. drain_pending_out() calls back in as each worker - // completes, and the last call finishes the job. + // The listener closes immediately and every live connection is marked for + // forced close, but the server's own handles can only go once those + // connections have actually gone: a connection whose worker is still + // running is still reading and writing its socket, so neither its fd nor + // its poll handle may be touched here. drain_pending_out() calls back in + // as each worker completes, and the last call finishes the job. + // + // The close is forced rather than deferred until buffered output has + // flushed, so that each connection takes exactly one further worker pass + // and shutdown cannot be held up by a peer which has stopped reading. // // Nothing here waits for the uv close callbacks. Each handle owns itself // (see new_handle()), so once torn_down is set nothing refers to them any @@ -1386,7 +1413,15 @@ namespace asynchost for (auto& [fd, conn] : conns) { - conn->close_requested = true; + // force_close is sticky, so a connection already on its way out is not + // dispatched again. Without that, a connection whose output cannot + // drain would be re-dispatched on every completion, spinning the loop + // thread and a worker for as long as its peer declined to read. + if (conn->force_close) + { + continue; + } + conn->force_close = true; dispatch_connection(conn); } if (!conns.empty()) @@ -1732,16 +1767,24 @@ namespace asynchost } // Thread-safe. Queue plaintext to be encrypted and written to `conn_id`. - void send(::tcp::ConnID conn_id, const uint8_t* data, size_t len) + // The buffer is taken rather than copied, so a response of any size crosses + // this boundary without another allocation. + void send(::tcp::ConnID conn_id, std::vector&& data) { { std::lock_guard g(out_mutex); - pending_out.push_back( - {conn_id, std::vector(data, data + len), false}); + pending_out.push_back({conn_id, std::move(data), false}); } wake(); } + // Thread-safe. Queue a copy of plaintext to be encrypted and written to + // `conn_id`. + void send(::tcp::ConnID conn_id, const uint8_t* data, size_t len) + { + send(conn_id, std::vector(data, data + len)); + } + // Thread-safe. Request that `conn_id` be torn down. void close_connection(::tcp::ConnID conn_id) { diff --git a/src/host/tls/openssl_session_manager.h b/src/host/tls/openssl_session_manager.h index 4ad9d8725f0d..f867856862f0 100644 --- a/src/host/tls/openssl_session_manager.h +++ b/src/host/tls/openssl_session_manager.h @@ -93,10 +93,21 @@ namespace asynchost const std::vector& peer_cert, bool soft_limited) { + const size_t size = data.size(); std::shared_ptr session; { std::lock_guard guard(conns_mutex); - auto& state = conns[conn_id]; + // find() then insert, rather than operator[], so that first sight of a + // connection is an explicit case. An entry is only ever removed by + // on_close, and the transport delivers nothing for a connection after + // that, so an insert here is always a genuinely new connection and + // never a zombie whose inbound budget nothing would release. + auto it = conns.find(conn_id); + if (it == conns.end()) + { + it = conns.emplace(conn_id, ConnState{}).first; + } + auto& state = it->second; if (state.closing) { return; @@ -114,15 +125,15 @@ namespace asynchost } session = state.session; // Charged only now that the data is definitely being delivered. - state.inbound_outstanding += data.size(); + state.inbound_outstanding += size; } if (inbound_admission != nullptr) { - inbound_admission->queued(data.size()); + inbound_admission->queued(size); } - session->handle_incoming_data({data.data(), data.size()}); + session->handle_incoming_data(std::move(data)); } void on_close(::tcp::ConnID conn_id) @@ -214,12 +225,9 @@ namespace asynchost // ccf::SessionWriter (callable from any thread). - void write_outbound( - ::tcp::ConnID id, - std::span data, - const ccf::SessionEndpoint& /*peer*/ = {}) override + void write_outbound(::tcp::ConnID id, std::vector&& data) override { - server->send(id, data.data(), data.size()); + server->send(id, std::move(data)); } void close_socket(::tcp::ConnID id) override From 0af7572dc34d934ca33229cf9bf95bf999bb93db Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Mon, 10 Aug 2026 13:54:19 +0000 Subject: [PATCH 50/59] More review feedback cleanup --- include/ccf/node/session.h | 44 +++ .../custom_protocol_subsystem_interface.h | 2 - src/enclave/session.h | 9 +- src/enclave/session_writer.h | 54 ---- src/host/rpc_connection_manager.h | 68 +++-- src/host/test/openssl_server_test.cpp | 197 +++++++++++++ src/host/tls/openssl_server.h | 65 ++++- src/host/tls/openssl_session_manager.h | 1 - src/http/curl.h | 15 +- src/http/test/curl_test.cpp | 2 +- src/tls/context.h | 264 ++++++++++++++++++ 11 files changed, 620 insertions(+), 101 deletions(-) delete mode 100644 src/enclave/session_writer.h create mode 100644 src/tls/context.h diff --git a/include/ccf/node/session.h b/include/ccf/node/session.h index dd7e658ff1fe..7f72a7b0d625 100644 --- a/include/ccf/node/session.h +++ b/include/ccf/node/session.h @@ -19,4 +19,48 @@ namespace ccf virtual void send_data(std::vector&& data) = 0; virtual void close_session() = 0; }; + + // Abstract output sink injected into Sessions: a Session hands its outbound + // bytes (and connection-teardown requests) to a SessionWriter, which is + // implemented by the RPC transport. + // + // IMPORTANT: Sessions may invoke these methods from worker threads, so + // implementations MUST be thread-safe and must marshal any socket operations + // onto their I/O thread if required. + class SessionWriter + { + public: + virtual ~SessionWriter() = default; + + // `id` throughout is the connection identifier the session was created + // with (ccf::tls::ConnID). + + // Queue bytes to be written to the socket associated with `id`. Ownership + // is transferred, so that a response which may be arbitrarily large is + // moved through to the transport rather than copied again. + // + // Fire-and-forget: there is currently no backpressure signal. + // + // FUTURE: to surface genuine TCP-layer backpressure, an implementation + // should report when a connection's pending-write queue exceeds a watermark + // (tracking per-connection queued bytes) and return a writable/would-block + // status here. + virtual void write_outbound(int64_t id, std::vector&& data) = 0; + + // Tear down the connection: stop the underlying socket and drop the + // session. + virtual void close_socket(int64_t id) = 0; + + // Report that `bytes` of previously delivered inbound data have now been + // processed. The transport uses this to decide when it may read more: it + // stops reading once the node is holding more unprocessed inbound data + // than it is willing to, and resumes as sessions catch up. Without it a + // client could make the node queue work faster than it retires it, for as + // long as it liked. + // + // A session which does not report is not penalised beyond its own + // connection - the transport releases whatever is still outstanding when + // the connection closes - so this defaults to a no-op. + virtual void inbound_consumed(int64_t /*id*/, size_t /*bytes*/) {} + }; } \ No newline at end of file diff --git a/include/ccf/research/custom_protocol_subsystem_interface.h b/include/ccf/research/custom_protocol_subsystem_interface.h index 57069e74deb8..d9f89f069128 100644 --- a/include/ccf/research/custom_protocol_subsystem_interface.h +++ b/include/ccf/research/custom_protocol_subsystem_interface.h @@ -14,8 +14,6 @@ namespace ccf { - class SessionWriter; - namespace tls { using ConnID = int64_t; diff --git a/src/enclave/session.h b/src/enclave/session.h index 1955e96b0401..70fc4f9ef22e 100644 --- a/src/enclave/session.h +++ b/src/enclave/session.h @@ -3,7 +3,6 @@ #pragma once #include "ccf/node/session.h" -#include "enclave/session_writer.h" #include "tasks/ordered_tasks.h" #include "tasks/task.h" #include "tasks/task_system.h" @@ -149,6 +148,14 @@ namespace ccf protected: ::tcp::ConnID session_id; + // Not owned. The writer is the transport's per-interface bridge, which is + // owned by the RPCConnectionManager and outlives every session on that + // interface: the manager is only destroyed if node creation fails, before + // any session exists, and is otherwise never destroyed. That matters + // because a queued task holds a shared_ptr to its session, so a session + // can outlive its removal from the transport's connection map. If the + // manager ever becomes destructible on the normal path, this must become a + // weak_ptr. ccf::SessionWriter& session_writer; std::vector peer_cert_; // Set once parse() has reported that it will process no more data (a parse diff --git a/src/enclave/session_writer.h b/src/enclave/session_writer.h deleted file mode 100644 index dab2625c74c6..000000000000 --- a/src/enclave/session_writer.h +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the Apache 2.0 License. -#pragma once - -#include "ccf/node/session.h" -#include "tcp/msg_types.h" - -#include -#include - -namespace ccf -{ - // Abstract output sink injected into Sessions: a Session hands its outbound - // bytes (and connection-teardown requests) to a SessionWriter, which is - // implemented by the RPC transport. - // - // IMPORTANT: Sessions may invoke these methods from worker threads, so - // implementations MUST be thread-safe and must marshal any socket operations - // onto their I/O thread if required. - class SessionWriter - { - public: - virtual ~SessionWriter() = default; - - // Queue bytes to be written to the socket associated with `id`. Ownership - // is transferred, so that a response which may be arbitrarily large is - // moved through to the transport rather than copied again. - // - // Fire-and-forget: there is currently no backpressure signal. - // - // FUTURE: to surface genuine TCP-layer backpressure, an implementation - // should report when a connection's pending-write queue exceeds a watermark - // (tracking per-connection queued bytes) and return a writable/would-block - // status here. - virtual void write_outbound( - ::tcp::ConnID id, std::vector&& data) = 0; - - // Tear down the connection: stop the underlying socket and drop the - // session. - virtual void close_socket(::tcp::ConnID id) = 0; - - // Report that `bytes` of previously delivered inbound data have now been - // processed. The transport uses this to decide when it may read more: it - // stops reading once the node is holding more unprocessed inbound data - // than it is willing to, and resumes as sessions catch up. Without it a - // client could make the node queue work faster than it retires it, for as - // long as it liked. - // - // A session which does not report is not penalised beyond its own - // connection - the transport releases whatever is still outstanding when - // the connection closes - so this defaults to a no-op. - virtual void inbound_consumed(::tcp::ConnID /*id*/, size_t /*bytes*/) {} - }; -} diff --git a/src/host/rpc_connection_manager.h b/src/host/rpc_connection_manager.h index dc119552be2b..fc6e37572245 100644 --- a/src/host/rpc_connection_manager.h +++ b/src/host/rpc_connection_manager.h @@ -45,6 +45,7 @@ #include #include #include +#include #include #include @@ -193,6 +194,18 @@ namespace ccf std::mutex interfaces_mutex; std::map> interfaces; + + // The transport each admitted TCP connection belongs to, so that a reply + // can find its session directly. Guarded by its own mutex rather than + // interfaces_mutex: replies arrive on session workers, while the entries + // are added and removed on the libuv loop thread, and putting that on the + // interface lock would serialise every forwarded reply behind unrelated + // loop-thread work. + std::mutex connection_interfaces_mutex; + std::unordered_map< + ::tcp::ConnID, + std::shared_ptr> + connection_transports; // UDP interface state, keyed by interface name. Only custom UDP protocols // hold state here, one session per peer; "QUIC" interfaces are echoed // statelessly (see listen_udp) and so have no entries at all. @@ -332,6 +345,12 @@ namespace ccf increment_interface_peak(li, open + 1); increment_active_sessions(); + { + // li->bridge is assigned by listen() before the transport is started, + // and so before it can accept anything and reach here. + std::lock_guard guard(connection_interfaces_mutex); + connection_transports[conn_id] = li->bridge; + } const bool soft_limited = open >= li->max_open_sessions_soft; if (soft_limited) @@ -349,8 +368,12 @@ namespace ccf // Release the reservation taken by admit_connection. Invoked exactly once // per admitted connection, when the transport has torn it down. - void release_connection(ListenInterface* li) + void release_connection(ListenInterface* li, ::tcp::ConnID conn_id) { + { + std::lock_guard guard(connection_interfaces_mutex); + connection_transports.erase(conn_id); + } decrement_interface_sessions(li); decrement_active_sessions(); } @@ -743,7 +766,9 @@ namespace ccf bool soft_limited) { return make_session(li, cid, w, std::move(pc), soft_limited); }; - auto on_closed = [this, li](::tcp::ConnID) { release_connection(li); }; + auto on_closed = [this, li](::tcp::ConnID cid) { + release_connection(li, cid); + }; LOG_INFO_FMT( "Registering RPC interface {}, on tcp {}:{}", name, host, port); @@ -850,33 +875,32 @@ namespace ccf bool terminate_after_reply, std::vector&& data) override { - std::vector> bridges; + // The transport is snapshotted under the lock and used without it: + // stopping a transport blocks on the libuv loop, which itself needs + // interfaces_mutex, so no manager lock may be held across a send. + std::shared_ptr bridge; { - std::lock_guard guard(interfaces_mutex); - for (auto& [name, li] : interfaces) + std::lock_guard guard(connection_interfaces_mutex); + auto it = connection_transports.find(id); + if (it != connection_transports.end()) { - if (li->bridge != nullptr) - { - bridges.push_back(li->bridge); - } + bridge = it->second; } } - for (const auto& bridge : bridges) + auto session = bridge == nullptr ? nullptr : bridge->get_session(id); + if (session == nullptr) { - auto session = bridge->get_session(id); - if (session != nullptr) - { - session->send_data(std::move(data)); - if (terminate_after_reply) - { - session->close_session(); - } - return true; - } + LOG_DEBUG_FMT("Refusing to reply to unknown session {}", id); + return false; + } + + session->send_data(std::move(data)); + if (terminate_after_reply) + { + session->close_session(); } - LOG_DEBUG_FMT("Refusing to reply to unknown session {}", id); - return false; + return true; } ccf::SessionMetrics get_session_metrics() override diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp index 8c980dc7f15a..9834f1e90dd2 100644 --- a/src/host/test/openssl_server_test.cpp +++ b/src/host/test/openssl_server_test.cpp @@ -828,6 +828,203 @@ TEST_CASE("Shutdown completes while a connection cannot flush its output") REQUIRE(uv_loop_alive(uv_default_loop()) == 0); } +// An interface binds before its certificate is necessarily known - a joining +// node only receives the service certificate once its join has been accepted. +// Until then it must refuse connections outright rather than accept them and +// drop them, so that a client fails to connect (and retries) rather than +// completing a TCP handshake and then failing mid-TLS. +TEST_CASE("A TLS interface without a certificate refuses connections") +{ + auto [cert, key] = make_server_cert(); + + std::mutex m; + std::condition_variable cv; + size_t data_callbacks = 0; + + // No cert_pem/key_pem: the certificate arrives later, via set_server_cert. + std::shared_ptr server; + server = std::make_shared( + OpenSSLServer::Config{.host = "127.0.0.1"}, + [&]( + ::tcp::ConnID id, + std::vector d, + const std::vector&, + bool) { + server->send(id, std::move(d)); + std::lock_guard guard(m); + ++data_callbacks; + cv.notify_all(); + }); + UVLoopRunner loop; + server->start(); + + // The port is fixed by the bind, so it is reportable even before the + // interface is serving. + const auto port = server->port(); + REQUIRE(port != 0); + loop.start(); + + const auto try_connect = [port]() { + const int fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + REQUIRE(fd >= 0); + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + REQUIRE(inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) == 1); + const int rc = + ::connect(fd, reinterpret_cast(&addr), sizeof(addr)); + const int err = errno; + ::close(fd); + return rc == 0 ? 0 : err; + }; + + // Bound but not listening, so the kernel answers the SYN with a reset. + REQUIRE(try_connect() == ECONNREFUSED); + + // Supplying the certificate is what puts the interface into service. + server->set_server_cert(cert, key); + + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(10); + while (try_connect() != 0 && std::chrono::steady_clock::now() < deadline) + { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + REQUIRE(try_connect() == 0); + + // And the interface now serves a complete TLS session. + { + const int fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + REQUIRE(fd >= 0); + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + REQUIRE(inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) == 1); + REQUIRE( + ::connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0); + + SSL_CTX* cctx = SSL_CTX_new(TLS_client_method()); + REQUIRE(cctx != nullptr); + SSL* ssl = SSL_new(cctx); + REQUIRE(ssl != nullptr); + REQUIRE(SSL_set_fd(ssl, fd) == 1); + SSL_set_connect_state(ssl); + REQUIRE(SSL_connect(ssl) == 1); + + const std::string request = "hello"; + REQUIRE( + SSL_write(ssl, request.data(), static_cast(request.size())) == + static_cast(request.size())); + + std::vector echoed(request.size()); + REQUIRE( + SSL_read(ssl, echoed.data(), static_cast(echoed.size())) == + static_cast(request.size())); + REQUIRE( + std::string(echoed.begin(), echoed.end()) == request); + + SSL_free(ssl); + SSL_CTX_free(cctx); + ::close(fd); + } + + { + std::unique_lock lock(m); + REQUIRE(cv.wait_for(lock, std::chrono::seconds(10), [&]() { + return data_callbacks > 0; + })); + } + + server->stop(OpenSSLServer::LoopState::Running); + server.reset(); + loop.thread.join(); + REQUIRE(uv_loop_alive(uv_default_loop()) == 0); +} + +// Idle connections are closed so that a client which connects, handshakes and +// then goes quiet cannot hold a file descriptor and TLS state indefinitely. +TEST_CASE("Idle connections are closed after the configured timeout") +{ + auto [cert, key] = make_server_cert(); + + std::mutex m; + std::condition_variable cv; + std::set<::tcp::ConnID> opened; + std::set<::tcp::ConnID> closed; + + // Shorter than the sweep interval, so the first sweep after the connection + // goes quiet closes it. + std::shared_ptr server; + server = std::make_shared( + OpenSSLServer::Config{ + .host = "127.0.0.1", + .cert_pem = cert, + .key_pem = key, + .idle_timeout = std::chrono::milliseconds(100)}, + [&]( + ::tcp::ConnID id, + std::vector d, + const std::vector&, + bool) { + server->send(id, std::move(d)); + std::lock_guard guard(m); + opened.insert(id); + cv.notify_all(); + }, + [&](::tcp::ConnID id) { + std::lock_guard guard(m); + closed.insert(id); + cv.notify_all(); + }); + UVLoopRunner loop; + server->start(); + const auto port = server->port(); + loop.start(); + + const int fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + REQUIRE(fd >= 0); + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + REQUIRE(inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) == 1); + REQUIRE(::connect(fd, reinterpret_cast(&addr), sizeof(addr)) == 0); + + SSL_CTX* cctx = SSL_CTX_new(TLS_client_method()); + REQUIRE(cctx != nullptr); + SSL* ssl = SSL_new(cctx); + REQUIRE(ssl != nullptr); + REQUIRE(SSL_set_fd(ssl, fd) == 1); + SSL_set_connect_state(ssl); + REQUIRE(SSL_connect(ssl) == 1); + + const uint8_t request = 'x'; + REQUIRE(SSL_write(ssl, &request, 1) == 1); + uint8_t echoed = 0; + REQUIRE(SSL_read(ssl, &echoed, 1) == 1); + + // The connection is established and served. Now go quiet, and the server + // should close it of its own accord. + { + std::unique_lock lock(m); + REQUIRE(cv.wait_for( + lock, std::chrono::seconds(10), [&]() { return !closed.empty(); })); + REQUIRE(closed == opened); + } + + // The client observes the close rather than the connection simply stalling. + uint8_t after = 0; + REQUIRE(SSL_read(ssl, &after, 1) <= 0); + + SSL_free(ssl); + SSL_CTX_free(cctx); + ::close(fd); + + server->stop(OpenSSLServer::LoopState::Running); + server.reset(); + loop.thread.join(); + REQUIRE(uv_loop_alive(uv_default_loop()) == 0); +} + TEST_CASE("TCP connections use the legacy latency and keepalive options") { const int fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index 1e2004e66add..458047831b1a 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -364,6 +364,11 @@ namespace asynchost uv_poll_t* listen_poll = nullptr; int listen_fd = -1; uint16_t bound_port = 0; + // Whether the bound socket has been placed in the LISTEN state and its + // poll handle armed. A TLS interface with no certificate stays bound but + // not listening, so inbound connections are refused by the kernel rather + // than accepted and dropped. Loop-thread only after start(). + bool listening = false; // Plaintext (UNSECURED) interface: no TLS, raw socket I/O. bool plaintext = false; bool started = false; @@ -1203,6 +1208,37 @@ namespace asynchost } } + // Place the bound socket in the LISTEN state and arm its poll handle, if + // the interface is ready to serve connections and is not already doing so. + // Idempotent. Called from start() and, for an interface whose certificate + // arrived later, from the loop thread once it has been applied. + void begin_listening() + { + if (listening || listen_poll == nullptr || listen_fd < 0) + { + return; + } + if (listen(listen_fd, SOMAXCONN) != 0) + { + LOG_FAIL_FMT( + "listen() failed on port {}: {}", + bound_port, + std::generic_category().message(errno)); + return; + } + + const int rc = uv_poll_start(listen_poll, UV_READABLE, on_listen_poll); + if (rc != 0) + { + LOG_FAIL_FMT( + "uv_poll_start(listen) failed on port {}: {}", + bound_port, + uv_strerror(rc)); + return; + } + listening = true; + } + // Apply worker completions and cross-thread commands on the libuv thread. void drain_pending_out() { @@ -1238,6 +1274,12 @@ namespace asynchost } } + if (!certs.empty()) + { + // A cert-deferred interface has been waiting for exactly this. + begin_listening(); + } + for (auto& item : items) { auto fit = id_to_fd.find(item.id); @@ -1405,6 +1447,7 @@ namespace asynchost (void)uv_poll_stop(listen_poll); close_handle(listen_poll); } + listening = false; if (listen_fd >= 0) { ::close(listen_fd); @@ -1573,11 +1616,12 @@ namespace asynchost cleanup(); throw std::runtime_error("bind() failed for " + config.host); } - if (listen(listen_fd, SOMAXCONN) != 0) - { - cleanup(); - throw std::runtime_error("listen() failed"); - } + // Deliberately no listen() here. Binding reserves the port (and fixes an + // ephemeral one, which port() reports), but the socket only enters the + // LISTEN state once this interface can actually serve a connection - see + // begin_listening(). Until then the kernel refuses inbound SYNs, so a + // client fails to connect rather than completing a TCP handshake against + // an interface which will immediately drop it. if (!set_nonblocking(listen_fd)) { cleanup(); @@ -1633,6 +1677,7 @@ namespace asynchost torn_down = false; stopping = false; started = true; + listening = false; listen_poll = new_handle(); listen_poll->data = this; @@ -1675,12 +1720,10 @@ namespace asynchost } } - rc = uv_poll_start(listen_poll, UV_READABLE, on_listen_poll); - if (rc != 0) - { - throw std::runtime_error( - std::string("uv_poll_start(listen) failed: ") + uv_strerror(rc)); - } + // Only actually listens if this interface can serve a connection now; a + // TLS interface still waiting for its certificate stays bound but + // unlistening until set_server_cert() supplies one. + begin_listening(); if (inbound_admission != nullptr) { diff --git a/src/host/tls/openssl_session_manager.h b/src/host/tls/openssl_session_manager.h index f867856862f0..1de7ffa17385 100644 --- a/src/host/tls/openssl_session_manager.h +++ b/src/host/tls/openssl_session_manager.h @@ -26,7 +26,6 @@ // guarded by a mutex. #include "ccf/node/session.h" -#include "enclave/session_writer.h" #include "host/tls/openssl_server.h" #include diff --git a/src/http/curl.h b/src/http/curl.h index 000119138dc1..ba5af07efcfd 100644 --- a/src/http/curl.h +++ b/src/http/curl.h @@ -65,10 +65,8 @@ namespace ccf::curl // 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 certificate or - // application errors. CURLE_SSL_CONNECT_ERROR is retryable because it also - // reports a peer disappearing during the TLS handshake; certificate - // verification failures have distinct error codes. + // 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), @@ -78,11 +76,10 @@ namespace ccf::curl { return code == CURLE_COULDNT_RESOLVE_PROXY || code == CURLE_COULDNT_RESOLVE_HOST || code == CURLE_COULDNT_CONNECT || - code == CURLE_OPERATION_TIMEDOUT || code == CURLE_SSL_CONNECT_ERROR || - 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; + 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 diff --git a/src/http/test/curl_test.cpp b/src/http/test/curl_test.cpp index c0bd4862674e..f9ef7993880b 100644 --- a/src/http/test/curl_test.cpp +++ b/src/http/test/curl_test.cpp @@ -44,7 +44,6 @@ TEST_CASE("is_transient_transport_error classifies curl errors") CURLE_COULDNT_RESOLVE_HOST, CURLE_COULDNT_CONNECT, CURLE_OPERATION_TIMEDOUT, - CURLE_SSL_CONNECT_ERROR, CURLE_GOT_NOTHING, CURLE_RECV_ERROR, CURLE_SEND_ERROR, @@ -67,6 +66,7 @@ TEST_CASE("is_transient_transport_error classifies curl errors") CURLE_OK, CURLE_PEER_FAILED_VERIFICATION, CURLE_SSL_CACERT_BADFILE, + CURLE_SSL_CONNECT_ERROR, CURLE_SSL_CERTPROBLEM, CURLE_USE_SSL_FAILED, CURLE_WRITE_ERROR, diff --git a/src/tls/context.h b/src/tls/context.h new file mode 100644 index 000000000000..6b6905be67cd --- /dev/null +++ b/src/tls/context.h @@ -0,0 +1,264 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +#include "ccf/crypto/base64.h" +#include "cert.h" +#include "ds/internal_logger.h" +#include "tls/tls.h" + +#include +#include +#include + +namespace ccf::tls +{ + class Context + { + protected: + ccf::crypto::OpenSSL::Unique_SSL_CTX cfg; + std::unique_ptr ssl; + bool client; + + void create_ssl() + { + ssl = std::make_unique(cfg); + + // Initialise connection + if (client) + { + SSL_set_connect_state(*ssl); + } + else + { + SSL_set_accept_state(*ssl); + } + } + + SSL* get_ssl() + { + // Context construction is split from SSL creation, so catch accidental + // use before create_ssl(). + CHECKNULL(ssl.get()); + CHECKNULL(*ssl); + return *ssl; + } + + public: + Context(bool client_) : + cfg(client_ ? TLS_client_method() : TLS_server_method()), + client(client_) + { + // Require at least TLS 1.2, support up to 1.3 + CHECK1(SSL_CTX_set_min_proto_version(cfg, TLS1_2_VERSION)); + + // Disable renegotiation to avoid DoS + SSL_CTX_set_options( + cfg, + SSL_OP_CIPHER_SERVER_PREFERENCE | + SSL_OP_NO_SESSION_RESUMPTION_ON_RENEGOTIATION | + SSL_OP_NO_RENEGOTIATION); + + // Set cipher for TLS 1.2 + const auto* const cipher_list = + "ECDHE-ECDSA-AES256-GCM-SHA384:" + "ECDHE-ECDSA-AES128-GCM-SHA256:" + "ECDHE-RSA-AES256-GCM-SHA384:" + "ECDHE-RSA-AES128-GCM-SHA256"; + CHECK1(SSL_CTX_set_cipher_list(cfg, cipher_list)); + + // Set cipher for TLS 1.3 + const auto* const ciphersuites = + "TLS_AES_256_GCM_SHA384:" + "TLS_AES_128_GCM_SHA256"; + CHECK1(SSL_CTX_set_ciphersuites(cfg, ciphersuites)); + + // Prefer hybrid post-quantum groups when available, while retaining the + // approved classical groups as fallbacks + CHECK1(SSL_CTX_set1_groups_list( + cfg, + "?X25519MLKEM768:?SecP256r1MLKEM768:?SecP384r1MLKEM1024:" + "P-521:P-384:P-256")); + + // Allow buffer to be relocated between WANT_WRITE retries, and do partial + // writes if possible + SSL_CTX_set_mode( + cfg, + SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER | SSL_MODE_ENABLE_PARTIAL_WRITE); + } + + virtual ~Context() = default; + + virtual void set_bio( + void* cb_obj, BIO_callback_fn_ex send, BIO_callback_fn_ex recv) + { + // Read/Write BIOs will be used by TLS + std::unique_ptr rbio( + BIO_new(BIO_s_mem()), BIO_free); + CHECKNULL(rbio.get()); + + std::unique_ptr wbio( + BIO_new(BIO_s_mem()), BIO_free); + CHECKNULL(wbio.get()); + + BIO_set_mem_eof_return(rbio.get(), -1); + BIO_set_callback_arg(rbio.get(), static_cast(cb_obj)); + BIO_set_callback_ex(rbio.get(), recv); + SSL_set0_rbio(get_ssl(), rbio.release()); + + BIO_set_mem_eof_return(wbio.get(), -1); + BIO_set_callback_arg(wbio.get(), static_cast(cb_obj)); + BIO_set_callback_ex(wbio.get(), send); + SSL_set0_wbio(get_ssl(), wbio.release()); + } + + virtual int handshake() + { + if (SSL_is_init_finished(get_ssl()) != 0) + { + return 0; + } + + int rc = SSL_do_handshake(get_ssl()); + // Success in OpenSSL is 1, MBed is 0 + if (rc > 0) + { + LOG_TRACE_FMT("Context::handshake() : Success"); + return 0; + } + + // Want read/write needs special return + if (SSL_want_read(get_ssl())) + { + return TLS_ERR_WANT_READ; + } + + if (SSL_want_write(get_ssl())) + { + return TLS_ERR_WANT_WRITE; + } + + // So does x509 validation + if (!peer_cert_ok()) + { + return TLS_ERR_X509_VERIFY; + } + + // Everything else falls here. + LOG_TRACE_FMT("Context::handshake() : Error code {}", rc); + + // As an MBedTLS emulation, we return negative for errors. + return -SSL_get_error(get_ssl(), rc); + } + + virtual int read(uint8_t* buf, size_t len) + { + if (len == 0) + { + return 0; + } + size_t readbytes = 0; + int rc = SSL_read_ex(get_ssl(), buf, len, &readbytes); + if (rc > 0) + { + return readbytes; + } + if (SSL_want_read(get_ssl())) + { + return TLS_ERR_WANT_READ; + } + + // Everything else falls here. + LOG_TRACE_FMT("Context::read() : Error code {}", rc); + + // As an MBedTLS emulation, we return negative for errors. + return -SSL_get_error(get_ssl(), rc); + } + + virtual int write(const uint8_t* buf, size_t len) + { + if (len == 0) + { + return 0; + } + size_t written = 0; + int rc = SSL_write_ex(get_ssl(), buf, len, &written); + if (rc > 0) + { + return written; + } + if (SSL_want_write(get_ssl())) + { + return TLS_ERR_WANT_WRITE; + } + + // Everything else falls here. + LOG_TRACE_FMT("Context::write() : Error code {}", rc); + + // As an MBedTLS emulation, we return negative for errors. + return -SSL_get_error(get_ssl(), rc); + } + + virtual int close() + { + LOG_TRACE_FMT("Context::close() : Shutdown"); + return SSL_shutdown(get_ssl()); + } + + virtual bool peer_cert_ok() + { + return SSL_get_verify_result(get_ssl()) == X509_V_OK; + } + + virtual std::string get_verify_error() + { + return X509_verify_cert_error_string(SSL_get_verify_result(get_ssl())); + } + + virtual std::string host() + { + return {}; + } + + virtual std::vector peer_cert() + { + // CodeQL complains that we don't verify the peer certificate. We don't + // need to do that because it's been verified before and we use + // SSL_get_peer_certificate just to extract it from the context. + + ccf::crypto::OpenSSL::Unique_X509 cert( + SSL_get_peer_certificate(get_ssl()), /*check_null=*/false); + if (cert == nullptr) + { + LOG_TRACE_FMT("Empty peer cert"); + return {}; + } + ccf::crypto::OpenSSL::Unique_BIO bio; + if (i2d_X509_bio(bio, cert) == 0) + { + LOG_TRACE_FMT("Can't convert X509 to DER"); + return {}; + } + + // Get the total length of the DER representation + auto len = BIO_get_mem_data(bio, nullptr); + if (len == 0) + { + LOG_TRACE_FMT("Null X509 peer cert"); + return {}; + } + + // Get the BIO memory pointer + BUF_MEM* ptr = nullptr; + if (BIO_get_mem_ptr(bio, &ptr) == 0) + { + LOG_TRACE_FMT("Invalid X509 peer cert"); + return {}; + } + + // Return its contents as a vector + auto ret = std::vector(ptr->data, ptr->data + len); + return ret; + } + }; +} From 2fd0a9575ec49928ae1eb362afdfe0f9cdbbefb1 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Mon, 10 Aug 2026 14:22:25 +0000 Subject: [PATCH 51/59] Format and pyproject version --- python/pyproject.toml | 2 +- src/host/test/openssl_server_test.cpp | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 302014bf5306..62d59ea26314 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ccf" -version = "7.0.12" +version = "7.0.13" authors = [ { name="CCF Team", email="CCF-Sec@microsoft.com" }, ] diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp index 9834f1e90dd2..a4d248913107 100644 --- a/src/host/test/openssl_server_test.cpp +++ b/src/host/test/openssl_server_test.cpp @@ -920,8 +920,7 @@ TEST_CASE("A TLS interface without a certificate refuses connections") REQUIRE( SSL_read(ssl, echoed.data(), static_cast(echoed.size())) == static_cast(request.size())); - REQUIRE( - std::string(echoed.begin(), echoed.end()) == request); + REQUIRE(std::string(echoed.begin(), echoed.end()) == request); SSL_free(ssl); SSL_CTX_free(cctx); @@ -930,9 +929,8 @@ TEST_CASE("A TLS interface without a certificate refuses connections") { std::unique_lock lock(m); - REQUIRE(cv.wait_for(lock, std::chrono::seconds(10), [&]() { - return data_callbacks > 0; - })); + REQUIRE(cv.wait_for( + lock, std::chrono::seconds(10), [&]() { return data_callbacks > 0; })); } server->stop(OpenSSLServer::LoopState::Running); From c82475f05954de5660b3805ece18001e27fad53e Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Tue, 11 Aug 2026 15:08:59 +0000 Subject: [PATCH 52/59] Wake zero-worker enclaves for queued TLS work Notify the enclave work beacon when transport tasks enter the JobBoard, while preserving direct worker handoff and coalescing redundant wakeups. Keep bounded task drains moving immediately when a backlog remains.\n\nRefs #8117\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 ++ src/ds/work_beacon.h | 18 +++++++ src/enclave/enclave.h | 19 +++++-- src/tasks/job_board.cpp | 70 +++++++++++++++++++------ src/tasks/job_board.h | 2 + src/tasks/test/basic_tasks.cpp | 96 ++++++++++++++++++++++++++++++++++ 6 files changed, 189 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3885bd251014..a496d7e1bfcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - TLS is now terminated by OpenSSL directly on the socket, rather than being relayed over the ringbuffer and decrypted through a memory BIO. The session interfaces in `include/ccf/node/session.h` and `include/ccf/research/custom_protocol_subsystem_interface.h` have changed shape accordingly: a session now receives and emits plaintext, taking ownership of the inbound buffer, and writes its output through a `ccf::SessionWriter` rather than a `tls::Context` (#8117). +### Fixed + +- Nodes configured with zero worker threads now wake the enclave main loop immediately when the OpenSSL transport queues work, rather than waiting for the next tick or polling timeout (#8117). + ## [7.0.12] [7.0.12]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.12 diff --git a/src/ds/work_beacon.h b/src/ds/work_beacon.h index 277f505f19d7..c603d1bf2d7a 100644 --- a/src/ds/work_beacon.h +++ b/src/ds/work_beacon.h @@ -55,6 +55,24 @@ namespace ccf::ds condition_variable.notify_all(); } + + void notify_work_available_coalesced() + { + bool notify = false; + { + std::lock_guard lock(mutex); + if (work_available == 0) + { + work_available = 1; + notify = true; + } + } + + if (notify) + { + condition_variable.notify_all(); + } + } }; using WorkBeaconPtr = std::shared_ptr; diff --git a/src/enclave/enclave.h b/src/enclave/enclave.h index 5ebe219eacf8..9485b491a50b 100644 --- a/src/enclave/enclave.h +++ b/src/enclave/enclave.h @@ -184,10 +184,13 @@ namespace ccf signature_cache, sig_tx_interval, sig_ms_interval); + + ccf::tasks::get_main_job_board().set_work_beacon(work_beacon); } ~Enclave() { + ccf::tasks::get_main_job_board().set_work_beacon(nullptr); LOG_TRACE_FMT("Shutting down enclave"); } @@ -480,12 +483,16 @@ namespace ccf // processed in a single iteration static constexpr size_t max_messages = 256; + bool should_wait_for_work = true; while (!bp.get_finished()) { - // Wait until the host indicates that some ringbuffer messages are - // available, but wake at least every 100ms to check thread messages - work_beacon->wait_for_work_with_timeout( - std::chrono::milliseconds(100)); + if (should_wait_for_work) + { + // Wait until the host indicates that some ringbuffer messages or + // tasks are available, but wake at least every 100ms. + work_beacon->wait_for_work_with_timeout( + std::chrono::milliseconds(100)); + } // First, read some messages from the ringbuffer auto read = bp.read_n(max_messages, circuit->read_from_outside()); @@ -504,6 +511,10 @@ namespace ccf } task = job_board.get_task(); } + // Hitting the task budget may leave queued work behind. Continue + // immediately rather than consuming the only coalesced wake and then + // sleeping with a non-empty JobBoard. + should_wait_for_work = tasks_done < max_messages; // If no messages were read from the ringbuffer and tasks were // executed, idle diff --git a/src/tasks/job_board.cpp b/src/tasks/job_board.cpp index 3ffdcd2ee285..e51da58c20c4 100644 --- a/src/tasks/job_board.cpp +++ b/src/tasks/job_board.cpp @@ -70,33 +70,66 @@ namespace ccf::tasks std::shared_ptr> waiting_worker_threads = std::make_shared>(); + ccf::ds::WorkBeaconPtr work_beacon = nullptr; + // Collection of delayed tasks, that may be ready for execution on a future // tick Delayed delayed; - void add_task(Task&& task) + void set_work_beacon(ccf::ds::WorkBeaconPtr work_beacon_) { - // Under lock - std::unique_lock lock(mutex); + ccf::ds::WorkBeaconPtr beacon; + { + std::lock_guard lock(mutex); + work_beacon = std::move(work_beacon_); + if (work_beacon != nullptr && !pending_tasks.empty()) + { + beacon = work_beacon; + } + } - // First check if there is an idle worker waiting for a task - for (WorkerThreadPtr& worker : *waiting_worker_threads) + if (beacon != nullptr) { - // NB: Although waiting_worker_threads is modified under lock, it is - // possible that a second call to add_task arrives before the notified - // thread wakes up and removes itself from this collection. In this case - // we must avoid overwriting a previously-assigned task. - if (worker->assigned_task == nullptr) + beacon->notify_work_available_coalesced(); + } + } + + void add_task(Task&& task) + { + ccf::ds::WorkBeaconPtr beacon; + { + // Under lock + std::unique_lock lock(mutex); + + // First check if there is an idle worker waiting for a task + for (WorkerThreadPtr& worker : *waiting_worker_threads) { - worker->assigned_task = std::move(task); - worker->cv.notify_one(); - return; + // NB: Although waiting_worker_threads is modified under lock, it is + // possible that a second call to add_task arrives before the notified + // thread wakes up and removes itself from this collection. In this + // case we must avoid overwriting a previously-assigned task. + if (worker->assigned_task == nullptr) + { + worker->assigned_task = std::move(task); + worker->cv.notify_one(); + return; + } } + + // There are no waiting_worker_threads currently, or none waiting for a + // task, so enqueue this task for later execution. Wake the external + // consumer only when the pending queue becomes non-empty. + if (pending_tasks.empty()) + { + beacon = work_beacon; + } + pending_tasks.emplace(std::move(task)); } - // There are no waiting_worker_threads currently, or none waiting for a - // task, so enqueue this task for later execution - pending_tasks.emplace(std::move(task)); + if (beacon != nullptr) + { + beacon->notify_work_available_coalesced(); + } } Task get_task() @@ -226,6 +259,11 @@ namespace ccf::tasks JobBoard::~JobBoard() = default; + void JobBoard::set_work_beacon(ccf::ds::WorkBeaconPtr work_beacon) + { + pimpl->set_work_beacon(std::move(work_beacon)); + } + void JobBoard::add_task(Task task) { pimpl->add_task(std::move(task)); diff --git a/src/tasks/job_board.h b/src/tasks/job_board.h index c2a5cb349ba3..5cfb28b23c28 100644 --- a/src/tasks/job_board.h +++ b/src/tasks/job_board.h @@ -25,6 +25,8 @@ namespace ccf::tasks JobBoard(); ~JobBoard(); + void set_work_beacon(ccf::ds::WorkBeaconPtr work_beacon); + void add_task(Task t); Task get_task(); diff --git a/src/tasks/test/basic_tasks.cpp b/src/tasks/test/basic_tasks.cpp index eb79a19fd714..9bc990a2f014 100644 --- a/src/tasks/test/basic_tasks.cpp +++ b/src/tasks/test/basic_tasks.cpp @@ -97,6 +97,102 @@ TEST_CASE("JobBoard" * doctest::test_suite("basic_tasks")) REQUIRE(b.load()); } +TEST_CASE("JobBoard external work beacon" * doctest::test_suite("basic_tasks")) +{ + constexpr auto short_wait = std::chrono::milliseconds(10); + + auto work_beacon = std::make_shared(); + ccf::tasks::JobBoard job_board; + job_board.set_work_beacon(work_beacon); + + const auto first = ccf::tasks::make_basic_task([]() {}); + const auto second = ccf::tasks::make_basic_task([]() {}); + + SUBCASE("Pending work notifications are coalesced") + { + job_board.add_task(first); + REQUIRE(work_beacon->wait_for_work_with_timeout(short_wait)); + + job_board.add_task(second); + REQUIRE_FALSE(work_beacon->wait_for_work_with_timeout(short_wait)); + + REQUIRE(job_board.get_task() == first); + REQUIRE(job_board.get_task() == second); + + job_board.add_task(first); + REQUIRE(job_board.get_task() == first); + job_board.add_task(second); + + REQUIRE(work_beacon->wait_for_work_with_timeout(short_wait)); + REQUIRE_FALSE(work_beacon->wait_for_work_with_timeout(short_wait)); + } + + SUBCASE("Direct worker handoff does not notify the external consumer") + { + ccf::tasks::Task received = nullptr; + std::thread worker( + [&]() { received = job_board.wait_for_task(std::chrono::seconds(1)); }); + + while (job_board.get_summary().idle_workers == 0) + { + std::this_thread::yield(); + } + + job_board.add_task(first); + worker.join(); + + REQUIRE(received == first); + REQUIRE_FALSE(work_beacon->wait_for_work_with_timeout(short_wait)); + } + + SUBCASE("Existing pending work is notified when a beacon is registered") + { + job_board.set_work_beacon(nullptr); + job_board.add_task(first); + job_board.set_work_beacon(work_beacon); + + REQUIRE(work_beacon->wait_for_work_with_timeout(short_wait)); + REQUIRE(job_board.get_task() == first); + } + + SUBCASE("A bounded consumer continues without waiting through a backlog") + { + constexpr size_t max_tasks_per_iteration = 256; + constexpr size_t num_tasks = max_tasks_per_iteration + 100; + for (size_t i = 0; i < num_tasks; ++i) + { + job_board.add_task(ccf::tasks::make_basic_task([]() {})); + } + + size_t completed = 0; + bool should_wait_for_work = true; + while (completed < num_tasks) + { + if (should_wait_for_work) + { + REQUIRE(work_beacon->wait_for_work_with_timeout(short_wait)); + } + + size_t completed_this_iteration = 0; + while (completed_this_iteration < max_tasks_per_iteration) + { + auto task = job_board.get_task(); + if (task == nullptr) + { + break; + } + + ++completed; + ++completed_this_iteration; + } + + should_wait_for_work = completed_this_iteration < max_tasks_per_iteration; + } + + REQUIRE(job_board.get_summary().pending_tasks == 0); + } +} + TEST_CASE("Cancellation" * doctest::test_suite("basic_tasks")) { ccf::tasks::JobBoard job_board; From 66371baba05502c50afa731563e960b44e91529d Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Wed, 12 Aug 2026 11:41:56 +0000 Subject: [PATCH 53/59] Update changelog and improve OpenSSL server tests and header file --- CHANGELOG.md | 1 + src/host/test/openssl_server_test.cpp | 29 ++++++++++++++------------- src/host/tls/openssl_server.h | 4 +++- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a496d7e1bfcf..6091ffbe4a68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed +- TLS interfaces without a configured certificate now remain unlistening until a certificate is supplied, so clients receive an immediate connection refusal rather than entering an unusable TLS connection (#8117). - Nodes configured with zero worker threads now wake the enclave main loop immediately when the OpenSSL transport queues work, rather than waiting for the next tick or polling timeout (#8117). ## [7.0.12] diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp index a4d248913107..995ef789dc9b 100644 --- a/src/host/test/openssl_server_test.cpp +++ b/src/host/test/openssl_server_test.cpp @@ -11,7 +11,7 @@ #include "host/tls/openssl_session_manager.h" #include "tasks/task_system.h" -#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#define DOCTEST_CONFIG_IMPLEMENT #include #include #include @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -53,19 +54,6 @@ namespace return signal(SIGPIPE, SIG_IGN) != SIG_ERR; }(); - struct TaskWorkers - { - TaskWorkers() - { - ccf::tasks::set_task_threads(4); - } - - ~TaskWorkers() - { - ccf::tasks::set_task_threads(0); - } - } task_workers; - std::pair make_server_cert() { using namespace std::literals; @@ -2023,3 +2011,16 @@ TEST_CASE("Inbound budget is released when a connection closes unreported") bridge.stop(OpenSSLServer::LoopState::Running); loop.thread.join(); } + +int main(int argc, char** argv) +{ + ccf::tasks::set_task_threads(4); + + doctest::Context context; + context.applyCommandLine(argc, argv); + const auto result = context.run(); + + ccf::tasks::set_task_threads(0); + OPENSSL_cleanup(); + return result; +} diff --git a/src/host/tls/openssl_server.h b/src/host/tls/openssl_server.h index 458047831b1a..b0da31d1e62a 100644 --- a/src/host/tls/openssl_server.h +++ b/src/host/tls/openssl_server.h @@ -1214,7 +1214,9 @@ namespace asynchost // arrived later, from the loop thread once it has been applied. void begin_listening() { - if (listening || listen_poll == nullptr || listen_fd < 0) + if ( + listening || listen_poll == nullptr || listen_fd < 0 || + (!plaintext && ctx == nullptr)) { return; } From a7f22b3582810745b1b42019cefbe71cfefd4550 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Wed, 12 Aug 2026 16:00:34 +0000 Subject: [PATCH 54/59] Update CMakeLists and improve rpc_tls_client and openssl_server_test code - Modify CMakeLists.txt for better configuration. - Refactor rpc_tls_client.h for improved clarity and functionality. - Enhance openssl_server_test.cpp with additional test cases. --- CMakeLists.txt | 2 +- src/clients/rpc_tls_client.h | 17 ++++++----- src/host/test/openssl_server_test.cpp | 43 +++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 33ca002905c6..847cfd24302a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -718,7 +718,7 @@ if(BUILD_TESTS) openssl_server_test ${CMAKE_CURRENT_SOURCE_DIR}/src/host/test/openssl_server_test.cpp ) - target_link_libraries(openssl_server_test PRIVATE ccf_tasks uv) + target_link_libraries(openssl_server_test PRIVATE ccf_tasks http_parser uv) target_compile_definitions( openssl_server_test PRIVATE TEST_HYBRID_TLS_GROUPS=$ diff --git a/src/clients/rpc_tls_client.h b/src/clients/rpc_tls_client.h index a4a8251ef945..80037ca0e642 100644 --- a/src/clients/rpc_tls_client.h +++ b/src/clients/rpc_tls_client.h @@ -8,6 +8,7 @@ #include "tls_client.h" #define FMT_HEADER_ONLY +#include #include #include #include @@ -90,7 +91,7 @@ namespace client return call_raw(prep.encoded); } - std::optional last_response; + std::deque pending_responses; public: using TlsClient::TlsClient; @@ -206,20 +207,20 @@ namespace client Response read_response() { - last_response = std::nullopt; - - while (!last_response.has_value()) + while (pending_responses.empty()) { const auto next = read_all(); parser.execute(next.data(), next.size()); } - return std::move(last_response.value()); + auto response = std::move(pending_responses.front()); + pending_responses.pop_front(); + return response; } std::optional read_response_non_blocking() { - if (bytes_available()) + if (!pending_responses.empty() || bytes_available()) { return read_response(); } @@ -232,8 +233,8 @@ namespace client ccf::http::HeaderMap&& headers, std::vector&& body) override { - last_response = { - next_recv_id++, status, std::move(headers), std::move(body)}; + pending_responses.push_back( + {next_recv_id++, status, std::move(headers), std::move(body)}); } void set_prefix(const std::string& prefix_) diff --git a/src/host/test/openssl_server_test.cpp b/src/host/test/openssl_server_test.cpp index 995ef789dc9b..ee96de293aca 100644 --- a/src/host/test/openssl_server_test.cpp +++ b/src/host/test/openssl_server_test.cpp @@ -5,6 +5,7 @@ #include "ccf/crypto/ec_key_pair.h" #include "ccf/ds/x509_time_fmt.h" +#include "clients/rpc_tls_client.h" #include "crypto/certs.h" #include "host/datagram_server.h" #include "host/tls/openssl_server.h" @@ -13,6 +14,7 @@ #define DOCTEST_CONFIG_IMPLEMENT #include +#include #include #include #include @@ -1050,6 +1052,47 @@ TEST_CASE("TLS handshake and small round-trip") REQUIRE(tls_client_exchange(s.port(), msg, msg.size()) == msg); } +TEST_CASE("Coalesced pipelined HTTP responses are preserved") +{ + auto [cert, key] = make_server_cert(); + + std::shared_ptr server; + server = std::make_shared( + OpenSSLServer::Config{ + .host = "127.0.0.1", .cert_pem = cert, .key_pem = key}, + [&]( + ::tcp::ConnID id, + std::vector, + const std::vector&, + bool) { + const std::string responses = + "HTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\na" + "HTTP/1.1 201 Created\r\nContent-Length: 1\r\n\r\nb"; + server->send( + id, std::vector(responses.begin(), responses.end())); + }); + UVLoopRunner loop; + server->start(); + loop.start(); + + { + auto ca = std::make_shared<::tls::CA>(cert); + client::RpcTlsClient client( + "127.0.0.1", std::to_string(server->port()), ca); + const std::array request = {'x'}; + client.write(request); + + const auto first = client.read_response(); + const auto second = client.read_response(); + REQUIRE(first.status == HTTP_STATUS_OK); + REQUIRE(std::string(first.body.begin(), first.body.end()) == "a"); + REQUIRE(second.status == HTTP_STATUS_CREATED); + REQUIRE(std::string(second.body.begin(), second.body.end()) == "b"); + } + + server->stop(OpenSSLServer::LoopState::Running); +} + TEST_CASE("TLS processing runs off the libuv thread") { auto [cert, key] = make_server_cert(); From b0d7982afebc5773c14175842809caebcdee4bf4 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Thu, 13 Aug 2026 13:38:45 +0000 Subject: [PATCH 55/59] Update changelog and enhance HTTP curl functionality and tests --- CHANGELOG.md | 5 ----- src/http/curl.h | 13 +++++++++++++ src/http/test/curl_test.cpp | 22 ++++++++++++++++++---- src/node/node_state.h | 15 +++++++++++---- 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6091ffbe4a68..3885bd251014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - TLS is now terminated by OpenSSL directly on the socket, rather than being relayed over the ringbuffer and decrypted through a memory BIO. The session interfaces in `include/ccf/node/session.h` and `include/ccf/research/custom_protocol_subsystem_interface.h` have changed shape accordingly: a session now receives and emits plaintext, taking ownership of the inbound buffer, and writes its output through a `ccf::SessionWriter` rather than a `tls::Context` (#8117). -### Fixed - -- TLS interfaces without a configured certificate now remain unlistening until a certificate is supplied, so clients receive an immediate connection refusal rather than entering an unusable TLS connection (#8117). -- Nodes configured with zero worker threads now wake the enclave main loop immediately when the OpenSSL transport queues work, rather than waiting for the next tick or polling timeout (#8117). - ## [7.0.12] [7.0.12]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.12 diff --git a/src/http/curl.h b/src/http/curl.h index ba5af07efcfd..1e03f3da1de2 100644 --- a/src/http/curl.h +++ b/src/http/curl.h @@ -82,6 +82,19 @@ namespace ccf::curl code == CURLE_HTTP2 || code == CURLE_HTTP2_STREAM; } + inline bool is_retryable_join_error( + CURLcode code, bool has_received_pending_join_response) + { + // CURLE_SSL_CONNECT_ERROR is intentionally not generally transient: it + // covers permanent TLS configuration and protocol errors as well as a peer + // disappearing during the handshake. Once this joiner has received a + // PENDING response, the same TLS configuration and pinned service identity + // have already succeeded, so a later handshake failure may be retried while + // that target changes role. + return is_transient_transport_error(code) || + (has_received_pending_join_response && code == CURLE_SSL_CONNECT_ERROR); + } + class UniqueCURL { private: diff --git a/src/http/test/curl_test.cpp b/src/http/test/curl_test.cpp index f9ef7993880b..2939a513e52c 100644 --- a/src/http/test/curl_test.cpp +++ b/src/http/test/curl_test.cpp @@ -58,10 +58,10 @@ TEST_CASE("is_transient_transport_error classifies curl errors") CHECK(ccf::curl::is_transient_transport_error(code)); } - // Errors that must be treated as fatal (never retried): TLS/certificate - // failures, application-level errors, and our own response size-cap - // rejection (CURLE_WRITE_ERROR). CURLE_OK and CURLE_ABORTED_BY_CALLBACK are - // not transport errors either. + // Errors that must be treated as fatal (never retried): explicit certificate + // or local TLS configuration failures, application-level errors, and our own + // response size-cap rejection (CURLE_WRITE_ERROR). CURLE_OK and + // CURLE_ABORTED_BY_CALLBACK are not transport errors either. const std::vector fatal = { CURLE_OK, CURLE_PEER_FAILED_VERIFICATION, @@ -81,6 +81,20 @@ TEST_CASE("is_transient_transport_error classifies curl errors") } } +TEST_CASE("is_retryable_join_error narrows generic TLS handshake failures") +{ + CHECK_FALSE( + ccf::curl::is_retryable_join_error(CURLE_SSL_CONNECT_ERROR, false)); + CHECK(ccf::curl::is_retryable_join_error(CURLE_SSL_CONNECT_ERROR, true)); + + CHECK(ccf::curl::is_retryable_join_error(CURLE_COULDNT_CONNECT, false)); + CHECK_FALSE( + ccf::curl::is_retryable_join_error(CURLE_PEER_FAILED_VERIFICATION, true)); + CHECK_FALSE( + ccf::curl::is_retryable_join_error(CURLE_SSL_CACERT_BADFILE, true)); + CHECK_FALSE(ccf::curl::is_retryable_join_error(CURLE_SSL_CERTPROBLEM, true)); +} + TEST_CASE("RequestBody supports replay") { const std::vector expected = {1, 2, 3, 4}; diff --git a/src/node/node_state.h b/src/node/node_state.h index 83247519dfae..15be361d8b62 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -490,6 +490,11 @@ namespace ccf // and so must not take NodeState::lock. std::atomic join_request_in_flight = false; + // A successful PENDING response proves that this joiner's TLS settings and + // pinned service identity are valid. A later generic TLS handshake failure + // can then be retried safely while the target changes role. + bool has_received_pending_join_response = false; + // Number of times we have fetched the latest snapshot from the primary size_t join_fetch_count = 0; @@ -1410,10 +1415,11 @@ namespace ccf // The legacy httpclient path silently dropped a failed // connection and relied on the periodic join timer to retry // when the target could not yet be reached, while treating TLS - // handshake failures (e.g. an untrusted service certificate) as - // fatal. Preserve both behaviours: transient transport errors - // are retried, everything else is fatal. - if (ccf::curl::is_transient_transport_error(curl_response)) + // explicit certificate verification/loading failures as fatal. + // Preserve both behaviours: transient transport errors are + // retried, everything else is fatal. + if (ccf::curl::is_retryable_join_error( + curl_response, has_received_pending_join_response)) { LOG_INFO_FMT( "Transient error contacting {} to join: {} ({}). The join " @@ -1740,6 +1746,7 @@ namespace ccf } else if (resp.node_status == NodeStatus::PENDING) { + has_received_pending_join_response = true; LOG_INFO_FMT( "Node {} is waiting for votes of members to be trusted", self); From bfa22a50a264f49d9e201a62febd0cf12a7ba884 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Fri, 14 Aug 2026 10:10:04 +0000 Subject: [PATCH 56/59] Synchronize public AFT state queries RPC task workers can query consensus state concurrently with Raft message processing. Publish a coherent query snapshot without taking the Raft lock from KV-backed endpoints, avoiding both data races and KV/Raft lock inversion.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/consensus/aft/raft.h | 221 ++++++++++++++++++++++++++++++--------- 1 file changed, 169 insertions(+), 52 deletions(-) diff --git a/src/consensus/aft/raft.h b/src/consensus/aft/raft.h index 286a3e0ebb8c..1b122791ea08 100644 --- a/src/consensus/aft/raft.h +++ b/src/consensus/aft/raft.h @@ -124,7 +124,15 @@ namespace aft // Volatile std::optional voted_for = std::nullopt; + // Public consensus queries may run on task workers while Raft messages are + // processed on the enclave main thread. Keep these small query fields + // independently synchronized so endpoint transactions do not need to take + // state->lock and invert the KV/Raft lock order. + mutable ccf::pal::Mutex public_state_lock; std::optional leader_id = std::nullopt; + Index published_last_idx = 0; + Index published_commit_idx = 0; + ViewHistory published_view_history; // Keep track of votes in each active configuration struct Votes @@ -201,6 +209,105 @@ namespace aft // pre-deserialisation, without an additional header. static constexpr size_t max_terms_per_append_entries = 1; + // Called while state->lock is held. + void set_leader_id(const ccf::NodeId& leader) + { + std::lock_guard guard(public_state_lock); + leader_id = leader; + } + + // Called while state->lock is held. + void reset_leader_id() + { + std::lock_guard guard(public_state_lock); + leader_id.reset(); + } + + // Called while state->lock is held. + void set_leadership_state(ccf::kv::LeadershipState leadership_state) + { + std::lock_guard guard(public_state_lock); + state->leadership_state = leadership_state; + } + + // Called while state->lock is held. + void set_current_view(Term view) + { + std::lock_guard guard(public_state_lock); + state->current_view = view; + } + + // Called while state->lock is held. + void advance_current_view(Term increment) + { + std::lock_guard guard(public_state_lock); + state->current_view += increment; + } + + // Called while state->lock is held. + void initialise_log_state( + Index last_idx, + Index commit_idx, + const std::vector& terms, + std::optional> view_update = std::nullopt) + { + state->last_idx = last_idx; + state->commit_idx = commit_idx; + state->view_history.initialise(terms); + if (view_update.has_value()) + { + state->view_history.update(view_update->first, view_update->second); + } + publish_log_state(); + } + + // Called while state->lock is held. + void publish_replicated_entry(Index index, Term view) + { + state->last_idx = index; + state->view_history.update(index, view); + publish_log_state(); + } + + // Called while state->lock is held, or during construction. + void publish_log_state() + { + std::lock_guard guard(public_state_lock); + published_last_idx = state->last_idx; + published_commit_idx = state->commit_idx; + published_view_history = state->view_history; + } + + // Called while state->lock is held. + void update_view_history(Index index, Term view) + { + state->view_history.update(index, view); + } + + // Called while state->lock is held. + void rollback_view_history(Index index) + { + state->view_history.rollback(index); + } + + // Called while state->lock is held. + void set_last_idx(Index index) + { + state->last_idx = index; + } + + // Called while state->lock is held. + void decrement_last_idx() + { + state->last_idx--; + } + + // Called while state->lock is held. + void set_commit_idx(Index index) + { + state->commit_idx = index; + } + public: static constexpr size_t append_entries_size_limit = 20000; std::unique_ptr ledger; @@ -239,6 +346,7 @@ namespace aft ledger(std::move(ledger_)), channels(std::move(channels_)) { + publish_log_state(); if (commit_callbacks != nullptr) { commit_callbacks->set_consensus(this); @@ -249,6 +357,7 @@ namespace aft std::optional primary() override { + std::lock_guard guard(public_state_lock); return leader_id; } @@ -259,11 +368,13 @@ namespace aft bool is_primary() override { + std::lock_guard guard(public_state_lock); return state->leadership_state == ccf::kv::LeadershipState::Leader; } bool is_candidate() override { + std::lock_guard guard(public_state_lock); return state->leadership_state == ccf::kv::LeadershipState::Candidate; } @@ -305,6 +416,7 @@ namespace aft bool is_backup() override { + std::lock_guard guard(public_state_lock); return state->leadership_state == ccf::kv::LeadershipState::Follower; } @@ -404,14 +516,14 @@ namespace aft { // This is unsafe and should only be called when the node is certain // there is no leader and no other node will attempt to force leadership. + std::lock_guard guard(state->lock); if (leader_id.has_value()) { throw std::logic_error( "Can't force leadership if there is already a leader"); } - std::lock_guard guard(state->lock); - state->current_view += starting_view_change; + advance_current_view(starting_view_change); become_leader(true); } @@ -423,19 +535,16 @@ namespace aft { // This is unsafe and should only be called when the node is certain // there is no leader and no other node will attempt to force leadership. + std::lock_guard guard(state->lock); if (leader_id.has_value()) { throw std::logic_error( "Can't force leadership if there is already a leader"); } - std::lock_guard guard(state->lock); - state->current_view = term; - state->last_idx = index; - state->commit_idx = commit_idx_; - state->view_history.initialise(terms); - state->view_history.update(index, term); - state->current_view += starting_view_change; + initialise_log_state( + index, commit_idx_, terms, std::make_pair(index, term)); + set_current_view(term + starting_view_change); become_leader(true); } @@ -449,10 +558,7 @@ namespace aft // before it has received any append entries. std::lock_guard guard(state->lock); - state->last_idx = index; - state->commit_idx = index; - - state->view_history.initialise(term_history); + initialise_log_state(index, index, term_history); ledger->init(index, recovery_start_index); @@ -461,44 +567,50 @@ namespace aft Index get_last_idx() { - return state->last_idx; + std::lock_guard guard(public_state_lock); + return published_last_idx; } Index get_committed_seqno() override { - std::lock_guard guard(state->lock); - return get_commit_idx_unsafe(); + std::lock_guard guard(public_state_lock); + return published_commit_idx; } Term get_view() override { - std::lock_guard guard(state->lock); + std::lock_guard guard(public_state_lock); return state->current_view; } std::pair get_committed_txid() override { - std::lock_guard guard(state->lock); - ccf::SeqNo commit_idx = get_commit_idx_unsafe(); - return {get_term_internal(commit_idx), commit_idx}; + std::lock_guard guard(public_state_lock); + return { + published_view_history.view_at(published_commit_idx), + published_commit_idx}; } Term get_view(Index idx) override { - std::lock_guard guard(state->lock); - return get_term_internal(idx); + std::lock_guard guard(public_state_lock); + if (idx > published_last_idx) + { + return ccf::VIEW_UNKNOWN; + } + return published_view_history.view_at(idx); } std::vector get_view_history(Index idx) override { - // This should only be called when the spin lock is held. - return state->view_history.get_history_until(idx); + std::lock_guard guard(public_state_lock); + return published_view_history.get_history_until(idx); } std::vector get_view_history_since(Index idx) override { - // This should only be called when the spin lock is held. - return state->view_history.get_history_since(idx); + std::lock_guard guard(public_state_lock); + return published_view_history.get_history_since(idx); } // Same as ccfraft.tla GetServerSet/IsInServerSet @@ -706,13 +818,12 @@ namespace aft should_sign = false; } - state->last_idx = index; ledger->put_entry( *data, globally_committable, state->current_view, index); entry_size_not_limited += data->size(); entry_count++; - state->view_history.update(index, state->current_view); + publish_replicated_entry(index, state->current_view); if (entry_size_not_limited >= append_entries_size_limit) { update_batch_size(); @@ -1203,7 +1314,7 @@ namespace aft restart_election_timeout(); if (!leader_id.has_value() || leader_id.value() != from) { - leader_id = from; + set_leader_id(from); RAFT_DEBUG_FMT( "Node {} thinks leader is {}", state->node_id, leader_id.value()); } @@ -1377,10 +1488,11 @@ namespace aft if (apply_success == ccf::kv::ApplyResult::FAIL) { ledger->truncate(i - 1); + publish_log_state(); send_append_entries_response_nack(from); return; } - state->last_idx = i; + set_last_idx(i); for (auto& hook : ds->get_hooks()) { @@ -1404,7 +1516,7 @@ namespace aft case ccf::kv::ApplyResult::FAIL: { RAFT_FAIL_FMT("Follower failed to apply log entry: {}", i); - state->last_idx--; + decrement_last_idx(); ledger->truncate(state->last_idx); send_append_entries_response_nack(from); break; @@ -1428,7 +1540,7 @@ namespace aft // happened in sig_term. We reflect this in the history. if (r.term_of_idx == aft::ViewHistory::InvalidView) { - state->view_history.update(1, r.term); + update_view_history(1, r.term); } else { @@ -1439,7 +1551,7 @@ namespace aft max_terms_per_append_entries == 1, "AppendEntries processing for term updates assumes single " "term"); - state->view_history.update(r.prev_idx + 1, ds->get_term()); + update_view_history(r.prev_idx + 1, ds->get_term()); } commit_if_possible(r.leader_commit_idx); @@ -1465,6 +1577,7 @@ namespace aft } execute_append_entries_finish(r, from); + publish_log_state(); } void execute_append_entries_finish( @@ -1483,7 +1596,7 @@ namespace aft // occurred, when processing a heartbeat at index 0, which does not // happen in a real node (due to the genesis transaction executing // before ticks start), but may happen in tests. - state->view_history.update(1, r.term); + update_view_history(1, r.term); } else { @@ -1492,7 +1605,7 @@ namespace aft // after the previous signature we saw (lci, last committable index). if (r.idx > lci) { - state->view_history.update(lci + 1, r.term_of_idx); + update_view_history(lci + 1, r.term_of_idx); } } @@ -1825,7 +1938,7 @@ namespace aft { // If we grant our vote to a candidate, then an election is in progress restart_election_timeout(); - leader_id.reset(); + reset_leader_id(); voted_for = from; } @@ -2102,8 +2215,8 @@ namespace aft return; } - state->leadership_state = ccf::kv::LeadershipState::PreVoteCandidate; - leader_id.reset(); + set_leadership_state(ccf::kv::LeadershipState::PreVoteCandidate); + reset_leader_id(); reset_votes_for_me(); restart_election_timeout(); @@ -2147,12 +2260,12 @@ namespace aft return; } - state->leadership_state = ccf::kv::LeadershipState::Candidate; - leader_id.reset(); + set_leadership_state(ccf::kv::LeadershipState::Candidate); + reset_leader_id(); voted_for = state->node_id; reset_votes_for_me(); - state->current_view++; + advance_current_view(1); restart_election_timeout(); reset_last_ack_timeouts(); @@ -2204,8 +2317,8 @@ namespace aft store->initialise_term(state->current_view); } - state->leadership_state = ccf::kv::LeadershipState::Leader; - leader_id = state->node_id; + set_leadership_state(ccf::kv::LeadershipState::Leader); + set_leader_id(state->node_id); should_sign = true; using namespace std::chrono_literals; @@ -2254,11 +2367,11 @@ namespace aft // primary node has not received a majority of acks (CheckQuorum) void become_follower() { - leader_id.reset(); + reset_leader_id(); restart_election_timeout(); reset_last_ack_timeouts(); - state->leadership_state = ccf::kv::LeadershipState::Follower; + set_leadership_state(ccf::kv::LeadershipState::Follower); RAFT_INFO_FMT( "Becoming follower {}: {}.{}", state->node_id, @@ -2286,7 +2399,7 @@ namespace aft { voted_for.reset(); } - state->current_view = term; + set_current_view(term); reset_votes_for_me(); become_follower(); is_new_follower = true; @@ -2380,8 +2493,8 @@ namespace aft { nominate_successor(); - leader_id.reset(); - state->leadership_state = ccf::kv::LeadershipState::None; + reset_leader_id(); + set_leadership_state(ccf::kv::LeadershipState::None); } state->membership_state = ccf::kv::MembershipState::Retired; @@ -2518,6 +2631,7 @@ namespace aft if (term_of_new == state->current_view) { commit(new_commit_idx.value()); + publish_log_state(); } else { @@ -2591,7 +2705,7 @@ namespace aft compact_committable_indices(idx); - state->commit_idx = idx; + set_commit_idx(idx); if ( is_retired() && state->retirement_phase == ccf::kv::RetirementPhase::Signed && @@ -2650,7 +2764,9 @@ namespace aft if (changed) { create_and_remove_node_state(); - if (retired_node_cleanup && is_primary()) + if ( + retired_node_cleanup && + state->leadership_state == ccf::kv::LeadershipState::Leader) { retired_node_cleanup->cleanup(); } @@ -2693,10 +2809,11 @@ namespace aft RAFT_DEBUG_FMT("Setting term in store to: {}", state->current_view); ledger->truncate(idx); - state->last_idx = idx; + set_last_idx(idx); RAFT_DEBUG_FMT("Rolled back at {}", idx); - state->view_history.rollback(idx); + rollback_view_history(idx); + publish_log_state(); while (!state->committable_indices.empty() && (state->committable_indices.back() > idx)) From 1a0469e2f3d60a4d9a01a1fae6ef05101e9b22a6 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Fri, 14 Aug 2026 11:46:51 +0000 Subject: [PATCH 57/59] Refactor raft.h to improve code clarity and reduce complexity --- src/consensus/aft/raft.h | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/consensus/aft/raft.h b/src/consensus/aft/raft.h index 1b122791ea08..86f5c4e572b6 100644 --- a/src/consensus/aft/raft.h +++ b/src/consensus/aft/raft.h @@ -1315,8 +1315,7 @@ namespace aft if (!leader_id.has_value() || leader_id.value() != from) { set_leader_id(from); - RAFT_DEBUG_FMT( - "Node {} thinks leader is {}", state->node_id, leader_id.value()); + RAFT_DEBUG_FMT("Node {} thinks leader is {}", state->node_id, from); } // Third, check index consistency, making sure entries are not in the past From a009f1aee172a79848c461d8ce1cc06998b7bbd2 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Wed, 19 Aug 2026 11:09:02 +0000 Subject: [PATCH 58/59] Remove bundled AFT synchronization changes These changes were added while chasing TSAN failures exposed during the RPC connection manager work, but they are broader Raft synchronization changes and should be handled separately rather than bundled into this PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/consensus/aft/raft.h | 224 ++++++++++----------------------------- 1 file changed, 54 insertions(+), 170 deletions(-) diff --git a/src/consensus/aft/raft.h b/src/consensus/aft/raft.h index 86f5c4e572b6..286a3e0ebb8c 100644 --- a/src/consensus/aft/raft.h +++ b/src/consensus/aft/raft.h @@ -124,15 +124,7 @@ namespace aft // Volatile std::optional voted_for = std::nullopt; - // Public consensus queries may run on task workers while Raft messages are - // processed on the enclave main thread. Keep these small query fields - // independently synchronized so endpoint transactions do not need to take - // state->lock and invert the KV/Raft lock order. - mutable ccf::pal::Mutex public_state_lock; std::optional leader_id = std::nullopt; - Index published_last_idx = 0; - Index published_commit_idx = 0; - ViewHistory published_view_history; // Keep track of votes in each active configuration struct Votes @@ -209,105 +201,6 @@ namespace aft // pre-deserialisation, without an additional header. static constexpr size_t max_terms_per_append_entries = 1; - // Called while state->lock is held. - void set_leader_id(const ccf::NodeId& leader) - { - std::lock_guard guard(public_state_lock); - leader_id = leader; - } - - // Called while state->lock is held. - void reset_leader_id() - { - std::lock_guard guard(public_state_lock); - leader_id.reset(); - } - - // Called while state->lock is held. - void set_leadership_state(ccf::kv::LeadershipState leadership_state) - { - std::lock_guard guard(public_state_lock); - state->leadership_state = leadership_state; - } - - // Called while state->lock is held. - void set_current_view(Term view) - { - std::lock_guard guard(public_state_lock); - state->current_view = view; - } - - // Called while state->lock is held. - void advance_current_view(Term increment) - { - std::lock_guard guard(public_state_lock); - state->current_view += increment; - } - - // Called while state->lock is held. - void initialise_log_state( - Index last_idx, - Index commit_idx, - const std::vector& terms, - std::optional> view_update = std::nullopt) - { - state->last_idx = last_idx; - state->commit_idx = commit_idx; - state->view_history.initialise(terms); - if (view_update.has_value()) - { - state->view_history.update(view_update->first, view_update->second); - } - publish_log_state(); - } - - // Called while state->lock is held. - void publish_replicated_entry(Index index, Term view) - { - state->last_idx = index; - state->view_history.update(index, view); - publish_log_state(); - } - - // Called while state->lock is held, or during construction. - void publish_log_state() - { - std::lock_guard guard(public_state_lock); - published_last_idx = state->last_idx; - published_commit_idx = state->commit_idx; - published_view_history = state->view_history; - } - - // Called while state->lock is held. - void update_view_history(Index index, Term view) - { - state->view_history.update(index, view); - } - - // Called while state->lock is held. - void rollback_view_history(Index index) - { - state->view_history.rollback(index); - } - - // Called while state->lock is held. - void set_last_idx(Index index) - { - state->last_idx = index; - } - - // Called while state->lock is held. - void decrement_last_idx() - { - state->last_idx--; - } - - // Called while state->lock is held. - void set_commit_idx(Index index) - { - state->commit_idx = index; - } - public: static constexpr size_t append_entries_size_limit = 20000; std::unique_ptr ledger; @@ -346,7 +239,6 @@ namespace aft ledger(std::move(ledger_)), channels(std::move(channels_)) { - publish_log_state(); if (commit_callbacks != nullptr) { commit_callbacks->set_consensus(this); @@ -357,7 +249,6 @@ namespace aft std::optional primary() override { - std::lock_guard guard(public_state_lock); return leader_id; } @@ -368,13 +259,11 @@ namespace aft bool is_primary() override { - std::lock_guard guard(public_state_lock); return state->leadership_state == ccf::kv::LeadershipState::Leader; } bool is_candidate() override { - std::lock_guard guard(public_state_lock); return state->leadership_state == ccf::kv::LeadershipState::Candidate; } @@ -416,7 +305,6 @@ namespace aft bool is_backup() override { - std::lock_guard guard(public_state_lock); return state->leadership_state == ccf::kv::LeadershipState::Follower; } @@ -516,14 +404,14 @@ namespace aft { // This is unsafe and should only be called when the node is certain // there is no leader and no other node will attempt to force leadership. - std::lock_guard guard(state->lock); if (leader_id.has_value()) { throw std::logic_error( "Can't force leadership if there is already a leader"); } - advance_current_view(starting_view_change); + std::lock_guard guard(state->lock); + state->current_view += starting_view_change; become_leader(true); } @@ -535,16 +423,19 @@ namespace aft { // This is unsafe and should only be called when the node is certain // there is no leader and no other node will attempt to force leadership. - std::lock_guard guard(state->lock); if (leader_id.has_value()) { throw std::logic_error( "Can't force leadership if there is already a leader"); } - initialise_log_state( - index, commit_idx_, terms, std::make_pair(index, term)); - set_current_view(term + starting_view_change); + std::lock_guard guard(state->lock); + state->current_view = term; + state->last_idx = index; + state->commit_idx = commit_idx_; + state->view_history.initialise(terms); + state->view_history.update(index, term); + state->current_view += starting_view_change; become_leader(true); } @@ -558,7 +449,10 @@ namespace aft // before it has received any append entries. std::lock_guard guard(state->lock); - initialise_log_state(index, index, term_history); + state->last_idx = index; + state->commit_idx = index; + + state->view_history.initialise(term_history); ledger->init(index, recovery_start_index); @@ -567,50 +461,44 @@ namespace aft Index get_last_idx() { - std::lock_guard guard(public_state_lock); - return published_last_idx; + return state->last_idx; } Index get_committed_seqno() override { - std::lock_guard guard(public_state_lock); - return published_commit_idx; + std::lock_guard guard(state->lock); + return get_commit_idx_unsafe(); } Term get_view() override { - std::lock_guard guard(public_state_lock); + std::lock_guard guard(state->lock); return state->current_view; } std::pair get_committed_txid() override { - std::lock_guard guard(public_state_lock); - return { - published_view_history.view_at(published_commit_idx), - published_commit_idx}; + std::lock_guard guard(state->lock); + ccf::SeqNo commit_idx = get_commit_idx_unsafe(); + return {get_term_internal(commit_idx), commit_idx}; } Term get_view(Index idx) override { - std::lock_guard guard(public_state_lock); - if (idx > published_last_idx) - { - return ccf::VIEW_UNKNOWN; - } - return published_view_history.view_at(idx); + std::lock_guard guard(state->lock); + return get_term_internal(idx); } std::vector get_view_history(Index idx) override { - std::lock_guard guard(public_state_lock); - return published_view_history.get_history_until(idx); + // This should only be called when the spin lock is held. + return state->view_history.get_history_until(idx); } std::vector get_view_history_since(Index idx) override { - std::lock_guard guard(public_state_lock); - return published_view_history.get_history_since(idx); + // This should only be called when the spin lock is held. + return state->view_history.get_history_since(idx); } // Same as ccfraft.tla GetServerSet/IsInServerSet @@ -818,12 +706,13 @@ namespace aft should_sign = false; } + state->last_idx = index; ledger->put_entry( *data, globally_committable, state->current_view, index); entry_size_not_limited += data->size(); entry_count++; - publish_replicated_entry(index, state->current_view); + state->view_history.update(index, state->current_view); if (entry_size_not_limited >= append_entries_size_limit) { update_batch_size(); @@ -1314,8 +1203,9 @@ namespace aft restart_election_timeout(); if (!leader_id.has_value() || leader_id.value() != from) { - set_leader_id(from); - RAFT_DEBUG_FMT("Node {} thinks leader is {}", state->node_id, from); + leader_id = from; + RAFT_DEBUG_FMT( + "Node {} thinks leader is {}", state->node_id, leader_id.value()); } // Third, check index consistency, making sure entries are not in the past @@ -1487,11 +1377,10 @@ namespace aft if (apply_success == ccf::kv::ApplyResult::FAIL) { ledger->truncate(i - 1); - publish_log_state(); send_append_entries_response_nack(from); return; } - set_last_idx(i); + state->last_idx = i; for (auto& hook : ds->get_hooks()) { @@ -1515,7 +1404,7 @@ namespace aft case ccf::kv::ApplyResult::FAIL: { RAFT_FAIL_FMT("Follower failed to apply log entry: {}", i); - decrement_last_idx(); + state->last_idx--; ledger->truncate(state->last_idx); send_append_entries_response_nack(from); break; @@ -1539,7 +1428,7 @@ namespace aft // happened in sig_term. We reflect this in the history. if (r.term_of_idx == aft::ViewHistory::InvalidView) { - update_view_history(1, r.term); + state->view_history.update(1, r.term); } else { @@ -1550,7 +1439,7 @@ namespace aft max_terms_per_append_entries == 1, "AppendEntries processing for term updates assumes single " "term"); - update_view_history(r.prev_idx + 1, ds->get_term()); + state->view_history.update(r.prev_idx + 1, ds->get_term()); } commit_if_possible(r.leader_commit_idx); @@ -1576,7 +1465,6 @@ namespace aft } execute_append_entries_finish(r, from); - publish_log_state(); } void execute_append_entries_finish( @@ -1595,7 +1483,7 @@ namespace aft // occurred, when processing a heartbeat at index 0, which does not // happen in a real node (due to the genesis transaction executing // before ticks start), but may happen in tests. - update_view_history(1, r.term); + state->view_history.update(1, r.term); } else { @@ -1604,7 +1492,7 @@ namespace aft // after the previous signature we saw (lci, last committable index). if (r.idx > lci) { - update_view_history(lci + 1, r.term_of_idx); + state->view_history.update(lci + 1, r.term_of_idx); } } @@ -1937,7 +1825,7 @@ namespace aft { // If we grant our vote to a candidate, then an election is in progress restart_election_timeout(); - reset_leader_id(); + leader_id.reset(); voted_for = from; } @@ -2214,8 +2102,8 @@ namespace aft return; } - set_leadership_state(ccf::kv::LeadershipState::PreVoteCandidate); - reset_leader_id(); + state->leadership_state = ccf::kv::LeadershipState::PreVoteCandidate; + leader_id.reset(); reset_votes_for_me(); restart_election_timeout(); @@ -2259,12 +2147,12 @@ namespace aft return; } - set_leadership_state(ccf::kv::LeadershipState::Candidate); - reset_leader_id(); + state->leadership_state = ccf::kv::LeadershipState::Candidate; + leader_id.reset(); voted_for = state->node_id; reset_votes_for_me(); - advance_current_view(1); + state->current_view++; restart_election_timeout(); reset_last_ack_timeouts(); @@ -2316,8 +2204,8 @@ namespace aft store->initialise_term(state->current_view); } - set_leadership_state(ccf::kv::LeadershipState::Leader); - set_leader_id(state->node_id); + state->leadership_state = ccf::kv::LeadershipState::Leader; + leader_id = state->node_id; should_sign = true; using namespace std::chrono_literals; @@ -2366,11 +2254,11 @@ namespace aft // primary node has not received a majority of acks (CheckQuorum) void become_follower() { - reset_leader_id(); + leader_id.reset(); restart_election_timeout(); reset_last_ack_timeouts(); - set_leadership_state(ccf::kv::LeadershipState::Follower); + state->leadership_state = ccf::kv::LeadershipState::Follower; RAFT_INFO_FMT( "Becoming follower {}: {}.{}", state->node_id, @@ -2398,7 +2286,7 @@ namespace aft { voted_for.reset(); } - set_current_view(term); + state->current_view = term; reset_votes_for_me(); become_follower(); is_new_follower = true; @@ -2492,8 +2380,8 @@ namespace aft { nominate_successor(); - reset_leader_id(); - set_leadership_state(ccf::kv::LeadershipState::None); + leader_id.reset(); + state->leadership_state = ccf::kv::LeadershipState::None; } state->membership_state = ccf::kv::MembershipState::Retired; @@ -2630,7 +2518,6 @@ namespace aft if (term_of_new == state->current_view) { commit(new_commit_idx.value()); - publish_log_state(); } else { @@ -2704,7 +2591,7 @@ namespace aft compact_committable_indices(idx); - set_commit_idx(idx); + state->commit_idx = idx; if ( is_retired() && state->retirement_phase == ccf::kv::RetirementPhase::Signed && @@ -2763,9 +2650,7 @@ namespace aft if (changed) { create_and_remove_node_state(); - if ( - retired_node_cleanup && - state->leadership_state == ccf::kv::LeadershipState::Leader) + if (retired_node_cleanup && is_primary()) { retired_node_cleanup->cleanup(); } @@ -2808,11 +2693,10 @@ namespace aft RAFT_DEBUG_FMT("Setting term in store to: {}", state->current_view); ledger->truncate(idx); - set_last_idx(idx); + state->last_idx = idx; RAFT_DEBUG_FMT("Rolled back at {}", idx); - rollback_view_history(idx); - publish_log_state(); + state->view_history.rollback(idx); while (!state->committable_indices.empty() && (state->committable_indices.back() > idx)) From b3990627387a45cf1f41a5864b51fc30c3c53db2 Mon Sep 17 00:00:00 2001 From: Eddy Ashton Date: Wed, 19 Aug 2026 14:18:43 +0100 Subject: [PATCH 59/59] Apply suggestion from @achamayou Co-authored-by: Amaury Chamayou --- src/enclave/abstract_rpc_sessions.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/enclave/abstract_rpc_sessions.h b/src/enclave/abstract_rpc_sessions.h index 93c94f4b91be..f1cc85c7c062 100644 --- a/src/enclave/abstract_rpc_sessions.h +++ b/src/enclave/abstract_rpc_sessions.h @@ -37,6 +37,6 @@ namespace ccf virtual void set_custom_protocol_subsystem( std::shared_ptr cpss) = 0; virtual void set_commit_callbacks_subsystem( - std::shared_ptr fcss) = 0; + std::shared_ptr ccss) = 0; }; }