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