1
0
Fork 0
mirror of https://github.com/puma/puma.git synced 2022-11-09 13:48:40 -05:00

Support for cert_pem and key_pem with ssl_bind DSL (#2728)

* Fix deprecation warning

DEPRECATED: Use assert_nil if expecting nil from test/test_binder.rb:265. This will fail in Minitest 6.

* Extend MiniSSL with support for cert_pem and key_pem

* Extend Puma ssl_bind DSL with support for cert_pem and cert_key

* Make some variables in binder test more readable
This commit is contained in:
Dalibor Nasevic 2021-10-31 14:59:21 +01:00 committed by GitHub
parent b7748849b4
commit 5608248c13
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 257 additions and 61 deletions

View file

@ -208,8 +208,11 @@ sslctx_initialize(VALUE self, VALUE mini_ssl_ctx) {
#endif #endif
int ssl_options; int ssl_options;
VALUE key, cert, ca, verify_mode, ssl_cipher_filter, no_tlsv1, no_tlsv1_1, VALUE key, cert, ca, verify_mode, ssl_cipher_filter, no_tlsv1, no_tlsv1_1,
verification_flags, session_id_bytes; verification_flags, session_id_bytes, cert_pem, key_pem;
DH *dh; DH *dh;
BIO *bio;
X509 *x509;
EVP_PKEY *pkey;
#if OPENSSL_VERSION_NUMBER < 0x10002000L #if OPENSSL_VERSION_NUMBER < 0x10002000L
EC_KEY *ecdh; EC_KEY *ecdh;
@ -218,13 +221,15 @@ sslctx_initialize(VALUE self, VALUE mini_ssl_ctx) {
TypedData_Get_Struct(self, SSL_CTX, &sslctx_type, ctx); TypedData_Get_Struct(self, SSL_CTX, &sslctx_type, ctx);
key = rb_funcall(mini_ssl_ctx, rb_intern_const("key"), 0); key = rb_funcall(mini_ssl_ctx, rb_intern_const("key"), 0);
StringValue(key);
cert = rb_funcall(mini_ssl_ctx, rb_intern_const("cert"), 0); cert = rb_funcall(mini_ssl_ctx, rb_intern_const("cert"), 0);
StringValue(cert);
ca = rb_funcall(mini_ssl_ctx, rb_intern_const("ca"), 0); ca = rb_funcall(mini_ssl_ctx, rb_intern_const("ca"), 0);
cert_pem = rb_funcall(mini_ssl_ctx, rb_intern_const("cert_pem"), 0);
key_pem = rb_funcall(mini_ssl_ctx, rb_intern_const("key_pem"), 0);
verify_mode = rb_funcall(mini_ssl_ctx, rb_intern_const("verify_mode"), 0); verify_mode = rb_funcall(mini_ssl_ctx, rb_intern_const("verify_mode"), 0);
ssl_cipher_filter = rb_funcall(mini_ssl_ctx, rb_intern_const("ssl_cipher_filter"), 0); ssl_cipher_filter = rb_funcall(mini_ssl_ctx, rb_intern_const("ssl_cipher_filter"), 0);
@ -233,8 +238,31 @@ sslctx_initialize(VALUE self, VALUE mini_ssl_ctx) {
no_tlsv1_1 = rb_funcall(mini_ssl_ctx, rb_intern_const("no_tlsv1_1"), 0); no_tlsv1_1 = rb_funcall(mini_ssl_ctx, rb_intern_const("no_tlsv1_1"), 0);
SSL_CTX_use_certificate_chain_file(ctx, RSTRING_PTR(cert)); if (!NIL_P(cert)) {
SSL_CTX_use_PrivateKey_file(ctx, RSTRING_PTR(key), SSL_FILETYPE_PEM); StringValue(cert);
SSL_CTX_use_certificate_chain_file(ctx, RSTRING_PTR(cert));
}
if (!NIL_P(key)) {
StringValue(key);
SSL_CTX_use_PrivateKey_file(ctx, RSTRING_PTR(key), SSL_FILETYPE_PEM);
}
if (!NIL_P(cert_pem)) {
bio = BIO_new(BIO_s_mem());
BIO_puts(bio, RSTRING_PTR(cert_pem));
x509 = PEM_read_bio_X509(bio, NULL, NULL, NULL);
SSL_CTX_use_certificate(ctx, x509);
}
if (!NIL_P(key_pem)) {
bio = BIO_new(BIO_s_mem());
BIO_puts(bio, RSTRING_PTR(key_pem));
pkey = PEM_read_bio_PrivateKey(bio, NULL, NULL, NULL);
SSL_CTX_use_PrivateKey(ctx, pkey);
}
verification_flags = rb_funcall(mini_ssl_ctx, rb_intern_const("verification_flags"), 0); verification_flags = rb_funcall(mini_ssl_ctx, rb_intern_const("verification_flags"), 0);

View file

@ -30,6 +30,7 @@ module Puma
def initialize(events, conf = Configuration.new) def initialize(events, conf = Configuration.new)
@events = events @events = events
@conf = conf
@listeners = [] @listeners = []
@inherited_fds = {} @inherited_fds = {}
@activated_sockets = {} @activated_sockets = {}
@ -234,7 +235,17 @@ module Puma
# Load localhost authority if not loaded. # Load localhost authority if not loaded.
ctx = localhost_authority && localhost_authority_context if params.empty? ctx = localhost_authority && localhost_authority_context if params.empty?
ctx ||= MiniSSL::ContextBuilder.new(params, @events).context ctx ||=
begin
# Extract cert_pem and key_pem from options[:store] if present
['cert', 'key'].each do |v|
if params[v] && params[v].start_with?('store:')
index = Integer(params.delete(v).split('store:').last)
params["#{v}_pem"] = @conf.options[:store][index]
end
end
MiniSSL::ContextBuilder.new(params, @events).context
end
if fd = @inherited_fds.delete(str) if fd = @inherited_fds.delete(str)
logger.log "* Inherited #{str}" logger.log "* Inherited #{str}"

View file

@ -447,6 +447,14 @@ module Puma
# verify_mode: verify_mode, # default 'none' # verify_mode: verify_mode, # default 'none'
# verification_flags: flags, # optional, not supported by JRuby # verification_flags: flags, # optional, not supported by JRuby
# } # }
#
# Alternatively, you can provide the cert_pem and key_pem:
# @example
# ssl_bind '127.0.0.1', '9292', {
# cert_pem: File.read(path_to_cert),
# key_pem: File.read(path_to_key),
# }
#
# @example For JRuby, two keys are required: keystore & keystore_pass. # @example For JRuby, two keys are required: keystore & keystore_pass.
# ssl_bind '127.0.0.1', '9292', { # ssl_bind '127.0.0.1', '9292', {
# keystore: path_to_keystore, # keystore: path_to_keystore,
@ -455,6 +463,7 @@ module Puma
# verify_mode: verify_mode # default 'none' # verify_mode: verify_mode # default 'none'
# } # }
def ssl_bind(host, port, opts) def ssl_bind(host, port, opts)
add_pem_values_to_options_store(opts)
bind self.class.ssl_bind_str(host, port, opts) bind self.class.ssl_bind_str(host, port, opts)
end end
@ -927,5 +936,25 @@ module Puma
def mutate_stdout_and_stderr_to_sync_on_write(enabled=true) def mutate_stdout_and_stderr_to_sync_on_write(enabled=true)
@options[:mutate_stdout_and_stderr_to_sync_on_write] = enabled @options[:mutate_stdout_and_stderr_to_sync_on_write] = enabled
end end
private
# To avoid adding cert_pem and key_pem as URI params, we store them on the
# options[:store] from where Puma binder knows how to find and extract them.
def add_pem_values_to_options_store(opts)
return if defined?(JRUBY_VERSION)
@options[:store] ||= []
# Store cert_pem and key_pem to options[:store] if present
[:cert, :key].each do |v|
opt_key = :"#{v}_pem"
if opts[opt_key]
index = @options[:store].length
@options[:store] << opts[opt_key]
opts[v] = "store:#{index}"
end
end
end
end end
end end

View file

@ -208,6 +208,10 @@ module Puma
def initialize def initialize
@no_tlsv1 = false @no_tlsv1 = false
@no_tlsv1_1 = false @no_tlsv1_1 = false
@key = nil
@cert = nil
@key_pem = nil
@cert_pem = nil
end end
if IS_JRUBY if IS_JRUBY
@ -230,6 +234,8 @@ module Puma
attr_reader :key attr_reader :key
attr_reader :cert attr_reader :cert
attr_reader :ca attr_reader :ca
attr_reader :cert_pem
attr_reader :key_pem
attr_accessor :ssl_cipher_filter attr_accessor :ssl_cipher_filter
attr_accessor :verification_flags attr_accessor :verification_flags
@ -248,9 +254,19 @@ module Puma
@ca = ca @ca = ca
end end
def cert_pem=(cert_pem)
raise ArgumentError, "'cert_pem' is not a String" unless cert_pem.is_a? String
@cert_pem = cert_pem
end
def key_pem=(key_pem)
raise ArgumentError, "'key_pem' is not a String" unless key_pem.is_a? String
@key_pem = key_pem
end
def check def check
raise "Key not configured" unless @key raise "Key not configured" if @key.nil? && @key_pem.nil?
raise "Cert not configured" unless @cert raise "Cert not configured" if @cert.nil? && @cert_pem.nil?
end end
end end

View file

@ -23,17 +23,19 @@ module Puma
ctx.keystore_pass = params['keystore-pass'] ctx.keystore_pass = params['keystore-pass']
ctx.ssl_cipher_list = params['ssl_cipher_list'] if params['ssl_cipher_list'] ctx.ssl_cipher_list = params['ssl_cipher_list'] if params['ssl_cipher_list']
else else
unless params['key'] if params['key'].nil? && params['key_pem'].nil?
events.error "Please specify the SSL key via 'key='" events.error "Please specify the SSL key via 'key=' or 'key_pem='"
end end
ctx.key = params['key'] ctx.key = params['key'] if params['key']
ctx.key_pem = params['key_pem'] if params['key_pem']
unless params['cert'] if params['cert'].nil? && params['cert_pem'].nil?
events.error "Please specify the SSL cert via 'cert='" events.error "Please specify the SSL cert via 'cert=' or 'cert_pem='"
end end
ctx.cert = params['cert'] ctx.cert = params['cert'] if params['cert']
ctx.cert_pem = params['cert_pem'] if params['cert_pem']
if ['peer', 'force_peer'].include?(params['verify_mode']) if ['peer', 'force_peer'].include?(params['verify_mode'])
unless params['ca'] unless params['ca']

View file

@ -262,7 +262,7 @@ class TestBinder < TestBinderBase
env_hash = @binder.envs[@binder.ios.first] env_hash = @binder.envs[@binder.ios.first]
@binder.proto_env.each do |k,v| @binder.proto_env.each do |k,v|
assert_equal env_hash[k], v assert env_hash[k] == v
end end
end end
@ -308,11 +308,11 @@ class TestBinder < TestBinderBase
def test_close_listeners_closes_ios def test_close_listeners_closes_ios
@binder.parse ["tcp://127.0.0.1:#{UniquePort.call}"], @events @binder.parse ["tcp://127.0.0.1:#{UniquePort.call}"], @events
refute @binder.listeners.any? { |u, l| l.closed? } refute @binder.listeners.any? { |_l, io| io.closed? }
@binder.close_listeners @binder.close_listeners
assert @binder.listeners.all? { |u, l| l.closed? } assert @binder.listeners.all? { |_l, io| io.closed? }
end end
def test_close_listeners_closes_ios_unless_closed? def test_close_listeners_closes_ios_unless_closed?
@ -322,11 +322,11 @@ class TestBinder < TestBinderBase
bomb.close bomb.close
def bomb.close; raise "Boom!"; end # the bomb has been planted def bomb.close; raise "Boom!"; end # the bomb has been planted
assert @binder.listeners.any? { |u, l| l.closed? } assert @binder.listeners.any? { |_l, io| io.closed? }
@binder.close_listeners @binder.close_listeners
assert @binder.listeners.all? { |u, l| l.closed? } assert @binder.listeners.all? { |_l, io| io.closed? }
end end
def test_listeners_file_unlink_if_unix_listener def test_listeners_file_unlink_if_unix_listener
@ -344,8 +344,8 @@ class TestBinder < TestBinderBase
@binder.parse ["tcp://127.0.0.1:0"], @events @binder.parse ["tcp://127.0.0.1:0"], @events
removals = @binder.create_inherited_fds(@binder.redirects_for_restart_env) removals = @binder.create_inherited_fds(@binder.redirects_for_restart_env)
@binder.listeners.each do |url, io| @binder.listeners.each do |l, io|
assert_equal io.to_i, @binder.inherited_fds[url] assert_equal io.to_i, @binder.inherited_fds[l]
end end
assert_includes removals, "PUMA_INHERIT_0" assert_includes removals, "PUMA_INHERIT_0"
end end

View file

@ -77,6 +77,28 @@ class TestConfigFile < TestConfigFileBase
assert_equal [ssl_binding], conf.options[:binds] assert_equal [ssl_binding], conf.options[:binds]
end end
def test_ssl_bind_with_cert_and_key_pem
skip_if :jruby
skip_unless :ssl
cert_path = File.expand_path "../examples/puma/client-certs", __dir__
cert_pem = File.read("#{cert_path}/server.crt")
key_pem = File.read("#{cert_path}/server.key")
conf = Puma::Configuration.new do |c|
c.ssl_bind "0.0.0.0", "9292", {
cert_pem: cert_pem,
key_pem: key_pem,
verify_mode: "the_verify_mode",
}
end
conf.load
ssl_binding = "ssl://0.0.0.0:9292?cert=store:0&key=store:1&verify_mode=the_verify_mode"
assert_equal [ssl_binding], conf.options[:binds]
end
def test_ssl_bind_jruby def test_ssl_bind_jruby
skip_unless :jruby skip_unless :jruby
skip_unless :ssl skip_unless :ssl

View file

@ -21,17 +21,47 @@ class TestIntegrationSSL < TestIntegration
super super
end end
def generate_config(opts = nil) def bind_port
@bind_port = UniquePort.call @bind_port ||= UniquePort.call
@control_tcp_port = UniquePort.call end
def control_tcp_port
@control_tcp_port ||= UniquePort.call
end
def with_server(config)
config_file = Tempfile.new %w(config .rb)
config_file.write config
config_file.close
config_file.path
# start server
cmd = "#{BASE} bin/puma -C #{config_file.path}"
@server = IO.popen cmd, 'r'
wait_for_server_to_boot
@pid = @server.pid
http = Net::HTTP.new HOST, bind_port
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
yield http
# stop server
sock = TCPSocket.new HOST, control_tcp_port
@ios_to_close << sock
sock.syswrite "GET /stop?token=#{TOKEN} HTTP/1.1\r\n\r\n"
sock.read
assert_match 'Goodbye!', @server.read
end
def test_ssl_run
config = <<RUBY config = <<RUBY
#{opts}
if ::Puma.jruby? if ::Puma.jruby?
keystore = '#{File.expand_path '../examples/puma/keystore.jks', __dir__}' keystore = '#{File.expand_path '../examples/puma/keystore.jks', __dir__}'
keystore_pass = 'jruby_puma' keystore_pass = 'jruby_puma'
ssl_bind '#{HOST}', '#{@bind_port}', { ssl_bind '#{HOST}', '#{bind_port}', {
keystore: keystore, keystore: keystore,
keystore_pass: keystore_pass, keystore_pass: keystore_pass,
verify_mode: 'none' verify_mode: 'none'
@ -40,53 +70,56 @@ else
key = '#{File.expand_path '../examples/puma/puma_keypair.pem', __dir__}' key = '#{File.expand_path '../examples/puma/puma_keypair.pem', __dir__}'
cert = '#{File.expand_path '../examples/puma/cert_puma.pem', __dir__}' cert = '#{File.expand_path '../examples/puma/cert_puma.pem', __dir__}'
ssl_bind '#{HOST}', '#{@bind_port}', { ssl_bind '#{HOST}', '#{bind_port}', {
cert: cert, cert: cert,
key: key, key: key,
verify_mode: 'none' verify_mode: 'none'
} }
end end
activate_control_app 'tcp://#{HOST}:#{@control_tcp_port}', { auth_token: '#{TOKEN}' } activate_control_app 'tcp://#{HOST}:#{control_tcp_port}', { auth_token: '#{TOKEN}' }
app do |env|
[200, {}, [env['rack.url_scheme']]]
end
RUBY
with_server(config) do |http|
body = nil
http.start do
req = Net::HTTP::Get.new '/', {}
http.request(req) { |resp| body = resp.body }
end
assert_equal 'https', body
end
end
def test_ssl_run_with_pem
skip_if :jruby
config = <<RUBY
key_path = '#{File.expand_path '../examples/puma/puma_keypair.pem', __dir__}'
cert_path = '#{File.expand_path '../examples/puma/cert_puma.pem', __dir__}'
ssl_bind '#{HOST}', '#{bind_port}', {
cert_pem: File.read(cert_path),
key_pem: File.read(key_path),
verify_mode: 'none'
}
activate_control_app 'tcp://#{HOST}:#{control_tcp_port}', { auth_token: '#{TOKEN}' }
app do |env| app do |env|
[200, {}, [env['rack.url_scheme']]] [200, {}, [env['rack.url_scheme']]]
end end
RUBY RUBY
config_file = Tempfile.new %w(config .rb) with_server(config) do |http|
config_file.write config body = nil
config_file.close http.start do
config_file.path req = Net::HTTP::Get.new '/', {}
end http.request(req) { |resp| body = resp.body }
end
def start_server(opts = nil) assert_equal 'https', body
cmd = "#{BASE} bin/puma -C #{generate_config opts}"
@server = IO.popen cmd, 'r'
wait_for_server_to_boot
@pid = @server.pid
@http = Net::HTTP.new HOST, @bind_port
@http.use_ssl = true
@http.verify_mode = OpenSSL::SSL::VERIFY_NONE
end
def stop_server
sock = TCPSocket.new HOST, @control_tcp_port
@ios_to_close << sock
sock.syswrite "GET /stop?token=#{TOKEN} HTTP/1.1\r\n\r\n"
sock.read
assert_match 'Goodbye!', @server.read
end
def test_ssl_run
body = nil
start_server
@http.start do
req = Net::HTTP::Get.new '/', {}
@http.request(req) { |resp| body = resp.body }
end end
assert_equal 'https', body
stop_server
end end
end if ::Puma::HAS_SSL end if ::Puma::HAS_SSL

View file

@ -25,5 +25,19 @@ class TestMiniSSL < Minitest::Test
exception = assert_raises(ArgumentError) { ctx.cert = "/no/such/cert" } exception = assert_raises(ArgumentError) { ctx.cert = "/no/such/cert" }
assert_equal("No such cert file '/no/such/cert'", exception.message) assert_equal("No such cert file '/no/such/cert'", exception.message)
end end
def test_raises_with_invalid_key_pem
ctx = Puma::MiniSSL::Context.new
exception = assert_raises(ArgumentError) { ctx.key_pem = nil }
assert_equal("'key_pem' is not a String", exception.message)
end
def test_raises_with_invalid_cert_pem
ctx = Puma::MiniSSL::Context.new
exception = assert_raises(ArgumentError) { ctx.cert_pem = nil }
assert_equal("'cert_pem' is not a String", exception.message)
end
end end
end if ::Puma::HAS_SSL end if ::Puma::HAS_SSL

View file

@ -333,3 +333,44 @@ class TestPumaServerSSLClient < Minitest::Test
end end
end end
end if ::Puma::HAS_SSL end if ::Puma::HAS_SSL
class TestPumaServerSSLWithCertPemAndKeyPem < Minitest::Test
CERT_PATH = File.expand_path "../examples/puma/client-certs", __dir__
def test_server_ssl_with_cert_pem_and_key_pem
host = "localhost"
port = 0
ctx = Puma::MiniSSL::Context.new.tap { |ctx|
ctx.cert_pem = File.read("#{CERT_PATH}/server.crt")
ctx.key_pem = File.read("#{CERT_PATH}/server.key")
}
app = lambda { |env| [200, {}, [env['rack.url_scheme']]] }
events = SSLEventsHelper.new STDOUT, STDERR
server = Puma::Server.new app, events
server.add_ssl_listener host, port, ctx
host_addrs = server.binder.ios.map { |io| io.to_io.addr[2] }
server.run
http = Net::HTTP.new host, server.connected_ports[0]
http.use_ssl = true
http.ca_file = "#{CERT_PATH}/ca.crt"
client_error = nil
begin
http.start do
req = Net::HTTP::Get.new "/", {}
http.request(req)
end
rescue OpenSSL::SSL::SSLError, EOFError, Errno::ECONNRESET => e
# Errno::ECONNRESET TruffleRuby
client_error = e
# closes socket if open, may not close on error
http.send :do_finish
end
assert_nil client_error
ensure
server.stop(true) if server
end
end if ::Puma::HAS_SSL && !Puma::IS_JRUBY