From ec5100b39d496f9dc198e8fc5a7005fe3de40831 Mon Sep 17 00:00:00 2001 From: Kazuki Yamaguchi Date: Tue, 13 Oct 2026 18:03:51 +0900 Subject: [PATCH] ssl: add SSLSocket#ssl_shutdown Expose SSL_shutdown() directly so that Ruby programs can control closure of connection more precisely. Update SSLSocket#sysclose and #close_write to use this method. They rescue SystemCallError and SSLError to preserve the existing behavior. Closes https://github.com/ruby/openssl/issues/610 --- ext/openssl/ossl_ssl.c | 71 +++++++++++++++++++++++++++++----------- lib/openssl/ssl.rb | 29 ++++++++++++++-- test/openssl/test_ssl.rb | 25 +++++++++++++- 3 files changed, 102 insertions(+), 23 deletions(-) diff --git a/ext/openssl/ossl_ssl.c b/ext/openssl/ossl_ssl.c index a3aff8009..3f172b2e9 100644 --- a/ext/openssl/ossl_ssl.c +++ b/ext/openssl/ossl_ssl.c @@ -1848,6 +1848,7 @@ ossl_start_ssl(VALUE self, int (*func)(SSL *), const char *funcname) cb_state = rb_attr_get(self, ID_callback_state); if (!NIL_P(cb_state)) { + rb_ivar_set(self, ID_callback_state, Qnil); /* must cleanup OpenSSL error stack before re-raising */ ossl_clear_error(); rb_jump_tag(NUM2INT(cb_state)); @@ -2078,34 +2079,66 @@ ossl_ssl_write(VALUE self, VALUE str) /* * call-seq: - * ssl.stop => nil + * ssl.ssl_shutdown -> 0, 1, :wait_readable, or :wait_writable + * + * Calls SSL_shutdown() once. This is a low-level method used by #close and + * #close_write. + * + * This method works in two steps. First, it sends a close_notify alert to the + * peer and returns 0 or 1 on success. A return value of 0 indicates that the + * alert has been sent, but not received, and a return value of 1 indicates + * that the alert has been sent and received. + * + * When called again, it attempts to receive a close_notify alert from the peer + * and returns 1 on success. + * + * This method does not block. If the underlying socket is not ready, + * +:wait_readable+ or +:wait_writable+ is returned. If an unrecoverable error + * occurs, SSLError or SystemCallError is raised. * - * Sends "close notify" to the peer and tries to shut down the SSL connection - * gracefully. + * Most users should use this method only to send the alert. The peer's alert + * can be received by reading from the socket until it reaches EOF. + * + * See also the man page SSL_shutdown(3). */ static VALUE -ossl_ssl_stop(VALUE self) +ossl_ssl_shutdown(VALUE self) { SSL *ssl; - int ret; + int ret, saved_errno; + VALUE cb_state; GetSSL(self, ssl); if (!ssl_started(ssl)) - return Qnil; + rb_raise(eSSLError, "SSL session is not started yet"); + ret = SSL_shutdown(ssl); - if (ret == 1) /* Have already received close_notify */ - return Qnil; - if (ret == 0) /* Sent close_notify, but we don't wait for reply */ - return Qnil; + saved_errno = errno_mapped(); - /* - * XXX: Something happened. Possibly it failed because the underlying socket - * is not writable/readable, since it is in non-blocking mode. We should do - * some proper error handling using SSL_get_error() and maybe retry, but we - * can't block here. Give up for now. - */ - ossl_clear_error(); - return Qnil; + cb_state = rb_attr_get(self, ID_callback_state); + if (!NIL_P(cb_state)) { + rb_ivar_set(self, ID_callback_state, Qnil); + ossl_clear_error(); + rb_jump_tag(NUM2INT(cb_state)); + } + + if (ret >= 0) + return INT2FIX(ret); + + switch (SSL_get_error(ssl, ret)) { + case SSL_ERROR_WANT_WRITE: + return sym_wait_writable; + case SSL_ERROR_WANT_READ: + return sym_wait_readable; + case SSL_ERROR_SYSCALL: + if (saved_errno) { + ossl_clear_error(); + rb_exc_raise(rb_syserr_new(saved_errno, "SSL_shutdown")); + } + /* fallthrough */ + default: + ossl_raise(eSSLError, "SSL_shutdown"); + } } /* @@ -3009,7 +3042,7 @@ Init_ossl_ssl(void) rb_define_private_method(cSSLSocket, "string_new_capacity", ossl_ssl_string_new_capacity, 1); rb_define_private_method(cSSLSocket, "ssl_read", ossl_ssl_read, 2); rb_define_private_method(cSSLSocket, "ssl_write", ossl_ssl_write, 1); - rb_define_private_method(cSSLSocket, "stop", ossl_ssl_stop, 0); + rb_define_method(cSSLSocket, "ssl_shutdown", ossl_ssl_shutdown, 0); rb_define_method(cSSLSocket, "cert", ossl_ssl_get_cert, 0); rb_define_method(cSSLSocket, "peer_cert", ossl_ssl_get_peer_cert, 0); rb_define_method(cSSLSocket, "peer_cert_chain", ossl_ssl_get_peer_cert_chain, 0); diff --git a/lib/openssl/ssl.rb b/lib/openssl/ssl.rb index e50be00ff..63992f104 100644 --- a/lib/openssl/ssl.rb +++ b/lib/openssl/ssl.rb @@ -368,13 +368,20 @@ class SSLSocket # call-seq: # ssl.sysclose => nil # - # Sends "close notify" to the peer and tries to shut down the SSL + # Tries to send "close notify" to the peer and shut down the SSL/TLS # connection gracefully. # + # If the underlying socket is not writable, the alert may not be sent, + # because #sysclose cannot block. See #ssl_shutdown if you need strict + # guarantees. + # # If sync_close is set to +true+, the underlying IO is also closed. def sysclose return if closed? - stop + ssl_shutdown + nil + rescue SystemCallError, OpenSSL::SSL::SSLError + ensure io.close if sync_close end @@ -597,8 +604,24 @@ def close_read # Therefore, on TLS 1.2, this method will cause the connection to be # completely shut down. On TLS 1.3, the connection will remain open for # reading only. + # + # If the underlying socket is not writable, the alert may not be sent, + # because #close_write cannot block. See #ssl_shutdown if you need strict + # guarantees. def close_write - stop + ssl_shutdown + nil + rescue SystemCallError, OpenSSL::SSL::SSLError + end + + # This has always been a private method, but we keep it for one release + # cycle because removing it appears to break some gems. + # + # Please instead use #close_write or #ssl_shutdown. + private def stop + warn "OpenSSL::SSL::SSLSocket#stop is deprecated; use " \ + "#close_write or #ssl_shutdown instead" + close_write end private diff --git a/test/openssl/test_ssl.rb b/test/openssl/test_ssl.rb index c1ab7a07c..3b7143d89 100644 --- a/test/openssl/test_ssl.rb +++ b/test/openssl/test_ssl.rb @@ -1844,7 +1844,7 @@ def test_npn_selected_protocol_too_long end def test_close_after_socket_close - # The client closes the TCP socket without SSLSocket#stop + # The client closes the TCP socket without calling SSLSocket#close sctx = make_server_context if defined?(OpenSSL::SSL::OP_IGNORE_UNEXPECTED_EOF) sctx.options |= OpenSSL::SSL::OP_IGNORE_UNEXPECTED_EOF @@ -1862,6 +1862,29 @@ def test_close_after_socket_close } end + def test_ssl_shutdown + server_can_close = Thread::Queue.new + server_proc = proc do |sock| + ssl = OpenSSL::SSL::SSLSocket.new(sock, make_server_context).accept + str = ssl.gets + ssl.puts(str) + assert_predicate(ssl, :eof?) + server_can_close.pop + assert_equal(1, ssl.ssl_shutdown) + end + start_server_proc(server_proc) do |port| + server_connect(port) do |ssl| + ssl.puts("abc") + assert_equal(0, ssl.ssl_shutdown) + assert_equal("abc\n", ssl.gets) + assert_equal(:wait_readable, ssl.ssl_shutdown) + server_can_close.close + ssl.wait_readable + assert_equal(1, ssl.ssl_shutdown) + end + end + end + def test_sync_close_without_connect Socket.open(:INET, :STREAM) {|s| ssl = OpenSSL::SSL::SSLSocket.new(s)