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 2 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
27 changes: 27 additions & 0 deletions lib/puma/accept_nonblock.rb
@@ -0,0 +1,27 @@
require 'openssl'

module OpenSSL
module SSL
class SSLServer
unless public_method_defined? :accept_nonblock
def accept_nonblock
sock = @svr.accept_nonblock

begin
ssl = OpenSSL::SSL::SSLSocket.new(sock, @ctx)
ssl.sync_close = true
ssl.accept if @start_immediately
ssl
rescue SSLError => ex
if ssl
ssl.close
else
sock.close
end
raise ex
end
end
end
end
end
end
15 changes: 14 additions & 1 deletion lib/puma/binder.rb
Expand Up @@ -12,7 +12,7 @@ module Puma
if HAS_SSL
require 'puma/minissl'
require 'puma/minissl/context_builder'

require 'puma/accept_nonblock'
# 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 @@ -290,6 +290,19 @@ 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 defined? Localhost::Authority
raise "Puma compiled without SSL support" unless HAS_SSL
if ENV['RACK_ENV'] == "development" || ENV['RACK_ENV'] == "test"
authority = Localhost::Authority.fetch
Copy link
Contributor

@ioquatix ioquatix Apr 24, 2021

Choose a reason for hiding this comment

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

Try to do this in the top level process (I assume this is) because there are some race conditions which I need to fix - if multiple processes try to get the same certificate at the same time, they may clobber each other - in theory not a big problem (especially for development) but you can simply avoid it by ensuring it's only called once per session and at some point I'll make sure it's more robust. See socketry/localhost#1 for more details.

Copy link
Contributor

Choose a reason for hiding this comment

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

By the way in several years of using It, I've not even had a single bug report about it. So, it's kind of a non issue in practice.

Copy link
Contributor

Choose a reason for hiding this comment

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

Okay, I fixed it :) Still do it in the top level process to avoid generating a different certificate per worker if possible :)

tcp_server = OpenSSL::SSL::SSLServer.new(tcp_server, authority.server_context)
env = @proto_env.dup
env[HTTPS_KEY] = HTTPS
@envs[tcp_server] = env
end
end


if optimize_for_latency
tcp_server.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
end
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
167 changes: 167 additions & 0 deletions test/test_puma_localhost_authority.rb
@@ -0,0 +1,167 @@
# 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
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_tcp_listener @host, 0).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, Errno::ECONNRESET) 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