diff --git a/Docs/net-http-over-tls.md b/Docs/net-http-over-tls.md index 5ec9fc6b1..513217dc1 100644 --- a/Docs/net-http-over-tls.md +++ b/Docs/net-http-over-tls.md @@ -171,8 +171,19 @@ As built: and `ssl_socket_connect` then wait on `#to_io` with their own timeouts, so `open_timeout`, `read_timeout` and `write_timeout` work without any IronRuby code in net/protocol, for RubyGems' vendored `Gem::Net` as well. -- Not done: the server side (`SSLServer`, `#accept`) is still not implemented, so puma over - TLS still cannot run. +- puma over TLS runs, on its own engine rather than `SSLServer`: puma never uses + `OpenSSL::SSL::SSLServer`, it drives its C extension's `Puma::MiniSSL::Engine`, OpenSSL over + memory BIOs. `Src/Libraries/Puma/MiniSsl.cs` is that engine on a server `SslStream` over an + in-memory stream: `#inject` fills its inbound side, `#extract` drains its outbound side, the + handshake and the reads run as SslStream's asynchronous operations, and `#read`, `#write` and + `#extract` first wait until SslStream has used up its input, as OpenSSL would have by the time + `SSL_read` returns. puma's own `Puma::MiniSSL::Socket` (minissl.rb) does the socket I/O, so + the TCP socket stays an ordinary one for puma's reactor. `ssl://` binds take `cert`/`key` + files or `cert_pem`/`key_pem`, `key_password_command`, `ca` with `verify_mode` none, peer or + force_peer (`puma.peercert` in the env), and `no_tlsv1`/`no_tlsv1_1`. Ignored: + `ssl_cipher_filter`, `ssl_ciphersuites`, `verification_flags`, `reuse`. +- Not done: `OpenSSL::SSL::SSLServer` and `SSLSocket#accept` (server-side TLS for Ruby code + other than puma) still raise NotImplementedError. ## How to test it @@ -189,6 +200,10 @@ As built: timeouts, the name check), `NetHttp_TlsVerifyCallback1`, `NetHttp_GemNetTls1`, `NetHttp_PlainNoOpenSsl1` and `Ssl_NonBlocking1` (stage 2: `connect_nonblock`, `read_nonblock`, `write_nonblock`, `IO.select` and `NIO::Selector` over TLS). +- puma over TLS: `Src/IronRuby.Tests/Runtime/PumaTlsTests.cs`, `Puma_Tls1`: puma in single mode + on three `ssl://` binds (cert files, `cert_pem`/`key_pem`, `force_peer` with a `ca`), with + `Net::HTTP` and `ca_file:`, keep-alive, pipelining, plain HTTP on the TLS port, and client + certificates presented through .NET's `SslStream`. ## Before this diff --git a/Src/IronRuby.Tests/RubyTests.cs b/Src/IronRuby.Tests/RubyTests.cs index 0d86b17bf..41bc8c8fe 100644 --- a/Src/IronRuby.Tests/RubyTests.cs +++ b/Src/IronRuby.Tests/RubyTests.cs @@ -764,6 +764,7 @@ public Tests(Driver/*!*/ driver) { NetHttp_PlainNoOpenSsl1, NetHttp_GemNetTls1, Ssl_NonBlocking1, + Puma_Tls1, Sprintf_MriTieRounding1, Float_NegativeZero1, Math_CLibrary1, diff --git a/Src/IronRuby.Tests/Runtime/PumaTlsTests.cs b/Src/IronRuby.Tests/Runtime/PumaTlsTests.cs new file mode 100644 index 000000000..c507181ae --- /dev/null +++ b/Src/IronRuby.Tests/Runtime/PumaTlsTests.cs @@ -0,0 +1,279 @@ +/* **************************************************************************** + * + * Copyright (c) IronRuby contributors. + * + * This source code is subject to terms and conditions of the Apache License, Version 2.0. A + * copy of the license can be found in the License.html file at the root of this distribution. If + * you cannot locate the Apache License, Version 2.0, please send an email to + * ironruby@microsoft.com. By using this source code in any fashion, you are agreeing to be bound + * by the terms of the Apache License, Version 2.0. + * + * You must not remove this notice, or any other, from this software. + * + * + * ***************************************************************************/ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace IronRuby.Tests { + + public partial class Tests { + /// + /// puma serving TLS in single mode, on Puma::MiniSSL::Engine (Src/Libraries/Puma/MiniSsl.cs): + /// an ssl:// bind with cert and key files, an ssl_bind with cert_pem and key_pem, and one + /// with verify_mode=force_peer and a ca. Net::HTTP talks to the first two with ca_file, + /// keep-alive and a large POST; an SSLSocket pipelines two requests; plain HTTP on the TLS + /// port is turned away and puma serves on. The client certificates are presented by + /// PumaTlsClient (.NET's SslStream), as IronRuby's SSLSocket has no client certificates. + /// + [Options(Prism = true)] + public void Puma_Tls1() { + using (var files = new PumaTlsFiles()) { + Runtime.Globals.SetVariable("MtlsClient", new PumaTlsClient(files.ClientPfx, files.RoguePfx)); + TestOutput(@" +require 'ruby4' +require 'net/http' +require 'openssl' +require 'stringio' +require 'puma' +require 'puma/configuration' +require 'puma/launcher' +require 'puma/log_writer' + +PORTS = " + files.RubyPorts + @" +CERT = '" + files.Cert + @"' +KEY = '" + files.Key + @"' +CA = '" + files.Ca + @"' + +p Puma.ssl? + +app = lambda do |env| + body = env['rack.input'].read + cert = env['puma.peercert'] + text = env['PATH_INFO'] == '/big' ? 'x' * 200_000 : + [env['REQUEST_METHOD'], env['PATH_INFO'], body.bytesize, env['HTTPS'], cert && cert.subject].compact.join(' ') + [200, { 'content-type' => 'text/plain' }, [text]] +end + +conf = Puma::Configuration.new do |c| + c.bind ""ssl://127.0.0.1:#{PORTS[0]}?cert=#{CERT}&key=#{KEY}"" + c.ssl_bind '127.0.0.1', PORTS[1], cert_pem: File.read(CERT), key_pem: File.read(KEY) + c.bind ""ssl://127.0.0.1:#{PORTS[2]}?cert=#{CERT}&key=#{KEY}&ca=#{CA}&verify_mode=force_peer"" + c.app app + c.threads 1, 4 + c.workers 0 +end +$0 ||= 'puma' # the launcher looks for the script it was started from, and for Gem.ruby +require 'rubygems' +log = StringIO.new +launcher = Puma::Launcher.new(conf, log_writer: Puma::LogWriter.new(log, log)) +server = Thread.new { launcher.run } +100.times do + begin + TCPSocket.new('127.0.0.1', PORTS[2]).close + break + rescue SystemCallError + sleep 0.1 + end +end + +def http(port) + h = Net::HTTP.new('127.0.0.1', port) + h.use_ssl = true + h.ca_file = CERT + h.max_retries = 0 + h +end + +# keep-alive: four requests on one connection +http(PORTS[0]).start do |h| + puts h.get('/a').body + puts h.post('/b', 'y' * 300_000).body + p h.get('/big').body.bytesize + puts h.get('/c').body +end +puts http(PORTS[1]).start { |h| h.get('/pem').body } + +# two requests in one TLS record +ctx = OpenSSL::SSL::SSLContext.new +ctx.set_params(ca_file: CERT) +s = OpenSSL::SSL::SSLSocket.new(TCPSocket.new('127.0.0.1', PORTS[0]), ctx) +s.sync_close = true +s.connect +s.write ""GET /one HTTP/1.1\r\nHost: x\r\n\r\nGET /two HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"" +p s.read.scan(/GET \S+/) +s.close + +# plain HTTP on the TLS port +c = TCPSocket.new('127.0.0.1', PORTS[0]) +c.write ""GET / HTTP/1.1\r\nHost: x\r\n\r\n"" +answer = begin; c.read; rescue SystemCallError; ''; end +p answer.include?('HTTP') +c.close +puts http(PORTS[0]).start { |h| h.get('/after').body } + +# force_peer: only a certificate the ca issued gets in +puts MtlsClient.Get(PORTS[2], '/issued', 'client') +puts MtlsClient.Get(PORTS[2], '/rogue', 'rogue') +puts MtlsClient.Get(PORTS[2], '/none', nil) + +launcher.stop +server.join +p log.string.include?('SSL error') +", @" +true +GET /a 0 https +POST /b 300000 https +200000 +GET /c 0 https +GET /pem 0 https +[""GET /one"", ""GET /two""] +false +GET /after 0 https +GET /issued 0 https /CN=test client +refused +refused +true +"); + } + } + + /// + /// A server certificate for 127.0.0.1 and its key as PEM files, a CA (PEM) and a client + /// certificate it issued, a self-signed client certificate (both PFX), and three free ports. + /// + private sealed class PumaTlsFiles : IDisposable { + private static readonly Oid ClientAuthentication = new Oid("1.3.6.1.5.5.7.3.2"); + private readonly List _paths = new List(); + private readonly int[] _ports = new int[3]; + + public readonly string Cert, Key, Ca, ClientPfx, RoguePfx; + + public PumaTlsFiles() { + var now = DateTimeOffset.UtcNow; + using (var key = RSA.Create(2048)) { + var request = new CertificateRequest("CN=127.0.0.1", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var names = new SubjectAlternativeNameBuilder(); + names.AddIpAddress(IPAddress.Loopback); + request.CertificateExtensions.Add(names.Build()); + using (var cert = request.CreateSelfSigned(now.AddMinutes(-5), now.AddDays(1))) { + Cert = Write(".pem", cert.ExportCertificatePem() + "\n"); + Key = Write(".pem", key.ExportPkcs8PrivateKeyPem() + "\n"); + } + } + + using (var caKey = RSA.Create(2048)) { + var request = new CertificateRequest("CN=Test Client CA", caKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true)); + using (var ca = request.CreateSelfSigned(now.AddMinutes(-5), now.AddDays(1))) { + Ca = Write(".pem", ca.ExportCertificatePem() + "\n"); + ClientPfx = WritePfx(ClientCertificate("CN=test client", ca)); + } + } + RoguePfx = WritePfx(ClientCertificate("CN=rogue client", null)); + + for (int i = 0; i < _ports.Length; i++) { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + _ports[i] = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + } + } + + public string/*!*/ RubyPorts { + get { return "[" + String.Join(", ", _ports) + "]"; } + } + + // Issued by ca, or self-signed without one. + private static X509Certificate2/*!*/ ClientCertificate(string/*!*/ subject, X509Certificate2 ca) { + using (var key = RSA.Create(2048)) { + var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { ClientAuthentication }, false)); + var now = DateTimeOffset.UtcNow; + if (ca == null) { + return request.CreateSelfSigned(now.AddMinutes(-5), now.AddDays(1)); + } + var serial = new byte[8]; + RandomNumberGenerator.Fill(serial); + serial[0] &= 0x7f; + using (var issued = request.Create(ca, now.AddMinutes(-5), now.AddHours(12), serial)) { + return issued.CopyWithPrivateKey(key); + } + } + } + + private string/*!*/ Write(string/*!*/ extension, string/*!*/ content) { + string path = Path.Combine(Path.GetTempPath(), "ironruby-puma-tls-" + Guid.NewGuid().ToString("N") + extension); + File.WriteAllText(path, content); + _paths.Add(path); + return path.Replace("\\", "/"); + } + + private string/*!*/ WritePfx(X509Certificate2/*!*/ certificate) { + using (certificate) { + string path = Path.Combine(Path.GetTempPath(), "ironruby-puma-tls-" + Guid.NewGuid().ToString("N") + ".pfx"); + File.WriteAllBytes(path, certificate.Export(X509ContentType.Pfx)); + _paths.Add(path); + return path; + } + } + + public void Dispose() { + foreach (var path in _paths) { + try { + File.Delete(path); + } catch (IOException) { + } + } + } + } + + /// + /// A TLS client with a client certificate: GET path, and the response body, or "refused". + /// + public sealed class PumaTlsClient { + private readonly Dictionary _pfx = new Dictionary(); + + internal PumaTlsClient(string/*!*/ client, string/*!*/ rogue) { + _pfx["client"] = client; + _pfx["rogue"] = rogue; + } + + public string/*!*/ Get(int port, string/*!*/ path, string certificate) { + var certificates = new X509CertificateCollection(); + if (certificate != null) { +#if NET9_0_OR_GREATER + certificates.Add(X509CertificateLoader.LoadPkcs12FromFile(_pfx[certificate], null)); +#else + certificates.Add(new X509Certificate2(_pfx[certificate])); +#endif + } + try { + using (var tcp = new TcpClient("127.0.0.1", port)) + using (var ssl = new SslStream(tcp.GetStream(), false, (sender, cert, chain, errors) => true)) { + ssl.ReadTimeout = ssl.WriteTimeout = 10000; + ssl.AuthenticateAsClient("127.0.0.1", certificates, false); + var request = Encoding.ASCII.GetBytes("GET " + path + " HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); + ssl.Write(request, 0, request.Length); + var response = new MemoryStream(); + ssl.CopyTo(response); + string text = Encoding.ASCII.GetString(response.ToArray()); + int body = text.IndexOf("\r\n\r\n", StringComparison.Ordinal); + return body >= 0 ? text.Substring(body + 4) : "refused"; + } + } catch (Exception e) when (e is IOException || e is SocketException || e is System.Security.Authentication.AuthenticationException) { + return "refused"; + } + } + } + } +} diff --git a/Src/Libraries/Initializers.Generated.cs b/Src/Libraries/Initializers.Generated.cs index 680c33439..a5b438138 100644 --- a/Src/Libraries/Initializers.Generated.cs +++ b/Src/Libraries/Initializers.Generated.cs @@ -17632,6 +17632,7 @@ public sealed class PumaLibraryInitializer : IronRuby.Builtins.LibraryInitialize protected override void LoadModules() { IronRuby.Builtins.RubyClass classRef0 = GetClass(typeof(IronRuby.Builtins.RubyObject)); IronRuby.Builtins.RubyClass classRef1 = GetClass(typeof(System.SystemException)); + IronRuby.Builtins.RubyClass classRef2 = GetClass(typeof(System.Object)); IronRuby.Builtins.RubyModule def1 = DefineGlobalModule("Puma", typeof(IronRuby.StandardLibrary.Puma.PumaModule), 0x00000008, null, null, null, IronRuby.Builtins.RubyModule.EmptyArray); @@ -17640,8 +17641,19 @@ protected override void LoadModules() { ); IronRuby.Builtins.RubyClass def3 = DefineClass("Puma::HttpParserError", typeof(IronRuby.StandardLibrary.Puma.PumaModule.HttpParserError), 0x00000008, classRef1, null, null, null, IronRuby.Builtins.RubyModule.EmptyArray, new Func(PumaLibraryInitializer.ExceptionFactory__Puma__HttpParserError)); + IronRuby.Builtins.RubyModule def4 = DefineModule("Puma::MiniSSL", typeof(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl), 0x00000008, null, LoadPuma__MiniSSL_Class, LoadPuma__MiniSSL_Constants, IronRuby.Builtins.RubyModule.EmptyArray); + IronRuby.Builtins.RubyClass def5 = DefineClass("Puma::MiniSSL::Engine", typeof(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Engine), 0x00000008, classRef2, LoadPuma__MiniSSL__Engine_Instance, LoadPuma__MiniSSL__Engine_Class, null, IronRuby.Builtins.RubyModule.EmptyArray); + IronRuby.Builtins.RubyClass def6 = DefineClass("Puma::MiniSSL::SSLContext", typeof(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.SSLContext), 0x00000008, classRef2, null, null, null, IronRuby.Builtins.RubyModule.EmptyArray, + new Func>, IronRuby.Builtins.RubyClass, System.Object, IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.SSLContext>(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.SSLContext.Create) + ); + IronRuby.Builtins.RubyClass def7 = DefineClass("Puma::MiniSSL::SSLError", typeof(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.SSLError), 0x00000008, classRef1, null, null, null, IronRuby.Builtins.RubyModule.EmptyArray, + new Func(PumaLibraryInitializer.ExceptionFactory__Puma__MiniSSL__SSLError)); SetConstant(def1, "HttpParser", def2); SetConstant(def1, "HttpParserError", def3); + SetConstant(def1, "MiniSSL", def4); + SetConstant(def4, "Engine", def5); + SetConstant(def4, "SSLContext", def6); + SetConstant(def4, "SSLError", def7); } private static void LoadPuma__HttpParser_Instance(IronRuby.Builtins.RubyModule/*!*/ module) { @@ -17687,10 +17699,82 @@ private static void LoadPuma__HttpParser_Instance(IronRuby.Builtins.RubyModule/* } + private static void LoadPuma__MiniSSL_Constants(IronRuby.Builtins.RubyModule/*!*/ module) { + SetConstant(module, "OPENSSL_LIBRARY_VERSION", IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.OPENSSL_LIBRARY_VERSION); + SetConstant(module, "OPENSSL_NO_SSL3", IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.OPENSSL_NO_SSL3); + SetConstant(module, "OPENSSL_NO_TLS1", IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.OPENSSL_NO_TLS1); + SetConstant(module, "OPENSSL_NO_TLS1_1", IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.OPENSSL_NO_TLS1_1); + SetConstant(module, "OPENSSL_VERSION", IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.OPENSSL_VERSION); + + } + + private static void LoadPuma__MiniSSL_Class(IronRuby.Builtins.RubyModule/*!*/ module) { + DefineLibraryMethod(module, "check", 0x21, + 0x00000000U, + new Func(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Check) + ); + + } + + private static void LoadPuma__MiniSSL__Engine_Instance(IronRuby.Builtins.RubyModule/*!*/ module) { + DefineLibraryMethod(module, "extract", 0x11, + 0x00000000U, + new Func(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Engine.Extract) + ); + + DefineLibraryMethod(module, "init?", 0x11, + 0x00000000U, + new Func(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Engine.InHandshake) + ); + + DefineLibraryMethod(module, "inject", 0x11, + 0x00000002U, + new Func(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Engine.Inject) + ); + + DefineLibraryMethod(module, "peercert", 0x11, + 0x00000000U, + new Func(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Engine.GetPeerCertificate) + ); + + DefineLibraryMethod(module, "read", 0x11, + 0x00000000U, + new Func(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Engine.Read) + ); + + DefineLibraryMethod(module, "shutdown", 0x11, + 0x00000000U, + new Func(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Engine.Shutdown) + ); + + DefineLibraryMethod(module, "ssl_vers_st", 0x11, + 0x00000000U, + new Func(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Engine.GetVersionState) + ); + + DefineLibraryMethod(module, "write", 0x11, + 0x00000002U, + new Func(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Engine.Write) + ); + + } + + private static void LoadPuma__MiniSSL__Engine_Class(IronRuby.Builtins.RubyModule/*!*/ module) { + DefineLibraryMethod(module, "server", 0x21, + 0x00000002U, + new Func(IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.Engine.Server) + ); + + } + public static System.Exception/*!*/ ExceptionFactory__Puma__HttpParserError(IronRuby.Builtins.RubyClass/*!*/ self, [DefaultParameterValueAttribute(null)]object message) { return IronRuby.Runtime.RubyExceptionData.InitializeException(new IronRuby.StandardLibrary.Puma.PumaModule.HttpParserError(IronRuby.Runtime.RubyExceptionData.GetClrMessage(self, message), (System.Exception)null), message); } + public static System.Exception/*!*/ ExceptionFactory__Puma__MiniSSL__SSLError(IronRuby.Builtins.RubyClass/*!*/ self, [DefaultParameterValueAttribute(null)]object message) { + return IronRuby.Runtime.RubyExceptionData.InitializeException(new IronRuby.StandardLibrary.Puma.PumaModule.MiniSsl.SSLError(IronRuby.Runtime.RubyExceptionData.GetClrMessage(self, message), (System.Exception)null), message); + } + } } diff --git a/Src/Libraries/Puma/MiniSsl.cs b/Src/Libraries/Puma/MiniSsl.cs new file mode 100644 index 000000000..9e64aefba --- /dev/null +++ b/Src/Libraries/Puma/MiniSsl.cs @@ -0,0 +1,618 @@ +/* **************************************************************************** + * + * Copyright (c) IronRuby contributors. + * + * Puma's TLS engine - ext/puma_http11/mini_ssl.c - for IronRuby, on System.Net.Security.SslStream. + * 3-clause BSD, as puma is. + * + * ***************************************************************************/ + +// What this is, and where it differs from the C extension: +// +// * The C extension is OpenSSL over a pair of memory BIOs: puma reads ciphertext off the +// socket and hands it to Engine#inject, takes plaintext from #read, gives plaintext to #write +// and sends what #extract answers. Here the pair of BIOs is MemoryBio, the inner stream of a +// server SslStream: #inject fills its inbound side, #extract drains its outbound side. The +// handshake and the reads run as SslStream's asynchronous operations, waiting on MemoryBio for +// input; nothing blocks on the socket, which puma alone touches. +// * OpenSSL does its work inside SSL_read, so the plaintext (or the handshake's answer) is there +// as soon as #inject returns. SslStream does it in continuations on the thread pool. So that +// puma sees the same thing, #read, #extract and #write first wait until SslStream has used up +// the input and is waiting for more (or has finished): it never waits for anything else, so +// that is a matter of microseconds. +// * #read answers all the plaintext there is, not 512 bytes of it at a time. #extract likewise. +// * The context: cert and key (files, or cert_pem and key_pem), key_password_command for an +// encrypted key, ca and verify_mode none, peer or force_peer for client certificates, +// no_tlsv1 and no_tlsv1_1. Not used: ssl_cipher_filter and ssl_ciphersuites (SslStream takes +// the system's cipher suites; Windows has no per-connection list), verification_flags and +// reuse (SslStream keeps its own session cache). +// * Engine.client, used by puma's own tests only, is not here. + +using System; +using System.IO; +using System.Net.Security; +using System.Runtime.CompilerServices; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using IronRuby.Builtins; +using IronRuby.Runtime; +using Microsoft.Scripting.Runtime; + +namespace IronRuby.StandardLibrary.Puma { + + public static partial class PumaModule { + + [RubyModule("MiniSSL")] + public static class MiniSsl { + // puma's minissl.rb takes the version out of these (HAS_TLS1_3): the TLS SslStream + // speaks is what OpenSSL 3.0 speaks. + [RubyConstant] + public const string OPENSSL_VERSION = "OpenSSL 3.0.0 (IronRuby, System.Net.Security.SslStream)"; + + [RubyConstant] + public const string OPENSSL_LIBRARY_VERSION = OPENSSL_VERSION; + + [RubyConstant] + public const bool OPENSSL_NO_SSL3 = true; + + [RubyConstant] + public const bool OPENSSL_NO_TLS1 = false; + + [RubyConstant] + public const bool OPENSSL_NO_TLS1_1 = false; + + [RubyMethod("check", RubyMethodAttributes.PublicSingleton)] + public static object Check(RubyModule/*!*/ self) { + return null; + } + + [RubyException("SSLError")] + public class SSLError : SystemException { + public SSLError() : this(null, null) { } + public SSLError(string message) : this(message, null) { } + public SSLError(string message, Exception inner) : base(message ?? "Puma::MiniSSL::SSLError", inner) { } + } + + // puma's VERIFY_PEER and VERIFY_FAIL_IF_NO_PEER_CERT, OpenSSL's values. + private const int VerifyPeer = 1; + private const int VerifyFailIfNoPeerCert = 2; + + private static readonly Oid ClientAuthentication = new Oid("1.3.6.1.5.5.7.3.2"); + + /// + /// What a Puma::MiniSSL::Context says, loaded once per listener: the server + /// certificate with its key and chain, and how to check a client's certificate. + /// + [RubyClass("SSLContext")] + public sealed class SSLContext { + internal SslStreamCertificateContext/*!*/ Certificate; + internal X509Certificate2Collection Ca; + internal int VerifyMode; + internal SslProtocols Protocols; + + [RubyConstructor] + public static SSLContext/*!*/ Create(CallSiteStorage>/*!*/ keyPasswordStorage, + RubyClass/*!*/ self, [NotNull]object/*!*/ miniContext) { + + var context = self.Context; + string key = FileText(context, Get(context, miniContext, "@key"), "key"); + if (key == null) { + key = StringOrNull(Get(context, miniContext, "@key_pem")); + } + string cert = FileText(context, Get(context, miniContext, "@cert"), "cert"); + if (cert == null) { + cert = StringOrNull(Get(context, miniContext, "@cert_pem")); + } + if (key == null) { + throw new SSLError("no SSL key given (key= or key_pem=)"); + } + if (cert == null) { + throw new SSLError("no SSL certificate given (cert= or cert_pem=)"); + } + + string password = null; + if (Get(context, miniContext, "@key_password_command") != null) { + var site = keyPasswordStorage.GetCallSite("key_password", 0); + password = StringOrNull(site.Target(site, miniContext)); + } + + var result = new SSLContext(); + result.Certificate = LoadCertificate(cert, key, password); + + string ca = FileText(context, Get(context, miniContext, "@ca"), "ca"); + if (ca != null) { + result.Ca = new X509Certificate2Collection(); + try { + result.Ca.ImportFromPem(ca); + } catch (CryptographicException e) { + throw new SSLError("cannot load the SSL ca: " + e.Message, e); + } + } + + object verifyMode = Get(context, miniContext, "@verify_mode"); + result.VerifyMode = verifyMode is int ? (int)verifyMode : 0; + +#pragma warning disable SYSLIB0039 // TLS 1.0 and 1.1: what no_tlsv1 and no_tlsv1_1 are about + if (RubyOps.IsTrue(Get(context, miniContext, "@no_tlsv1_1"))) { + result.Protocols = SslProtocols.Tls12 | SslProtocols.Tls13; + } else if (RubyOps.IsTrue(Get(context, miniContext, "@no_tlsv1"))) { + result.Protocols = SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13; + } else { + result.Protocols = SslProtocols.None; + } +#pragma warning restore SYSLIB0039 + return result; + } + + private static object Get(RubyContext/*!*/ context, object/*!*/ obj, string/*!*/ name) { + object value; + return context.TryGetInstanceVariable(obj, name, out value) ? value : null; + } + + private static string StringOrNull(object value) { + var str = value as MutableString; + return str != null ? str.ToString() : null; + } + + private static string FileText(RubyContext/*!*/ context, object path, string/*!*/ what) { + string name = StringOrNull(path); + if (name == null) { + return null; + } + try { + using (var stream = context.DomainManager.Platform.OpenInputFileStream(name)) + using (var reader = new StreamReader(stream)) { + return reader.ReadToEnd(); + } + } catch (IOException e) { + throw new SSLError(String.Format("cannot read the SSL {0} file {1}: {2}", what, name, e.Message), e); + } catch (UnauthorizedAccessException e) { + throw new SSLError(String.Format("cannot read the SSL {0} file {1}: {2}", what, name, e.Message), e); + } + } + + // The first certificate of the PEM is the server's, the others its chain. + private static SslStreamCertificateContext/*!*/ LoadCertificate(string/*!*/ certPem, string/*!*/ keyPem, string password) { + X509Certificate2 leaf; + var chain = new X509Certificate2Collection(); + try { + leaf = password != null + ? X509Certificate2.CreateFromEncryptedPem(certPem, keyPem, password) + : X509Certificate2.CreateFromPem(certPem, keyPem); + chain.ImportFromPem(certPem); + } catch (CryptographicException e) { + throw new SSLError("cannot load the SSL certificate and key: " + e.Message, e); + } catch (ArgumentException e) { + throw new SSLError("cannot load the SSL certificate and key: " + e.Message, e); + } + if (chain.Count > 0) { + chain.RemoveAt(0); + } + + // SChannel cannot serve with an ephemeral key: round-trip the certificate through PFX. + if (OperatingSystem.IsWindows()) { + using (leaf) { +#if NET9_0_OR_GREATER + leaf = X509CertificateLoader.LoadPkcs12(leaf.Export(X509ContentType.Pfx), null); +#else + leaf = new X509Certificate2(leaf.Export(X509ContentType.Pfx)); +#endif + } + } + return SslStreamCertificateContext.Create(leaf, chain, offline: true); + } + } + + /// + /// One TLS connection, server side: an SslStream over a MemoryBio. + /// + [RubyClass("Engine")] + public sealed class Engine { + // Guards everything below and MemoryBio's buffers; never held while SslStream runs. + private readonly object _sync = new object(); + private readonly ByteQueue/*!*/ _inbound = new ByteQueue(); + private readonly ByteQueue/*!*/ _outbound = new ByteQueue(); + private readonly ByteQueue/*!*/ _plain = new ByteQueue(); + // SslStream's read of the inbound side, while there is nothing to give it. + private TaskCompletionSource _bioRead; + private Memory _bioReadBuffer; + private bool _inboundClosed; + + private SslStream _ssl; + private bool _verifyPeer; + private bool _handshaken; + private bool _eof; // the client sent close_notify + private bool _done; // the handshake or a read failed, or ended + private Exception _error; + + // How long #read, #extract and #write wait for SslStream to take up its input. + // It only computes, so this is a safety net, not a timeout anyone should meet. + private const int IdleTimeout = 30 * 1000; + + [RubyMethod("server", RubyMethodAttributes.PublicSingleton)] + public static Engine/*!*/ Server(RubyClass/*!*/ self, [NotNull]SSLContext/*!*/ context) { + var engine = new Engine(); + engine._verifyPeer = (context.VerifyMode & VerifyPeer) != 0; + engine._ssl = new SslStream(new MemoryBio(engine), false); + + var options = new SslServerAuthenticationOptions { + ServerCertificateContext = context.Certificate, + EnabledSslProtocols = context.Protocols, + CertificateRevocationCheckMode = X509RevocationMode.NoCheck, + ClientCertificateRequired = engine._verifyPeer, + RemoteCertificateValidationCallback = (sender, certificate, chain, errors) => + !engine._verifyPeer || ValidateClient(context, certificate, chain, errors), + }; + // Runs up to its first read of MemoryBio, and on from there in the background. + _ = engine.Run(options); + return engine; + } + + // OpenSSL's check of a client certificate: against the ca, as a TLS client. + private static bool ValidateClient(SSLContext/*!*/ context, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors) { + if (certificate == null) { + return (context.VerifyMode & VerifyFailIfNoPeerCert) == 0; + } + if (context.Ca == null) { + return (errors & ~SslPolicyErrors.RemoteCertificateNameMismatch) == SslPolicyErrors.None; + } + using (var custom = new X509Chain()) + using (var leaf = new X509Certificate2(certificate)) { + var policy = custom.ChainPolicy; + policy.RevocationMode = X509RevocationMode.NoCheck; + policy.TrustMode = X509ChainTrustMode.CustomRootTrust; + policy.ApplicationPolicy.Add(ClientAuthentication); + policy.CustomTrustStore.AddRange(context.Ca); + policy.ExtraStore.AddRange(context.Ca); + // The intermediates the client sent. + if (chain != null) { + policy.ExtraStore.AddRange(chain.ChainPolicy.ExtraStore); + } + return custom.Build(leaf); + } + } + + // The handshake, then reads until the client closes: started here, it runs on as + // MemoryBio gets input. + private async Task Run(SslServerAuthenticationOptions/*!*/ options) { + try { + await _ssl.AuthenticateAsServerAsync(options, CancellationToken.None).ConfigureAwait(false); + lock (_sync) { + _handshaken = true; + } + var buffer = new byte[16 * 1024]; + while (true) { + int count = await _ssl.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false); + lock (_sync) { + if (count == 0) { + _eof = true; + break; + } + _plain.Append(buffer, 0, count); + } + } + } catch (Exception e) { + lock (_sync) { + _error = e; + } + } finally { + bool dispose; + lock (_sync) { + _done = true; + dispose = _inboundClosed; + Monitor.PulseAll(_sync); + } + if (dispose) { + _ssl.Dispose(); + } + } + } + + // Waits until SslStream has taken up all of the input and waits for more, or is done. + // Called holding _sync. + private void WaitIdle() { + int start = Environment.TickCount; + while (!_done && _bioRead == null) { + int left = IdleTimeout - unchecked(Environment.TickCount - start); + if (left <= 0 || !Monitor.Wait(_sync, left)) { + throw new SSLError("the TLS engine did not take up its input"); + } + } + } + + private SSLError/*!*/ Failure() { + var authentication = _error as AuthenticationException; + string message = (_error.InnerException != null && authentication != null) + ? _error.Message + " (" + _error.InnerException.Message + ")" + : _error.Message; + return new SSLError("SSL error: " + message, _error); + } + + /// + /// inject(data) -> Integer: hands ciphertext read off the socket to the engine. + /// + [RubyMethod("inject")] + public static int Inject(Engine/*!*/ self, [NotNull]MutableString/*!*/ data) { + byte[] bytes = data.ToByteArray(); + TaskCompletionSource read = null; + int count = 0; + lock (self._sync) { + if (self._inboundClosed) { + return 0; + } + self._inbound.Append(bytes, 0, bytes.Length); + if (self._bioRead != null && self._inbound.Count > 0) { + read = self._bioRead; + count = self._inbound.Take(self._bioReadBuffer.Span); + self._bioRead = null; + self._bioReadBuffer = default(Memory); + } + } + if (read != null) { + read.SetResult(count); + } + return bytes.Length; + } + + /// + /// read -> String or nil: the plaintext there is, nil while there is none yet. + /// Raises EOFError once the client has closed the TLS session, Puma::MiniSSL::SSLError + /// when the handshake or the connection failed. + /// + [RubyMethod("read")] + public static MutableString Read(Engine/*!*/ self) { + lock (self._sync) { + self.WaitIdle(); + if (self._plain.Count > 0) { + return MutableString.CreateBinary(self._plain.TakeAll()); + } + if (self._eof) { + throw new EOFError("end of file reached"); + } + if (self._error != null && !self._inboundClosed) { + throw self.Failure(); + } + return null; + } + } + + /// + /// write(data) -> Integer: encrypts all of the plaintext; #extract has the ciphertext. + /// + [RubyMethod("write")] + public static int Write(Engine/*!*/ self, [NotNull]MutableString/*!*/ data) { + byte[] bytes = data.ToByteArray(); + lock (self._sync) { + self.WaitIdle(); + if (self._error != null) { + throw self.Failure(); + } + if (!self._handshaken) { + throw new SSLError("SSL error: the handshake has not finished"); + } + } + try { + // MemoryBio's writes complete at once, so this does not block. + self._ssl.Write(bytes, 0, bytes.Length); + } catch (IOException e) { + throw new SSLError("SSL error: " + e.Message, e); + } catch (ObjectDisposedException e) { + throw new SSLError("SSL error: " + e.Message, e); + } + return bytes.Length; + } + + /// + /// extract -> String or nil: the ciphertext to send, nil when there is none. + /// + [RubyMethod("extract")] + public static MutableString Extract(Engine/*!*/ self) { + lock (self._sync) { + self.WaitIdle(); + return self._outbound.Count > 0 ? MutableString.CreateBinary(self._outbound.TakeAll()) : null; + } + } + + /// + /// shutdown -> true or false: false when there is a close_notify for #extract to hand out. + /// + [RubyMethod("shutdown")] + public static bool Shutdown(Engine/*!*/ self) { + bool notify; + lock (self._sync) { + self.WaitIdle(); + notify = self._handshaken && self._error == null && !self._inboundClosed; + } + if (notify) { + try { + self._ssl.ShutdownAsync().GetAwaiter().GetResult(); + } catch (IOException) { + } catch (InvalidOperationException) { + } + } + + TaskCompletionSource read; + bool disposeNow; + lock (self._sync) { + // Ends the read in flight, and so the engine's loop. + self._inboundClosed = true; + read = self._bioRead; + self._bioRead = null; + self._bioReadBuffer = default(Memory); + disposeNow = self._done; + notify = self._outbound.Count > 0; + } + if (read != null) { + read.SetResult(0); + } + if (disposeNow) { + self._ssl.Dispose(); + } + return !notify; + } + + [RubyMethod("init?")] + public static bool InHandshake(Engine/*!*/ self) { + lock (self._sync) { + return !self._handshaken; + } + } + + /// + /// peercert -> String or nil: the client's certificate, DER; nil with verify_mode none. + /// + [RubyMethod("peercert")] + public static MutableString GetPeerCertificate(Engine/*!*/ self) { + lock (self._sync) { + if (!self._verifyPeer || !self._handshaken) { + return null; + } + } + var certificate = self._ssl.RemoteCertificate; + return certificate != null ? MutableString.CreateBinary(certificate.GetRawCertData()) : null; + } + + /// + /// ssl_vers_st -> [version, state]: SSL_get_version and SSL_state_string, which puma + /// looks at to tell a plain HTTP request on a TLS port from a TLS 1.3 handshake. + /// + [RubyMethod("ssl_vers_st")] + public static RubyArray/*!*/ GetVersionState(Engine/*!*/ self) { + string version = "TLSv1.3", state; + lock (self._sync) { + if (self._handshaken) { +#pragma warning disable SYSLIB0039 + switch (self._ssl.SslProtocol) { + case SslProtocols.Tls: version = "TLSv1"; break; + case SslProtocols.Tls11: version = "TLSv1.1"; break; + case SslProtocols.Tls12: version = "TLSv1.2"; break; + } +#pragma warning restore SYSLIB0039 + } + state = self._error != null ? "SSLERR" : self._handshaken ? "SSLOK " : "before"; + } + return new RubyArray { MutableString.CreateMutable(version, RubyEncoding.Binary), MutableString.CreateMutable(state, RubyEncoding.Binary) }; + } + + /// + /// The engine's side of the socket: what SslStream reads is what #inject was given, + /// what it writes waits for #extract. + /// + private sealed class MemoryBio : Stream { + private readonly Engine/*!*/ _engine; + + public MemoryBio(Engine/*!*/ engine) { + _engine = engine; + } + + public override bool CanRead { get { return true; } } + public override bool CanWrite { get { return true; } } + public override bool CanSeek { get { return false; } } + public override long Length { get { throw new NotSupportedException(); } } + public override long Position { + get { throw new NotSupportedException(); } + set { throw new NotSupportedException(); } + } + public override long Seek(long offset, SeekOrigin origin) { throw new NotSupportedException(); } + public override void SetLength(long value) { throw new NotSupportedException(); } + public override void Flush() { } + + public override int Read(byte[]/*!*/ buffer, int offset, int count) { + return ReadAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult(); + } + + public override Task ReadAsync(byte[]/*!*/ buffer, int offset, int count, CancellationToken cancellationToken) { + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default(CancellationToken)) { + var engine = _engine; + lock (engine._sync) { + if (engine._inbound.Count > 0) { + return new ValueTask(engine._inbound.Take(buffer.Span)); + } + if (engine._inboundClosed || buffer.Length == 0) { + return new ValueTask(0); + } + // Continuations run elsewhere: not inside #inject, holding the lock of whoever called it. + var read = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + engine._bioRead = read; + engine._bioReadBuffer = buffer; + Monitor.PulseAll(engine._sync); + return new ValueTask(read.Task); + } + } + + public override void Write(byte[]/*!*/ buffer, int offset, int count) { + lock (_engine._sync) { + _engine._outbound.Append(buffer, offset, count); + } + } + + public override Task WriteAsync(byte[]/*!*/ buffer, int offset, int count, CancellationToken cancellationToken) { + Write(buffer, offset, count); + return Task.CompletedTask; + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default(CancellationToken)) { + lock (_engine._sync) { + _engine._outbound.Append(buffer.Span); + } + return default(ValueTask); + } + + public override Task FlushAsync(CancellationToken cancellationToken) { + return Task.CompletedTask; + } + } + } + + /// A FIFO of bytes. + private sealed class ByteQueue { + private byte[]/*!*/ _data = new byte[0]; + private int _start, _count; + + public int Count { get { return _count; } } + + public void Append(byte[]/*!*/ bytes, int offset, int count) { + Append(new ReadOnlySpan(bytes, offset, count)); + } + + public void Append(ReadOnlySpan bytes) { + if (_start + _count + bytes.Length > _data.Length) { + if (_count + bytes.Length <= _data.Length) { + Buffer.BlockCopy(_data, _start, _data, 0, _count); + } else { + var data = new byte[Math.Max(_data.Length * 2, _count + bytes.Length)]; + Buffer.BlockCopy(_data, _start, data, 0, _count); + _data = data; + } + _start = 0; + } + bytes.CopyTo(new Span(_data, _start + _count, bytes.Length)); + _count += bytes.Length; + } + + public int Take(Span destination) { + int count = Math.Min(_count, destination.Length); + new ReadOnlySpan(_data, _start, count).CopyTo(destination); + _start += count; + _count -= count; + if (_count == 0) { + _start = 0; + } + return count; + } + + public byte[]/*!*/ TakeAll() { + var result = new byte[_count]; + Take(result); + return result; + } + } + } + } +} diff --git a/Src/Libraries/Puma/PumaHttp11.cs b/Src/Libraries/Puma/PumaHttp11.cs index 48c85c1d8..a8245d4b8 100644 --- a/Src/Libraries/Puma/PumaHttp11.cs +++ b/Src/Libraries/Puma/PumaHttp11.cs @@ -25,7 +25,7 @@ // PUMA_REQUEST_PATH_MAX_LENGTH, PUMA_QUERY_STRING_MAX_LENGTH) are read from the environment // when the parser is first used, as puma's JRuby parser does - there is no compile step // here to hand them to. -// * Puma::MiniSSL (TLS) is not part of this file; see Src/StdLib/ironruby/puma/puma_http11.rb. +// * Puma::MiniSSL (TLS) is not part of this file; it is in MiniSsl.cs. using System; using System.Collections.Generic; @@ -37,7 +37,7 @@ namespace IronRuby.StandardLibrary.Puma { [RubyModule("Puma")] - public static class PumaModule { + public static partial class PumaModule { [RubyException("HttpParserError")] public class HttpParserError : SystemException { diff --git a/Src/StdLib/ironruby/puma/puma_http11.rb b/Src/StdLib/ironruby/puma/puma_http11.rb index e62d53e90..2c1e67141 100644 --- a/Src/StdLib/ironruby/puma/puma_http11.rb +++ b/Src/StdLib/ironruby/puma/puma_http11.rb @@ -5,15 +5,19 @@ # # `gem install puma` cannot work here: the extension is C (a Ragel-generated HTTP parser, and # MiniSSL over OpenSSL's memory BIOs). IronRuby ships puma's Ruby half unchanged -# (Src/StdLib/ironruby/puma.rb and puma/, from puma 8.0.2) and implements the parser in C# - -# Puma::HttpParser and Puma::HttpParserError, in Src/Libraries/Puma/PumaHttp11.cs, a port of the -# same state machine with the C extension's messages and limits. +# (Src/StdLib/ironruby/puma.rb and puma/, from puma 8.0.2) and implements the native half in C#: +# +# * Puma::HttpParser and Puma::HttpParserError, in Src/Libraries/Puma/PumaHttp11.cs, a port of +# the same state machine with the C extension's messages and limits. +# * Puma::MiniSSL::Engine, SSLContext and SSLError, puma's TLS, in Src/Libraries/Puma/MiniSsl.cs: +# the engine is a server SslStream over an in-memory stream that #inject fills and #extract +# drains, so Puma.ssl? is true and ssl:// binds serve TLS. cert/key files or cert_pem/key_pem, +# key_password_command, ca with verify_mode none, peer or force_peer, no_tlsv1 and no_tlsv1_1 +# are honoured; ssl_cipher_filter, ssl_ciphersuites, verification_flags and reuse are not +# (SslStream uses the system's cipher suites and its own session cache). # # Not implemented: # -# * MiniSSL, puma's TLS. Puma::MiniSSL::Engine is not defined, which is exactly how a puma -# built without OpenSSL looks: Puma.ssl? is false, and binding an ssl:// URL fails with -# puma's own "Puma compiled without SSL support" error. Terminate TLS in front of puma. # * Cluster mode. .NET cannot fork(2), so Process.respond_to?(:fork) is false and puma # refuses `workers N` with its own "worker mode not supported on ironruby on this # platform" message. Single mode - one process, a thread pool - is fully supported. diff --git a/Src/StdLib/ruby/gems/4.0.0/specifications/default/puma-8.0.2.gemspec b/Src/StdLib/ruby/gems/4.0.0/specifications/default/puma-8.0.2.gemspec index 9283ea558..662fcb8c6 100644 --- a/Src/StdLib/ruby/gems/4.0.0/specifications/default/puma-8.0.2.gemspec +++ b/Src/StdLib/ruby/gems/4.0.0/specifications/default/puma-8.0.2.gemspec @@ -10,8 +10,8 @@ Gem::Specification.new do |s| s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) s.require_paths = ["lib".freeze] s.authors = ["IronRuby".freeze] - s.description = "Puma, with its HTTP parser in C#; single mode only, no TLS. Provided by IronRuby itself.".freeze - s.summary = "Puma, with its HTTP parser in C#; single mode only, no TLS".freeze + s.description = "Puma, with its HTTP parser and TLS engine in C#; single mode only. Provided by IronRuby itself.".freeze + s.summary = "Puma, with its HTTP parser and TLS engine in C#; single mode only".freeze s.homepage = "https://github.com/IronLanguages/ironruby".freeze s.licenses = ["Ruby".freeze, "BSD-2-Clause".freeze] s.metadata = { "ironruby_pinned".freeze => "true".freeze } diff --git a/Util/gen-default-gemspecs.rb b/Util/gen-default-gemspecs.rb index d102ff0b2..ee11d8534 100644 --- a/Util/gen-default-gemspecs.rb +++ b/Util/gen-default-gemspecs.rb @@ -154,11 +154,11 @@ # nio4r and puma: both C extensions (over libev, and a Ragel HTTP parser plus OpenSSL # glue), so neither installs here. nio4r's Ruby half is vendored with its native half # in Src/Libraries/Nio4r; puma's Ruby half is vendored unchanged and its HTTP parser is - # Src/Libraries/Puma. puma's MiniSSL (TLS) is not implemented: Puma.ssl? is false, as it - # is for a puma built without OpenSSL. puma depends on nio4r, and says so here too. + # Src/Libraries/Puma, as is its MiniSSL engine (TLS, on SslStream). puma depends on + # nio4r, and says so here too. "nio4r" => ["2.7.5", "NIO::Selector, Monitor and ByteBuffer, on epoll, poll(2) or Socket.Select", ["nio", "nio/", "nio4r", "nio4r_ext"], require: "nio", check: "NIO::VERSION", pinned: true], - "puma" => ["8.0.2", "Puma, with its HTTP parser in C#; single mode only, no TLS", + "puma" => ["8.0.2", "Puma, with its HTTP parser and TLS engine in C#; single mode only", ["puma", "puma/", "rack/handler/puma"], require: "puma/const", check: "Puma::Const::PUMA_VERSION", pinned: true, bin: ["puma", "pumactl"], deps: [["nio4r", "~> 2.0"]]],