Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatic SSL certificate provisioning for localhost #2610

Merged
merged 32 commits into from Aug 18, 2021
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
90353b0
Added puma to automatically use localhost gem to self signed https if…
Apr 24, 2021
5c1b82f
Update files according to rubocop rules
ye-lin-aung Apr 24, 2021
0c5ff76
Moved localhost authority from tcp_listener to ssl_listener
ye-lin-aung Apr 25, 2021
4b448ce
Reformatted codes according to rubocop rules
ye-lin-aung Apr 25, 2021
e3cace7
Fixed test case crashing in production env
ye-lin-aung Apr 25, 2021
944cc0d
Removed transform_keys to support ruby version < 2.5.0
ye-lin-aung Apr 25, 2021
0f33961
Changed wrong keystore_pass key given to context to keystore-pass
ye-lin-aung Apr 25, 2021
cc359e6
Remove accept_nonblock.rb since we are using MiniSSL Server
ye-lin-aung Apr 25, 2021
014c703
Removed localhost_authority test case running in JRUBY since localhos…
ye-lin-aung Apr 25, 2021
c72f8de
Reload Localhost authority if not loaded runned from puma cli
ye-lin-aung Apr 27, 2021
c44c1df
Memorise localhost authority object on init
ye-lin-aung Apr 27, 2021
c459993
Added readme for self-signed certificates
ye-lin-aung Apr 27, 2021
f3126e3
Removed jruby version
ye-lin-aung Jun 20, 2021
d9b9e20
Update readme.md
ye-lin-aung Jun 20, 2021
29c5763
Merged with master
ye-lin-aung Jun 20, 2021
05c4926
Added validations to check certificate
ye-lin-aung Jun 21, 2021
f3ced89
Remove ssl test running in no ssl implementations
ye-lin-aung Jun 21, 2021
54f07d1
Changed host in localhost authority
ye-lin-aung Jun 21, 2021
345e6cc
Update ssl events wrong arguments error
ye-lin-aung Jun 22, 2021
525b288
Update README.md
nateberkopec Jun 27, 2021
522c286
Update binder.rb
nateberkopec Jun 27, 2021
de84ffd
Update binder.rb
nateberkopec Jun 27, 2021
d8b0451
Update binder.rb
nateberkopec Jun 27, 2021
0699664
Update binder.rb
nateberkopec Jun 27, 2021
b07434c
Update request.rb
nateberkopec Jun 27, 2021
540a971
Update binder
ye-lin-aung Jun 28, 2021
c71b062
Removed running test in JRUBY
ye-lin-aung Jun 28, 2021
1ec13b6
Removed test for jruby
ye-lin-aung Jun 28, 2021
c09b3dc
Merge branch 'master' of github.com:puma/puma
ye-lin-aung Jun 28, 2021
fe16b29
Removed testing localhost authority file while in JRUBY
ye-lin-aung Jun 28, 2021
ca27c11
Removed unused variables
ye-lin-aung Jun 29, 2021
ca3fa6e
Updated readme
ye-lin-aung Jun 29, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 certificates:
ye-lin-aung marked this conversation as resolved.
Show resolved Hide resolved

Puma supports [localhost](https://github.com/socketry/localhost) gem for self-signed certificates on non JRuby implementations. To use [localhost](https://github.com/socketry/localhost), you have to `require "localhost/authority"`:
ye-lin-aung marked this conversation as resolved.
Show resolved Hide resolved
nateberkopec marked this conversation as resolved.
Show resolved Hide resolved

```ruby
# config.ru
require './app'
require 'localhost/authority'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you gem 'localhost', require: 'localhost/authority'? Does that work?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

presumably in my app I should also put localhost in the development group in my Gemfile since the release doesn't depend on the gem... :)

run Sinatra::Application
```
##### Ruby:
```
$ puma -b 'ssl://localhost:9292' config.ru
```


#### Controlling SSL Cipher Suites

Expand Down
38 changes: 36 additions & 2 deletions lib/puma/binder.rb
Expand Up @@ -12,7 +12,6 @@ module Puma
if HAS_SSL
require 'puma/minissl'
require 'puma/minissl/context_builder'

# Odd bug in 'pure Ruby' nio4r verion 2.5.2, which installs with Ruby 2.3.
# NIO doesn't create any OpenSSL objects, but it rescues an OpenSSL error.
# The bug was that it did not require openssl.
Expand Down Expand Up @@ -56,6 +55,7 @@ def initialize(events, conf = Configuration.new)

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

attr_reader :ios
Expand Down Expand Up @@ -215,7 +215,17 @@ 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 params.empty?
# 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.
localhost_authority
ctx = localhost_authority_context || MiniSSL::ContextBuilder.new(params, @events).context
else
ctx = MiniSSL::ContextBuilder.new(params, @events).context
end

if fd = @inherited_fds.delete(str)
logger.log "* Inherited #{str}"
Expand Down Expand Up @@ -273,6 +283,25 @@ def parse(binds, logger, log_msg = 'Listening')
end
end

def localhost_authority
@localhost_authority ||= Localhost::Authority.fetch if defined?(Localhost::Authority) && !defined?(JRUBY_VERSION)
end

def localhost_authority_context
if !localhost_authority.nil?
local_certificates_path = File.expand_path("~/.localhost")
ye-lin-aung marked this conversation as resolved.
Show resolved Hide resolved
if (localhost_authority.respond_to?(:key_path) && localhost_authority.respond_to?(:certificate_path))
key_path = localhost_authority.key_path
crt_path = localhost_authority.certificate_path
else
key_path = File.join(local_certificates_path, "localhost.key")
crt_path = File.join(local_certificates_path, "localhost.crt")
end
ctx = MiniSSL::ContextBuilder.new({ "key" => key_path, "cert" => crt_path}, @events).context
return ctx
ye-lin-aung marked this conversation as resolved.
Show resolved Hide resolved
end
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 @@ -290,6 +319,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 @@ -311,6 +341,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 = ctx || localhost_authority_context
ye-lin-aung marked this conversation as resolved.
Show resolved Hide resolved

if host == "localhost"
loopback_addresses.each do |addr|
Expand Down Expand Up @@ -338,6 +370,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 = ctx || localhost_authority_context
ye-lin-aung marked this conversation as resolved.
Show resolved Hide resolved

s = fd.kind_of?(::TCPServer) ? fd : ::TCPServer.for_fd(fd)

Expand Down
2 changes: 1 addition & 1 deletion lib/puma/request.rb
Expand Up @@ -38,7 +38,7 @@ def handle_request(client, lines)

env[PUMA_SOCKET] = io

if env[HTTPS_KEY] && io.peercert
if env[HTTPS_KEY] && io.methods.include?("peer_cert") && io.peercert
ye-lin-aung marked this conversation as resolved.
Show resolved Hide resolved
env[PUMA_PEERCERT] = io.peercert
end

Expand Down
168 changes: 168 additions & 0 deletions test/test_puma_localhost_authority.rb
@@ -0,0 +1,168 @@
# 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"

if ::Puma::HAS_SSL && !Puma.jruby?
require "puma/events"
require "net/http"
require "localhost/authority"

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

puts "", RUBY_DESCRIPTION, "RUBYOPT: #{ENV['RUBYOPT']}",
ye-lin-aung marked this conversation as resolved.
Show resolved Hide resolved
" OpenSSL",
"OPENSSL_LIBRARY_VERSION: #{OpenSSL::OPENSSL_LIBRARY_VERSION}",
" OPENSSL_VERSION: #{OpenSSL::OPENSSL_VERSION}", ""
end

class TestPumaLocalhostAuthority < Minitest::Test
nateberkopec marked this conversation as resolved.
Show resolved Hide resolved
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 = "127.0.0.1"
app = lambda { |env| [200, {}, [env['rack.url_scheme']]] }

@events = SSLEventsHelper.new STDOUT, STDERR

@server = Puma::Server.new app, @events
@server.app = app
@port = (@server.add_ssl_listener @host, 0,nil).addr[1]

@http = Net::HTTP.new @host, @port
@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_url_scheme_for_https
start_server
body = nil
@http.start do
req = Net::HTTP::Get.new "/", {}
@http.request(req) do |rep|
body = rep.body
end
end

assert_equal "https", body
end

def test_request_wont_block_thread
start_server
# Open a connection and give enough data to trigger a read, then wait
ctx = OpenSSL::SSL::SSLContext.new
ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
port = @server.connected_ports[0]
socket = OpenSSL::SSL::SSLSocket.new TCPSocket.new(@host, port), ctx
socket.connect
socket.write "HEAD"
sleep 0.1

# Capture the amount of threads being used after connecting and being idle
thread_pool = @server.instance_variable_get(:@thread_pool)
busy_threads = thread_pool.spawned - thread_pool.waiting

socket.close

# The thread pool should be empty since the request would block on read
# and our request should have been moved to the reactor.
assert busy_threads.zero?, "Our connection is monopolizing a thread"
end

def test_very_large_return
start_server
giant = "x" * 2056610
@server.app = proc do
[200, {}, [giant]]
end

body = nil
@http.start do
req = Net::HTTP::Get.new "/"
@http.request(req) do |rep|
body = rep.body
end
end

assert_equal giant.bytesize, body.bytesize
end

def test_form_submit
start_server
body = nil
@http.start do
req = Net::HTTP::Post.new '/'
req.set_form_data('a' => '1', 'b' => '2')

@http.request(req) do |rep|
body = rep.body
end

end

assert_equal "https", body
end

def test_http_rejection
body_http = nil
body_https = nil
start_server
http = Net::HTTP.new @host, @server.connected_ports[0]
http.use_ssl = false
http.read_timeout = 6

tcp = Thread.new do
req_http = Net::HTTP::Get.new "/", {}
# Net::ReadTimeout - TruffleRuby
assert_raises(Errno::ECONNREFUSED, EOFError, Net::ReadTimeout) do
http.start.request(req_http) { |rep| body_http = rep.body }
end
end

ssl = Thread.new do
@http.start do
req_https = Net::HTTP::Get.new "/", {}
@http.request(req_https) { |rep_https| body_https = rep_https.body }
end
end

tcp.join
ssl.join
http.finish
sleep 1.0

assert_nil body_http
assert_equal "https", body_https

thread_pool = @server.instance_variable_get(:@thread_pool)
busy_threads = thread_pool.spawned - thread_pool.waiting

assert busy_threads.zero?, "Our connection is wasn't dropped"
end
end if ::Puma::HAS_SSL && !Puma.jruby?