From ac346b5abcfa6253bd792091e5fb011774c40d49 Mon Sep 17 00:00:00 2001 From: Eric Wong Date: Wed, 7 Sep 2011 00:36:58 +0000 Subject: add preliminary SSL support This will also be the foundation of SSL support in Rainbows! and Zbatery. Some users may also want to use this in Unicorn on LANs to meet certain security/auditing requirements. Of course, Nightmare! (in whatever form) should also be able to use it. --- lib/unicorn/configurator.rb | 13 +++-- lib/unicorn/http_server.rb | 3 ++ lib/unicorn/ssl_client.rb | 6 +++ lib/unicorn/ssl_configurator.rb | 104 ++++++++++++++++++++++++++++++++++++++++ lib/unicorn/ssl_server.rb | 42 ++++++++++++++++ script/isolate_for_tests | 1 + t/.gitignore | 2 + t/sslgen.sh | 63 ++++++++++++++++++++++++ t/t0600-https-server-basic.sh | 48 +++++++++++++++++++ test/unit/test_sni_hostnames.rb | 47 ++++++++++++++++++ 10 files changed, 325 insertions(+), 4 deletions(-) create mode 100644 lib/unicorn/ssl_client.rb create mode 100644 lib/unicorn/ssl_configurator.rb create mode 100644 lib/unicorn/ssl_server.rb create mode 100755 t/sslgen.sh create mode 100755 t/t0600-https-server-basic.sh create mode 100644 test/unit/test_sni_hostnames.rb diff --git a/lib/unicorn/configurator.rb b/lib/unicorn/configurator.rb index 8816c85..a93c1dc 100644 --- a/lib/unicorn/configurator.rb +++ b/lib/unicorn/configurator.rb @@ -1,5 +1,6 @@ # -*- encoding: binary -*- require 'logger' +require 'unicorn/ssl_configurator' # Implements a simple DSL for configuring a \Unicorn server. # @@ -12,6 +13,7 @@ require 'logger' # See the link:/TUNING.html document for more information on tuning unicorn. class Unicorn::Configurator include Unicorn + include Unicorn::SSLConfigurator # :stopdoc: attr_accessor :set, :config_file, :after_reload @@ -563,13 +565,16 @@ private end end - def set_bool(var, bool) #:nodoc: + def check_bool(var, bool) # :nodoc: case bool when true, false - set[var] = bool - else - raise ArgumentError, "#{var}=#{bool.inspect} not a boolean" + return bool end + raise ArgumentError, "#{var}=#{bool.inspect} not a boolean" + end + + def set_bool(var, bool) #:nodoc: + set[var] = check_bool(var, bool) end def set_hook(var, my_proc, req_arity = 2) #:nodoc: diff --git a/lib/unicorn/http_server.rb b/lib/unicorn/http_server.rb index 65880d4..c78b094 100644 --- a/lib/unicorn/http_server.rb +++ b/lib/unicorn/http_server.rb @@ -1,4 +1,5 @@ # -*- encoding: binary -*- +require "unicorn/ssl_server" # This is the process manager of Unicorn. This manages worker # processes which in turn handle the I/O and application process. @@ -19,6 +20,7 @@ class Unicorn::HttpServer attr_reader :pid, :logger include Unicorn::SocketHelper include Unicorn::HttpResponse + include Unicorn::SSLServer # backwards compatibility with 1.x Worker = Unicorn::Worker @@ -563,6 +565,7 @@ class Unicorn::HttpServer self.timeout /= 2.0 # halve it for select() @config = nil build_app! unless preload_app + ssl_enable! @after_fork = @listener_opts = @orig_app = nil end diff --git a/lib/unicorn/ssl_client.rb b/lib/unicorn/ssl_client.rb new file mode 100644 index 0000000..7b41cd2 --- /dev/null +++ b/lib/unicorn/ssl_client.rb @@ -0,0 +1,6 @@ +# -*- encoding: binary -*- +# :stopdoc: +class Unicorn::SSLClient < Kgio::SSL + alias write kgio_write + alias close kgio_close +end diff --git a/lib/unicorn/ssl_configurator.rb b/lib/unicorn/ssl_configurator.rb new file mode 100644 index 0000000..c92c85e --- /dev/null +++ b/lib/unicorn/ssl_configurator.rb @@ -0,0 +1,104 @@ +# -*- encoding: binary -*- +# :stopdoc: +# This module is included in Unicorn::Configurator +# :startdoc: +# +module Unicorn::SSLConfigurator + def ssl(&block) + ssl_require! + before = @set[:listeners].dup + opts = @set[:ssl_opts] = {} + yield + (@set[:listeners] - before).each do |address| + (@set[:listener_opts][address] ||= {})[:ssl_opts] = opts + end + ensure + @set.delete(:ssl_opts) + end + + def ssl_certificate(file) + ssl_set(:ssl_certificate, file) + end + + def ssl_certificate_key(file) + ssl_set(:ssl_certificate_key, file) + end + + def ssl_client_certificate(file) + ssl_set(:ssl_client_certificate, file) + end + + def ssl_dhparam(file) + ssl_set(:ssl_dhparam, file) + end + + def ssl_ciphers(openssl_cipherlist_spec) + ssl_set(:ssl_ciphers, openssl_cipherlist_spec) + end + + def ssl_crl(file) + ssl_set(:ssl_crl, file) + end + + def ssl_prefer_server_ciphers(bool) + ssl_set(:ssl_prefer_server_ciphers, check_bool(bool)) + end + + def ssl_protocols(list) + ssl_set(:ssl_protocols, list) + end + + def ssl_verify_client(on_off_optional) + ssl_set(:ssl_verify_client, on_off_optional) + end + + def ssl_session_timeout(seconds) + ssl_set(:ssl_session_timeout, seconds) + end + + def ssl_verify_depth(depth) + ssl_set(:ssl_verify_depth, depth) + end + + # Allows specifying an engine for OpenSSL to use. We have not been + # able to successfully test this feature due to a lack of hardware, + # Reports of success or patches to mongrel-unicorn@rubyforge.org is + # greatly appreciated. + def ssl_engine(engine) + ssl_warn_global(:ssl_engine) + ssl_require! + OpenSSL::Engine.load + OpenSSL::Engine.by_id(engine) + @set[:ssl_engine] = engine + end + + def ssl_compression(bool) + # OpenSSL uses the SSL_OP_NO_COMPRESSION flag, Flipper follows suit + # with :ssl_no_compression, but we negate it to avoid exposing double + # negatives to the user. + ssl_set(:ssl_no_compression, check_bool(:ssl_compression, ! bool)) + end + +private + + def ssl_warn_global(func) # :nodoc: + Hash === @set[:ssl_opts] or return + warn("`#{func}' affects all SSL contexts in this process, " \ + "not just this block") + end + + def ssl_set(key, value) # :nodoc: + cur = @set[:ssl_opts] + Hash === cur or + raise ArgumentError, "#{key} must be called inside an `ssl' block" + cur[key] = value + end + + def ssl_require! # :nodoc: + require "flipper" + require "unicorn/ssl_client" + rescue LoadError + warn "install 'kgio-monkey' for SSL support" + raise + end +end diff --git a/lib/unicorn/ssl_server.rb b/lib/unicorn/ssl_server.rb new file mode 100644 index 0000000..c00c3ae --- /dev/null +++ b/lib/unicorn/ssl_server.rb @@ -0,0 +1,42 @@ +# -*- encoding: binary -*- +# :stopdoc: +# this module is meant to be included in Unicorn::HttpServer +# It is an implementation detail and NOT meant for users. +module Unicorn::SSLServer + attr_accessor :ssl_engine + + def ssl_enable! + sni_hostnames = rack_sni_hostnames(@app) + seen = {} # we map a single SSLContext to multiple listeners + listener_ctx = {} + @listener_opts.each do |address, address_opts| + ssl_opts = address_opts[:ssl_opts] or next + listener_ctx[address] = seen[ssl_opts.object_id] ||= begin + unless sni_hostnames.empty? + ssl_opts = ssl_opts.dup + ssl_opts[:sni_hostnames] = sni_hostnames + end + ctx = Flipper.ssl_context(ssl_opts) + # FIXME: make configurable + ctx.session_cache_mode = OpenSSL::SSL::SSLContext::SESSION_CACHE_OFF + ctx + end + end + Unicorn::HttpServer::LISTENERS.each do |listener| + ctx = listener_ctx[sock_name(listener)] or next + listener.extend(Kgio::SSLServer) + listener.ssl_ctx = ctx + listener.kgio_ssl_class = Unicorn::SSLClient + end + end + + # ugh, this depends on Rack internals... + def rack_sni_hostnames(rack_app) # :nodoc: + hostnames = {} + if Rack::URLMap === rack_app + mapping = rack_app.instance_variable_get(:@mapping) + mapping.each { |hostname,_,_,_| hostnames[hostname] = true } + end + hostnames.keys + end +end diff --git a/script/isolate_for_tests b/script/isolate_for_tests index 1dbe769..cd4a2b2 100755 --- a/script/isolate_for_tests +++ b/script/isolate_for_tests @@ -18,6 +18,7 @@ pid = fork do Isolate.now!(opts) do gem 'sqlite3-ruby', '1.2.5' gem 'raindrops', '0.7.0' + gem 'kgio-monkey', '0.3.0' gem 'kgio', '2.6.0' gem 'rack', '1.3.2' end diff --git a/t/.gitignore b/t/.gitignore index a0c1c36..2312321 100644 --- a/t/.gitignore +++ b/t/.gitignore @@ -1,2 +1,4 @@ /random_blob /.dep+* +/*.crt +/*.key diff --git a/t/sslgen.sh b/t/sslgen.sh new file mode 100755 index 0000000..3fd070a --- /dev/null +++ b/t/sslgen.sh @@ -0,0 +1,63 @@ +#!/bin/sh +set -e +set -x + +certinfo() { + echo US + echo Hell + echo A Very Special Place + echo Monkeys + echo Poo-Flingers + echo 127.0.0.1 + echo kgio@bogomips.org +} + +certinfo2() { + certinfo + echo + echo +} + +ca_certinfo () { + echo US + echo Hell + echo An Even More Special Place + echo Deranged Monkeys + echo Poo-Hurlers + echo 127.6.6.6 + echo unicorn@bogomips.org +} + +openssl genrsa -out ca.key 512 +ca_certinfo | openssl req -new -x509 -days 666 -key ca.key -out ca.crt + +openssl genrsa -out bad-ca.key 512 +ca_certinfo | openssl req -new -x509 -days 666 -key bad-ca.key -out bad-ca.crt + +openssl genrsa -out server.key 512 +certinfo2 | openssl req -new -key server.key -out server.csr + +openssl x509 -req -days 666 \ + -in server.csr -CA ca.crt -CAkey ca.key -set_serial 1 -out server.crt +n=2 +mk_client_cert () { + CLIENT=$1 + openssl genrsa -out $CLIENT.key 512 + certinfo2 | openssl req -new -key $CLIENT.key -out $CLIENT.csr + + openssl x509 -req -days 666 \ + -in $CLIENT.csr -CA $CA.crt -CAkey $CA.key -set_serial $n \ + -out $CLIENT.crt + rm -f $CLIENT.csr + n=$(($n + 1)) +} + +CA=ca +mk_client_cert client1 +mk_client_cert client2 + +CA=bad-ca mk_client_cert bad-client + +rm -f server.csr + +echo OK diff --git a/t/t0600-https-server-basic.sh b/t/t0600-https-server-basic.sh new file mode 100755 index 0000000..5dd0d65 --- /dev/null +++ b/t/t0600-https-server-basic.sh @@ -0,0 +1,48 @@ +#!/bin/sh +. ./test-lib.sh +t_plan 7 "simple HTTPS connection tests" + +t_begin "setup and start" && { + rtmpfiles curl_err + unicorn_setup +cat > $unicorn_config <> $curl_err >> $tmp + dbgcat curl_err +} + +t_begin "check stderr has no errors" && { + check_stderr +} + +t_begin "killing succeeds" && { + kill $unicorn_pid +} + +t_begin "check stderr has no errors" && { + check_stderr +} + +t_done diff --git a/test/unit/test_sni_hostnames.rb b/test/unit/test_sni_hostnames.rb new file mode 100644 index 0000000..457afee --- /dev/null +++ b/test/unit/test_sni_hostnames.rb @@ -0,0 +1,47 @@ +# -*- encoding: binary -*- +require "test/unit" +require "unicorn" + +# this tests an implementation detail, it may change so this test +# can be removed later. +class TestSniHostnames < Test::Unit::TestCase + include Unicorn::SSLServer + + def setup + GC.start + end + + def teardown + GC.start + end + + def test_host_name_detect_one + app = Rack::Builder.new do + map "http://sni1.example.com/" do + use Rack::ContentLength + use Rack::ContentType, "text/plain" + run lambda { |env| [ 200, {}, [] ] } + end + end.to_app + hostnames = rack_sni_hostnames(app) + assert hostnames.include?("sni1.example.com") + end + + def test_host_name_detect_multiple + app = Rack::Builder.new do + map "http://sni2.example.com/" do + use Rack::ContentLength + use Rack::ContentType, "text/plain" + run lambda { |env| [ 200, {}, [] ] } + end + map "http://sni3.example.com/" do + use Rack::ContentLength + use Rack::ContentType, "text/plain" + run lambda { |env| [ 200, {}, [] ] } + end + end.to_app + hostnames = rack_sni_hostnames(app) + assert hostnames.include?("sni2.example.com") + assert hostnames.include?("sni3.example.com") + end +end -- cgit v1.2.3-24-ge0c7