diff --git a/Gemfile b/Gemfile index a435e2a7a2..10b004bff6 100644 --- a/Gemfile +++ b/Gemfile @@ -23,3 +23,4 @@ if %w(2.2.7 2.2.8 2.2.9 2.2.10 2.3.4 2.4.1).include? RUBY_VERSION end gem 'm' +gem "localhost", require: false diff --git a/README.md b/README.md index 07ef6117dd..eaeeb53724 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,21 @@ Need a bit of security? Use SSL sockets: ``` $ puma -b 'ssl://127.0.0.1:9292?key=path_to_key&cert=path_to_cert' ``` +#### Self-signed SSL certificates (via _localhost_ gem, for development use): + +Puma supports [localhost](https://github.com/socketry/localhost) gem for self-signed certificates. This is particularly useful if you want to use Puma with SSL locally, and self-signed certificates will work for your use-case. Currently, `localhost-authority` can be used only in MRI. To use [localhost](https://github.com/socketry/localhost), you have to `require "localhost/authority"`: + +```ruby +# config.ru +require './app' +require 'localhost/authority' +run Sinatra::Application + +... + +$ puma -b 'ssl://localhost:9292' config.ru +``` + #### Controlling SSL Cipher Suites diff --git a/lib/puma/binder.rb b/lib/puma/binder.rb index 7507c92af1..6151889e79 100644 --- a/lib/puma/binder.rb +++ b/lib/puma/binder.rb @@ -57,6 +57,7 @@ def initialize(events, conf = Configuration.new) @envs = {} @ios = [] + localhost_authority end attr_reader :ios @@ -227,7 +228,13 @@ def parse(binds, logger, log_msg = 'Listening') raise "Puma compiled without SSL support" unless HAS_SSL params = Util.parse_query uri.query - ctx = MiniSSL::ContextBuilder.new(params, @events).context + + # If key and certs are not defined and localhost gem is required. + # localhost gem will be used for self signed + # Load localhost authority if not loaded. + ctx = localhost_authority && localhost_authority_context if params.empty? + + ctx ||= MiniSSL::ContextBuilder.new(params, @events).context if fd = @inherited_fds.delete(str) logger.log "* Inherited #{str}" @@ -285,6 +292,22 @@ def parse(binds, logger, log_msg = 'Listening') end end + def localhost_authority + @localhost_authority ||= Localhost::Authority.fetch if defined?(Localhost::Authority) && !Puma::IS_JRUBY + end + + def localhost_authority_context + return unless localhost_authority + + key_path, crt_path = if [:key_path, :certificate_path].all? { |m| localhost_authority.respond_to?(m) } + [localhost_authority.key_path, localhost_authority.certificate_path] + else + local_certificates_path = File.expand_path("~/.localhost") + [File.join(local_certificates_path, "localhost.key"), File.join(local_certificates_path, "localhost.crt")] + end + MiniSSL::ContextBuilder.new({ "key" => key_path, "cert" => crt_path }, @events).context + end + # Tell the server to listen on host +host+, port +port+. # If +optimize_for_latency+ is true (the default) then clients connecting # will be optimized for latency over throughput. @@ -302,6 +325,7 @@ def add_tcp_listener(host, port, optimize_for_latency=true, backlog=1024) host = host[1..-2] if host and host[0..0] == '[' tcp_server = TCPServer.new(host, port) + if optimize_for_latency tcp_server.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) end @@ -323,6 +347,8 @@ def add_ssl_listener(host, port, ctx, optimize_for_latency=true, backlog=1024) raise "Puma compiled without SSL support" unless HAS_SSL + # Puma will try to use local authority context if context is supplied nil + ctx ||= localhost_authority_context if host == "localhost" loopback_addresses.each do |addr| @@ -350,6 +376,8 @@ def add_ssl_listener(host, port, ctx, def inherit_ssl_listener(fd, ctx) raise "Puma compiled without SSL support" unless HAS_SSL + # Puma will try to use local authority context if context is supplied nil + ctx ||= localhost_authority_context s = fd.kind_of?(::TCPServer) ? fd : ::TCPServer.for_fd(fd) diff --git a/test/test_puma_localhost_authority.rb b/test/test_puma_localhost_authority.rb new file mode 100644 index 0000000000..24d2ab19d8 --- /dev/null +++ b/test/test_puma_localhost_authority.rb @@ -0,0 +1,106 @@ +# Nothing in this file runs if Puma isn't compiled with ssl support +# +# helper is required first since it loads Puma, which needs to be +# loaded so HAS_SSL is defined +require_relative "helper" +require "localhost/authority" + +if ::Puma::HAS_SSL && !Puma::IS_JRUBY + require "puma/minissl" + require "puma/events" + require "net/http" + + class SSLEventsHelper < ::Puma::Events + attr_accessor :addr, :cert, :error + + def ssl_error(error, ssl_socket) + self.error = error + self.addr = ssl_socket.peeraddr.last rescue "" + self.cert = ssl_socket.peercert + end + end + + # net/http (loaded in helper) does not necessarily load OpenSSL + require "openssl" unless Object.const_defined? :OpenSSL +end + +class TestPumaLocalhostAuthority < Minitest::Test + parallelize_me! + def setup + @http = nil + @server = nil + end + + def teardown + @http.finish if @http && @http.started? + @server.stop(true) if @server + end + + # yields ctx to block, use for ctx setup & configuration + def start_server + @host = "localhost" + app = lambda { |env| [200, {}, [env['rack.url_scheme']]] } + + @events = SSLEventsHelper.new STDOUT, STDERR + @server = Puma::Server.new app, @events + @server.app = app + @server.add_ssl_listener @host, 0,nil + @http = Net::HTTP.new @host, @server.connected_ports[0] + + @http.use_ssl = true + # Disabling verification since its self signed + @http.verify_mode = OpenSSL::SSL::VERIFY_NONE + # @http.verify_mode = OpenSSL::SSL::VERIFY_NONE + + @server.run + end + + def test_localhost_authority_file_generated + # Initiate server to create localhost authority + unless File.exist?(File.join(Localhost::Authority.path,"localhost.key")) + start_server + end + assert_equal(File.exist?(File.join(Localhost::Authority.path,"localhost.key")), true) + assert_equal(File.exist?(File.join(Localhost::Authority.path,"localhost.crt")), true) + end + +end if ::Puma::HAS_SSL && !Puma::IS_JRUBY + +class TestPumaSSLLocalhostAuthority < Minitest::Test + def test_self_signed_by_localhost_authority + @host = "localhost" + + app = lambda { |env| [200, {}, [env['rack.url_scheme']]] } + + @events = SSLEventsHelper.new STDOUT, STDERR + + @server = Puma::Server.new app, @events + @server.app = app + + @server.add_ssl_listener @host, 0,nil + + @http = Net::HTTP.new @host, @server.connected_ports[0] + @http.use_ssl = true + + OpenSSL::PKey::RSA.new File.read(File.join(Localhost::Authority.path,"localhost.key")) + local_authority_crt = OpenSSL::X509::Certificate.new File.read(File.join(Localhost::Authority.path,"localhost.crt")) + + @http.verify_mode = OpenSSL::SSL::VERIFY_NONE + @server.run + @cert = nil + begin + @http.start do + req = Net::HTTP::Get.new "/", {} + @http.request(req) + @cert = @http.peer_cert + end + rescue OpenSSL::SSL::SSLError, EOFError, Errno::ECONNRESET + # Errno::ECONNRESET TruffleRuby + # closes socket if open, may not close on error + @http.send :do_finish + end + sleep 0.1 + + assert_equal(@cert.to_pem, local_authority_crt.to_pem) + end +end if ::Puma::HAS_SSL && !Puma::IS_JRUBY