From 6d9aca92f1323f69c310529f2c8480f8b3369c65 Mon Sep 17 00:00:00 2001 From: Guoqiang Liu Date: Tue, 22 Sep 2026 23:05:42 +0400 Subject: [PATCH] net: detect half-closed sockets before connection reuse Detect peer EOF and TLS closure before reusing idle connections. Make disconnect idempotent while preserving buffered data and pending operation lifetimes. Keep the release branch's multi-byte recv_n behavior, and cover partial EOF, socket modes, and TLS shutdown with loopback regression tests. Use fixed test certificates and retain compatibility with the release branch's Google Test version. Co-authored-by: woodser <13068859+woodser@users.noreply.github.com> (cherry picked from commit 096abdaa22b71ade9ca673ed8012c500cecb58d3) --- contrib/epee/include/net/net_helper.h | 104 ++++- tests/data/ssl/README.md | 14 + tests/data/ssl/test.crt | 30 ++ tests/data/ssl/test.key | 52 +++ tests/unit_tests/net.cpp | 522 ++++++++++++++++++++++++++ 5 files changed, 717 insertions(+), 5 deletions(-) create mode 100644 tests/data/ssl/README.md create mode 100644 tests/data/ssl/test.crt create mode 100644 tests/data/ssl/test.key diff --git a/contrib/epee/include/net/net_helper.h b/contrib/epee/include/net/net_helper.h index 16c2f1d1f..49f3eccd4 100644 --- a/contrib/epee/include/net/net_helper.h +++ b/contrib/epee/include/net/net_helper.h @@ -32,6 +32,7 @@ //#include //#include #include +#include #include #include #include @@ -280,6 +281,7 @@ namespace net_utils try { m_ssl_socket->next_layer().close(); + m_pending_read.reset(); // Set SSL options // disable sslv2 @@ -323,14 +325,18 @@ namespace net_utils inline bool disconnect() { + m_connected = false; try { - if(m_connected) + if(!shutdown_requested() && m_ssl_socket->next_layer().is_open()) { - m_connected = false; if(m_ssl_options) shutdown_ssl(); - m_ssl_socket->next_layer().shutdown(boost::asio::ip::tcp::socket::shutdown_both); + // The peer or the TLS shutdown deadline may already have closed the connection. + boost::system::error_code ignored_ec; + m_ssl_socket->next_layer().shutdown(boost::asio::ip::tcp::socket::shutdown_both, ignored_ec); + // shutdown() alone leaves is_open() true and repeats TLS shutdown on later calls. + m_ssl_socket->next_layer().close(); } } catch(const boost::system::system_error& /*er*/) @@ -475,9 +481,61 @@ namespace net_utils { if (!m_connected || !m_ssl_socket->next_layer().is_open()) return false; + + boost::system::error_code ec; + if (m_ssl_options) + { + // let Asio process TLS alerts and retain a byte of application data for recv + if (!m_pending_read) + { + const auto pending = std::make_shared(); + // reconnect may replace the stream before this handler completes + const auto socket = m_ssl_socket; + socket->async_read_some(boost::asio::buffer(&pending->byte, 1), + [socket, pending](const boost::system::error_code& error, size_t) { + pending->error = error; + if (pending->handler) + { + auto handler = std::move(pending->handler); + handler(error, pending->byte); + } + }); + m_pending_read = pending; + } + m_io_service.restart(); + m_io_service.poll(); + ec = m_pending_read->error; + } + else + { + // peek for EOF without consuming application data or changing the socket mode + auto& socket = m_ssl_socket->next_layer(); + const bool non_blocking = socket.non_blocking(); + socket.non_blocking(true, ec); + if (!ec) + { + char byte; + socket.receive(boost::asio::buffer(&byte, 1), boost::asio::ip::tcp::socket::message_peek, ec); + boost::system::error_code restore_error; + socket.non_blocking(non_blocking, restore_error); + if (restore_error) + { + m_connected = false; + return false; + } + } + } + if (ec && ec != boost::asio::error::would_block && + ec != boost::asio::error::try_again && ec != boost::asio::error::interrupted) + { + MDEBUG("Peer closed idle connection, marking disconnected: " << ec.message()); + m_connected = false; + return false; + } + if (ssl) *ssl = m_ssl_options.support != ssl_support_t::e_ssl_support_disabled; - return true; + return m_connected; } inline @@ -534,6 +592,7 @@ namespace net_utils if (ec) { + m_connected = false; MTRACE("READ ENDS: Connection err_code " << ec.value()); if(ec == boost::asio::error::eof) { @@ -544,7 +603,6 @@ namespace net_utils } MDEBUG("Problems at read: " << ec.message()); - m_connected = false; return false; }else { @@ -705,6 +763,13 @@ namespace net_utils private: + struct pending_read + { + char byte = 0; + boost::system::error_code error = boost::asio::error::would_block; + std::function handler; + }; + bool shutdown_requested() const { return m_aborted.load(); @@ -785,6 +850,34 @@ namespace net_utils void async_read(char* buff, size_t sz, boost::asio::detail::transfer_at_least_t transfer_at_least, handler_obj& hndlr) { + // finish the probe before starting another TLS read + if (sz && m_pending_read) + { + const auto pending = m_pending_read; + auto on_read = [socket = m_ssl_socket, buff, sz, transfer_at_least, hndlr] + (const boost::system::error_code& error, char byte) mutable { + if (error) + { + hndlr(error, 0); + return; + } + buff[0] = byte; + if (!transfer_at_least(error, 1) || sz == 1) + hndlr(error, 1); + else + boost::asio::async_read(*socket, boost::asio::buffer(buff + 1, sz - 1), + [transfer_at_least](const boost::system::error_code& ec, size_t bytes) mutable { + return transfer_at_least(ec, bytes + 1); + }, + [hndlr](const boost::system::error_code& ec, size_t bytes) mutable { hndlr(ec, bytes + 1); }); + }; + if (pending->error == boost::asio::error::would_block) + pending->handler = std::move(on_read); + m_pending_read.reset(); + if (pending->error != boost::asio::error::would_block) + on_read(pending->error, pending->byte); + return; + } if(m_ssl_options.support == ssl_support_t::e_ssl_support_disabled) boost::asio::async_read(m_ssl_socket->next_layer(), boost::asio::buffer(buff, sz), transfer_at_least, hndlr); else @@ -796,6 +889,7 @@ namespace net_utils boost::asio::io_context m_io_service; boost::asio::ssl::context m_ctx; std::shared_ptr> m_ssl_socket; + std::shared_ptr m_pending_read; std::function m_connector; ssl_options_t m_ssl_options; bool m_initialized; diff --git a/tests/data/ssl/README.md b/tests/data/ssl/README.md new file mode 100644 index 000000000..1fda5bb73 --- /dev/null +++ b/tests/data/ssl/README.md @@ -0,0 +1,14 @@ +# TLS unit-test credentials + +`test.key` and `test.crt` are public, test-only credentials for loopback TLS tests. +Never use this private key outside tests. The tests disable peer verification; +they exercise connection and record handling, not certificate trust or validity. + +Loading these fixtures avoids generating a new RSA key for each client and server +context. They were generated with: + +```sh +openssl req -x509 -newkey rsa:4096 -sha256 -nodes \ + -keyout test.key -out test.crt -days 36500 \ + -subj '/CN=monero-unit-tests.invalid' +``` diff --git a/tests/data/ssl/test.crt b/tests/data/ssl/test.crt new file mode 100644 index 000000000..8ecb24554 --- /dev/null +++ b/tests/data/ssl/test.crt @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFKzCCAxOgAwIBAgIUd0AScwuUiPylHvlBJq4rFZaKN6IwDQYJKoZIhvcNAQEL +BQAwJDEiMCAGA1UEAwwZbW9uZXJvLXVuaXQtdGVzdHMuaW52YWxpZDAgFw0yNjA5 +MjEwNTM2MDBaGA8yMTI2MDgyODA1MzYwMFowJDEiMCAGA1UEAwwZbW9uZXJvLXVu +aXQtdGVzdHMuaW52YWxpZDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB +AJ+j7lLEHRJSORlw9mR6Stz99TC6KaWqp/KXPKQzQzomhfqrr9MCm07gp5VrmIrD +MU2tObhsJdq4u+8q1FDwZ781O9qm17sBDWwiF/OCEIWMiILqF+FmwunbJWrn9EV8 +70aWbqRo6INcA2SvgMqigXPQ4GMtCBsL8ft/WyMmavTlR1rqzWpMvxoPlYFmMS0X +CfSEOb9/PoRyBqa2eTjVveqbwPlFAuKy1xX37nNJGEaqCZEhr1qdQ3fAZhAkPcAs +FPMk7XhQH0Bpcrwi6ld4JlyisQgg0lP4hjU+RcUOy5ZMxV+cWX8FNs9pek7XxjVB +UpIunMmemwUNzR8r5Wzn8qHKzcn5KR56iy+c7nEq1LrHsAsNnaELuDRYOrJdLoWo +e+qt6oUIYzkt2COQuDQrQ8dsehpsbpYnrptfv0+4xn1hngw3zvZHNJG+v+Z2Cp0Q +9fBBHJWfLq+wylKzp2O0S3HXqc36CIi8qwGVhT8DUKsoYg/OLZUckxjRMpUqq+8w +TPDA0viiXx/k8rm7iEfSkGf0Q9aC7YbH+E8lg5J+xCb7Uis3+nwsmCwG1yyUn1Dk +qCL28MeB42MSunhaG/vVg2Mc8SRS9BTj7V1itnbwI4vCRAdjp0KCn95tNhTuujBw +OR/wBcoEtYaAtZpOi5A9J3ZSXFuJYEEZe0tSbGrRn2ZhAgMBAAGjUzBRMB0GA1Ud +DgQWBBSIboly3I6SE/1UixxQOVRp/6uoTTAfBgNVHSMEGDAWgBSIboly3I6SE/1U +ixxQOVRp/6uoTTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQA1 +bXapt3SuuZGvCU13+4K9TVJDX15TYqga1NJbomoUsFtvcio2PNGwPLj8O3Po6god +W+uEpJNF30FT8AzDxy90dRSBLjVBhfuHyNb0YAdkSVejndvbH0Pe25eZ/o4dHRIz +Xg6slE2192K6Rahq6Yeb7npNfanrdt2akNMidEzwno1BTQ78kr1jWdvaUd+HXIMT +QgUuKYvEKE93lB7elgkEtlNp8sHWdTF8h78VxdDoyV+1RBAz5C5v9HppEekUaeia +a5h50SkkltADfWhE9bDv/vqpjJ8FyHYsyqNJbLbHWMYnlP0/AJblGDst0D4XH0ZV +++iH2bLxM9AITytZOdoPtvT5cvUNqYtyvIlq5OCrCBGcvo681yiBm7azuIFt7fS5 +Al6IpUi0MfdqpefYqVAm5uXDcDrZQuxauXdeRqrM8QJ4HxbK9STMzHVDmx3wkEA2 +KXDiNE+EfwH76ae3SS0cPIw/ng5mctQ5aLlmP7Ezt14bQ0/dJBH8Jpj6uUj2R6s+ +ASEPm20qtnwDAq1nSceKAvh8HwOlaTc4SKr7faAIIRhfPXIJQN0DqJhQWgcCN/Zu +CTg/dFP/fe4TM2L9/tRXiArp/JSdx+7n9J68krU7Vnq1MJ43dAE4TlGEBNNPMzDM +hG7YKywPL38eWhv0rVNUE9B7nHvfZVC82Wl1vUGzBQ== +-----END CERTIFICATE----- diff --git a/tests/data/ssl/test.key b/tests/data/ssl/test.key new file mode 100644 index 000000000..fb36ace07 --- /dev/null +++ b/tests/data/ssl/test.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +MEPbK7BIR9js26unXYswf1Cy8rfO +-----END PRIVATE KEY----- diff --git a/tests/unit_tests/net.cpp b/tests/unit_tests/net.cpp index 33a797f12..02b2b6c60 100644 --- a/tests/unit_tests/net.cpp +++ b/tests/unit_tests/net.cpp @@ -76,6 +76,7 @@ #include "p2p/net_peerlist_boost_serialization.h" #include "serialization/keyvalue_serialization.h" #include "storages/portable_storage.h" +#include "unit_tests_utils.h" TEST(host, canonicalize_host) { @@ -270,6 +271,527 @@ TEST(blocked_mode_client, shutdown_is_permanent) EXPECT_FALSE(client.connect("127.0.0.1", port, std::chrono::seconds{5})); } +TEST(blocked_mode_client, eof_marks_disconnected_and_allows_reconnect) +{ + boost::asio::io_context server_io; + boost::asio::ip::tcp::acceptor acceptor{ + server_io, + {boost::asio::ip::address_v4::loopback(), 0} + }; + + epee::net_utils::blocked_mode_client client; + client.set_ssl(epee::net_utils::ssl_options_t{ + epee::net_utils::ssl_support_t::e_ssl_support_disabled + }); + const std::string port = std::to_string(acceptor.local_endpoint().port()); + ASSERT_TRUE(client.connect("127.0.0.1", port, std::chrono::seconds{5})); + + boost::system::error_code error; + boost::asio::ip::tcp::socket first_peer{server_io}; + acceptor.accept(first_peer, error); + ASSERT_FALSE(error); + boost::asio::write(first_peer, boost::asio::buffer("x", 1), error); + ASSERT_FALSE(error); + first_peer.shutdown(boost::asio::ip::tcp::socket::shutdown_send, error); + ASSERT_FALSE(error); + + std::string response; + ASSERT_TRUE(client.recv(response, std::chrono::seconds{5})); + EXPECT_EQ("x", response); + + ASSERT_TRUE(client.recv(response, std::chrono::seconds{5})); + EXPECT_TRUE(response.empty()); + EXPECT_FALSE(client.is_connected()); + + ASSERT_TRUE(client.connect("127.0.0.1", port, std::chrono::seconds{5})); + boost::asio::ip::tcp::socket second_peer{server_io}; + acceptor.accept(second_peer, error); + ASSERT_FALSE(error); + boost::asio::write(second_peer, boost::asio::buffer("y", 1), error); + ASSERT_FALSE(error); + ASSERT_TRUE(client.recv(response, std::chrono::seconds{5})); + EXPECT_EQ("y", response); +} + +TEST(blocked_mode_client, peer_eof_detected_before_reuse) +{ + boost::asio::io_context server_io; + boost::asio::ip::tcp::acceptor acceptor{ + server_io, + {boost::asio::ip::address_v4::loopback(), 0} + }; + + struct test_client : epee::net_utils::blocked_mode_client + { + boost::asio::ip::tcp::socket& socket() { return m_ssl_socket->next_layer(); } + } client; + client.set_ssl(epee::net_utils::ssl_options_t{ + epee::net_utils::ssl_support_t::e_ssl_support_disabled + }); + const std::string port = std::to_string(acceptor.local_endpoint().port()); + ASSERT_TRUE(client.connect("127.0.0.1", port, std::chrono::seconds{5})); + + boost::system::error_code error; + boost::asio::ip::tcp::socket peer{server_io}; + acceptor.accept(peer, error); + ASSERT_FALSE(error); + for (const bool non_blocking : {false, true}) + { + client.socket().non_blocking(non_blocking); + boost::asio::write(peer, boost::asio::buffer("x", 1)); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds{5}; + while (!client.socket().available() && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds{1}); + ASSERT_GT(client.socket().available(), 0u); + EXPECT_TRUE(client.is_connected()); + EXPECT_EQ(non_blocking, client.socket().non_blocking()); + std::string response; + ASSERT_TRUE(client.recv(response, std::chrono::seconds{5})); + EXPECT_EQ("x", response); + } + + // close the idle connection from the peer; is_connected must observe the EOF without a request failing first + peer.shutdown(boost::asio::ip::tcp::socket::shutdown_send, error); + ASSERT_FALSE(error); + bool disconnected = false; + for (int i = 0; i < 500 && !disconnected; ++i) + { + disconnected = !client.is_connected(); + if (!disconnected) + std::this_thread::sleep_for(std::chrono::milliseconds{10}); + } + EXPECT_TRUE(disconnected); + + ASSERT_TRUE(client.connect("127.0.0.1", port, std::chrono::seconds{5})); + boost::asio::ip::tcp::socket second_peer{server_io}; + acceptor.accept(second_peer, error); + ASSERT_FALSE(error); + EXPECT_TRUE(client.is_connected()); +} + +TEST(blocked_mode_client, disconnect_is_idempotent_after_peer_close) +{ + using tcp = boost::asio::ip::tcp; + boost::asio::io_context io; + tcp::acceptor acceptor{io, {boost::asio::ip::address_v4::loopback(), 0}}; + struct test_client : epee::net_utils::blocked_mode_client + { + bool socket_is_open() const { return m_ssl_socket->next_layer().is_open(); } + } client; + client.set_ssl(epee::net_utils::ssl_options_t{ + epee::net_utils::ssl_support_t::e_ssl_support_disabled + }); + ASSERT_TRUE(client.connect("127.0.0.1", std::to_string(acceptor.local_endpoint().port()), + std::chrono::seconds{5})); + tcp::socket peer{io}; + acceptor.accept(peer); + peer.shutdown(tcp::socket::shutdown_both); + peer.close(); + std::string response; + ASSERT_TRUE(client.recv(response, std::chrono::seconds{5})); + ASSERT_TRUE(response.empty()); + for (unsigned i = 0; i < 3; ++i) + { + EXPECT_TRUE(client.disconnect()); + EXPECT_FALSE(client.socket_is_open()); + EXPECT_FALSE(client.is_connected()); + } + // disconnect is not the permanent shutdown: reconnect must still work. + ASSERT_TRUE(client.connect("127.0.0.1", std::to_string(acceptor.local_endpoint().port()), + std::chrono::seconds{5})); + acceptor.accept(peer); + EXPECT_TRUE(client.is_connected()); +} + +namespace +{ + class blocked_mode_client_ssl : public testing::TestWithParam + { + protected: + using tcp = boost::asio::ip::tcp; + using ssl_stream = boost::asio::ssl::stream; + + static epee::net_utils::ssl_options_t ssl_options() + { + epee::net_utils::ssl_options_t options{epee::net_utils::ssl_support_t::e_ssl_support_enabled}; + options.verification = epee::net_utils::ssl_verification_t::none; + // Public test-only credentials avoid generating RSA keys for every context. + options.auth = epee::net_utils::ssl_authentication_t{ + (unit_test::data_dir / "ssl" / "test.key").string(), + (unit_test::data_dir / "ssl" / "test.crt").string() + }; + return options; + } + + boost::asio::io_context io; + boost::asio::ssl::context context{ssl_options().create_context()}; + tcp::acceptor acceptor{io, {boost::asio::ip::address_v4::loopback(), 0}}; + std::unique_ptr peer; + struct test_client : epee::net_utils::blocked_mode_client + { + bool socket_is_open() const { return m_ssl_socket->next_layer().is_open(); } + } client; + + void SetUp() override + { +#if (OPENSSL_VERSION_NUMBER >= 0x10101000L && !defined(LIBRESSL_VERSION_NUMBER)) \ + || (defined(LIBRESSL_VERSION_NUMBER) && LIBRESSL_VERSION_NUMBER >= 0x2070000fL) + ASSERT_EQ(1, SSL_CTX_set_min_proto_version(context.native_handle(), GetParam())); + ASSERT_EQ(1, SSL_CTX_set_max_proto_version(context.native_handle(), GetParam())); +#endif + client.set_ssl(ssl_options()); + ASSERT_TRUE(connect()); + ASSERT_EQ(GetParam(), SSL_version(peer->native_handle())); + } + + void TearDown() override + { + boost::system::error_code ignored; + if (peer) + peer->next_layer().close(ignored); + io.restart(); + io.poll(); + } + + bool connect() + { + if (peer) + { + boost::system::error_code ignored; + peer->next_layer().close(ignored); + io.restart(); + io.poll(); + } + peer.reset(new ssl_stream{io, context}); + io.restart(); + boost::system::error_code server_error = boost::asio::error::would_block; + boost::asio::steady_timer deadline{io, std::chrono::seconds{5}}; + deadline.async_wait([this](const boost::system::error_code& error) { + if (!error) + { + boost::system::error_code ignored; + acceptor.cancel(ignored); + peer->next_layer().close(ignored); + } + }); + acceptor.async_accept(peer->next_layer(), [&](const boost::system::error_code& error) { + if (error) + { + server_error = error; + deadline.cancel(); + return; + } + peer->async_handshake(boost::asio::ssl::stream_base::server, + [&](const boost::system::error_code& error) { + server_error = error; + deadline.cancel(); + }); + }); + std::thread server{[this] { io.run(); }}; + const bool connected = client.connect("127.0.0.1", + std::to_string(acceptor.local_endpoint().port()), std::chrono::seconds{5}); + server.join(); + return connected && !server_error; + } + + bool wait_for_disconnect() + { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds{2}; + do + { + if (!client.is_connected()) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds{1}); + } while (std::chrono::steady_clock::now() < deadline); + return false; + } + }; +} + +TEST_P(blocked_mode_client_ssl, close_notify_detected_before_reuse) +{ + bool ssl = false; + ASSERT_TRUE(client.is_connected(&ssl)); + ASSERT_TRUE(ssl); + ASSERT_TRUE(client.send("x", std::chrono::seconds{5})); + char request = 0; + boost::asio::read(*peer, boost::asio::buffer(&request, 1)); + ASSERT_EQ('x', request); + boost::asio::write(*peer, boost::asio::buffer("y", 1)); + std::string response; + ASSERT_TRUE(client.recv(response, std::chrono::seconds{5})); + ASSERT_EQ("y", response); + ASSERT_TRUE(client.is_connected()); + // close only after the client has received the response; leave TCP open + // while waiting for the client's alert + io.restart(); + peer->async_shutdown([](const boost::system::error_code&) {}); + io.poll(); + ASSERT_TRUE(wait_for_disconnect()); + ASSERT_TRUE(connect()); + ASSERT_TRUE(client.send("x", std::chrono::seconds{5})); + request = 0; + boost::asio::read(*peer, boost::asio::buffer(&request, 1)); + EXPECT_EQ('x', request); +} + +TEST_P(blocked_mode_client_ssl, preserves_unread_data) +{ + boost::asio::write(*peer, boost::asio::buffer("xyz", 3)); + EXPECT_TRUE(client.is_connected()); + EXPECT_TRUE(client.is_connected()); + std::string response, received; + while (received.size() < 3) + { + ASSERT_TRUE(client.recv(response, std::chrono::seconds{5})); + ASSERT_FALSE(response.empty()); + received += response; + } + EXPECT_EQ("xyz", received); + EXPECT_EQ(3u, client.get_bytes_received()); + EXPECT_TRUE(client.is_connected()); +} + +TEST_P(blocked_mode_client_ssl, abrupt_eof_detected_before_reuse) +{ + peer->next_layer().shutdown(tcp::socket::shutdown_send); + EXPECT_TRUE(wait_for_disconnect()); +} + +TEST_P(blocked_mode_client_ssl, partial_record_does_not_block) +{ + // an incomplete TLS record must not turn a status check into a blocking read + const unsigned char header[] = {0x17, 0x03, 0x03, 0x00, 0x20}; + boost::asio::write(peer->next_layer(), boost::asio::buffer(header)); + const auto start = std::chrono::steady_clock::now(); + EXPECT_TRUE(client.is_connected()); + EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds{1}); + peer->next_layer().shutdown(tcp::socket::shutdown_send); + EXPECT_TRUE(wait_for_disconnect()); +} + +TEST_P(blocked_mode_client_ssl, idle_probe_allows_request_and_response) +{ + const auto start = std::chrono::steady_clock::now(); + for (unsigned i = 0; i < 10; ++i) + ASSERT_TRUE(client.is_connected()); + EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds{1}); + ASSERT_TRUE(client.send("x", std::chrono::seconds{5})); + char request = 0; + boost::asio::read(*peer, boost::asio::buffer(&request, 1)); + EXPECT_EQ('x', request); + boost::asio::write(*peer, boost::asio::buffer("y", 1)); + std::string response; + ASSERT_TRUE(client.recv(response, std::chrono::seconds{5})); + EXPECT_EQ("y", response); + EXPECT_EQ(1u, client.get_bytes_received()); +} + +TEST_P(blocked_mode_client_ssl, reconnect_with_pending_probe) +{ + ASSERT_TRUE(client.is_connected()); + ASSERT_TRUE(connect()); + boost::asio::write(*peer, boost::asio::buffer("y", 1)); + std::string response; + ASSERT_TRUE(client.recv(response, std::chrono::seconds{5})); + EXPECT_EQ("y", response); +} + +TEST_P(blocked_mode_client_ssl, shutdown_aborts_pending_probe) +{ + ASSERT_TRUE(client.is_connected()); + std::thread stopper{[this] { + std::this_thread::sleep_for(std::chrono::milliseconds{100}); + client.shutdown(); + }}; + std::string response; + const auto start = std::chrono::steady_clock::now(); + EXPECT_FALSE(client.recv(response, std::chrono::seconds{30})); + EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds{5}); + stopper.join(); + EXPECT_FALSE(client.is_connected()); +} + +TEST_P(blocked_mode_client_ssl, data_before_close_notify_is_preserved) +{ + boost::asio::write(*peer, boost::asio::buffer("xyz", 3)); + io.restart(); + peer->async_shutdown([](const boost::system::error_code&) {}); + io.poll(); + ASSERT_TRUE(client.is_connected()); + std::string response, received; + while (received.size() < 3) + { + ASSERT_TRUE(client.recv(response, std::chrono::seconds{5})); + ASSERT_FALSE(response.empty()); + received += response; + } + EXPECT_EQ("xyz", received); + EXPECT_EQ(3u, client.get_bytes_received()); + EXPECT_TRUE(wait_for_disconnect()); +} + +#if OPENSSL_VERSION_NUMBER >= 0x10101000L && !defined(LIBRESSL_VERSION_NUMBER) +// This branch's Google Test predates GTEST_SKIP: only instantiate this test for TLS 1.3. +class blocked_mode_client_ssl_tls13 : public blocked_mode_client_ssl {}; + +TEST_P(blocked_mode_client_ssl_tls13, key_update_allows_request_and_response) +{ + ASSERT_EQ(1, SSL_key_update(peer->native_handle(), SSL_KEY_UPDATE_REQUESTED)); + boost::asio::write(*peer, boost::asio::buffer("x", 1)); + ASSERT_TRUE(client.is_connected()); + std::string response; + ASSERT_TRUE(client.recv(response, std::chrono::seconds{5})); + EXPECT_EQ("x", response); + ASSERT_TRUE(client.send("y", std::chrono::seconds{5})); + char request = 0; + boost::asio::read(*peer, boost::asio::buffer(&request, 1)); + EXPECT_EQ('y', request); +} +INSTANTIATE_TEST_CASE_P(tls13, blocked_mode_client_ssl_tls13, testing::Values(TLS1_3_VERSION)); +#endif + +TEST_P(blocked_mode_client_ssl, recv_times_out_with_pending_probe) +{ + ASSERT_TRUE(client.is_connected()); + std::string response; + const auto start = std::chrono::steady_clock::now(); + EXPECT_FALSE(client.recv(response, std::chrono::milliseconds{100})); + EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds{5}); + EXPECT_FALSE(client.is_connected()); +} + +TEST_P(blocked_mode_client_ssl, disconnect_with_pending_probe_is_graceful) +{ + ASSERT_TRUE(client.is_connected()); + boost::system::error_code peer_error = boost::asio::error::would_block; + char byte; + io.restart(); + boost::asio::steady_timer deadline{io, std::chrono::seconds{5}}; + deadline.async_wait([this](const boost::system::error_code& error) { + if (!error) + { + boost::system::error_code ignored; + peer->next_layer().close(ignored); + } + }); + peer->async_read_some(boost::asio::buffer(&byte, 1), + [&](const boost::system::error_code& error, size_t) { + EXPECT_EQ(boost::asio::error::eof, error); + if (error != boost::asio::error::eof) + { + peer_error = boost::asio::error::fault; + deadline.cancel(); + } + else + peer->async_shutdown([&](const boost::system::error_code& error) { + peer_error = error; + deadline.cancel(); + }); + }); + std::thread server{[this] { io.run(); }}; + EXPECT_TRUE(client.disconnect()); + server.join(); + EXPECT_FALSE(peer_error); + EXPECT_FALSE(client.socket_is_open()); + // Repeated disconnects must not attempt another TLS shutdown, even after the peer closes. + peer->next_layer().close(); + EXPECT_TRUE(client.disconnect()); + EXPECT_TRUE(client.disconnect()); + EXPECT_FALSE(client.is_connected()); + ASSERT_TRUE(connect()); + EXPECT_TRUE(client.is_connected()); +} + +TEST_P(blocked_mode_client_ssl, disconnect_after_peer_eof_is_graceful) +{ + boost::system::error_code peer_error = boost::asio::error::would_block; + io.restart(); + boost::asio::steady_timer deadline{io, std::chrono::seconds{5}}; + deadline.async_wait([this](const boost::system::error_code& error) { + if (!error) + { + boost::system::error_code ignored; + peer->next_layer().close(ignored); + } + }); + peer->async_shutdown([&](const boost::system::error_code& error) { + peer_error = error; + deadline.cancel(); + }); + std::thread server{[this] { io.run(); }}; + std::string response; + EXPECT_TRUE(client.recv(response, std::chrono::seconds{5})); + EXPECT_TRUE(response.empty()); + EXPECT_FALSE(client.is_connected()); + EXPECT_TRUE(client.disconnect()); + server.join(); + EXPECT_FALSE(peer_error); + EXPECT_FALSE(client.socket_is_open()); + peer->next_layer().close(); + EXPECT_TRUE(client.disconnect()); + EXPECT_TRUE(client.disconnect()); +} + +TEST_P(blocked_mode_client_ssl, disconnect_after_abrupt_eof_is_idempotent) +{ + peer->next_layer().shutdown(tcp::socket::shutdown_both); + peer->next_layer().close(); + ASSERT_TRUE(wait_for_disconnect()); + for (unsigned i = 0; i < 3; ++i) + { + EXPECT_TRUE(client.disconnect()); + EXPECT_FALSE(client.socket_is_open()); + EXPECT_FALSE(client.is_connected()); + } +} + +TEST_P(blocked_mode_client_ssl, disconnect_after_shutdown_does_not_wait_for_peer) +{ + ASSERT_TRUE(client.is_connected()); + ASSERT_TRUE(client.shutdown()); + // permanent shutdown must not wait for the peer's TLS close alert + const auto start = std::chrono::steady_clock::now(); + EXPECT_TRUE(client.disconnect()); + EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds{1}); + EXPECT_FALSE(client.is_connected()); +} + +TEST_P(blocked_mode_client_ssl, recv_n_preserves_probe_byte_and_count) +{ + ASSERT_TRUE(client.is_connected()); + std::string response; + ASSERT_TRUE(client.recv_n(response, 0, std::chrono::seconds{5})); + EXPECT_TRUE(response.empty()); + const std::string expected(100000, 'x'); + boost::asio::write(*peer, boost::asio::buffer(expected)); + ASSERT_TRUE(client.recv_n(response, expected.size(), std::chrono::seconds{5})); + EXPECT_EQ(expected, response); + EXPECT_EQ(expected.size(), client.get_bytes_received()); +} + +TEST_P(blocked_mode_client_ssl, recv_n_returns_false_on_eof_after_probe_byte) +{ + // Unlike master, this branch still needs the multi-byte async_read path for recv_n. + ASSERT_TRUE(client.is_connected()); + boost::asio::write(*peer, boost::asio::buffer("x", 1)); + io.restart(); + peer->async_shutdown([](const boost::system::error_code&) {}); + io.poll(); + std::string response; + EXPECT_FALSE(client.recv_n(response, 2, std::chrono::seconds{5})); + EXPECT_FALSE(client.is_connected()); + ASSERT_EQ(2u, response.size()); + EXPECT_EQ('x', response[0]); +} + +INSTANTIATE_TEST_CASE_P(tls, blocked_mode_client_ssl, testing::Values( + TLS1_2_VERSION +#if OPENSSL_VERSION_NUMBER >= 0x10101000L && !defined(LIBRESSL_VERSION_NUMBER) + , TLS1_3_VERSION +#endif +)); + TEST(tor_address, constants) { static_assert(!net::tor_address::is_local(), "bad is_local() response");