Skip to content

Commit

Permalink
Automatic SSL certificate provisioning for localhost (#2610)
Browse files Browse the repository at this point in the history
* Added puma to automatically use localhost gem to self signed https if defined.

* Update files according to rubocop rules

* Moved localhost authority from tcp_listener to ssl_listener

Use MiniSSLContext and MiniSSLServer for localhost authority's self-signed ceritifcates

* Reformatted codes according to rubocop rules

* Fixed test case crashing in production env

* Removed transform_keys to support ruby version < 2.5.0

* Changed wrong keystore_pass key given to context to keystore-pass

* Remove accept_nonblock.rb since we are using MiniSSL Server

* Removed localhost_authority test case running in JRUBY since localhost_authority doesn't suppot JKS yet.

* Reload Localhost authority if not loaded runned from puma cli

* Memorise localhost authority object on init

* Added readme for self-signed certificates

* Removed jruby version

* Update readme.md

* Added validations to check certificate

* Remove ssl test running in no ssl implementations

* Changed host in localhost authority

* Update ssl events wrong arguments error

* Update README.md

* Update binder.rb

* Update binder.rb

* Update binder.rb

* Update binder.rb

* Update request.rb

* Update binder

* Removed running test in JRUBY

* Removed testing localhost authority file while in JRUBY

* Removed unused variables

* Updated readme

Co-authored-by: Nate Berkopec <nate.berkopec@gmail.com>
  • Loading branch information
ye-lin-aung and nateberkopec committed Aug 18, 2021
1 parent afc21c3 commit af3675b
Show file tree
Hide file tree
Showing 4 changed files with 151 additions and 1 deletion.
1 change: 1 addition & 0 deletions Gemfile
Expand Up @@ -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
15 changes: 15 additions & 0 deletions README.md
Expand Up @@ -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

Expand Down
30 changes: 29 additions & 1 deletion lib/puma/binder.rb
Expand Up @@ -57,6 +57,7 @@ def initialize(events, conf = Configuration.new)

@envs = {}
@ios = []
localhost_authority
end

attr_reader :ios
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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|
Expand Down Expand Up @@ -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)

Expand Down
106 changes: 106 additions & 0 deletions 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 "<unknown>"
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

0 comments on commit af3675b

Please sign in to comment.