Commit 68839179 authored by Patrick J Cherry's avatar Patrick J Cherry
Browse files

Added an SSL configuration class for the apache stuff, with tests.

parent eec43c5c
......@@ -28,8 +28,13 @@ NameVirtualHost <%= ip() %>:443
<VirtualHost <%= ip() %>:443>
SSLEngine On
<%= certificate %>
<%= bundle() %>
SSLCertificateFile <%= certificate %>
<% if key_file != certificate_file %>
SSLKeyFile <%= certificate %>
<% end %>
<% if certificate_chain_file %>
SSLCertificateChainFile <%= bundle() %>
<% end %>
SSLOptions +StrictRequire
......
require 'openssl'
#
# A helper class which copes with SSL-domains.
#
#
module Symbiosis
class SSLConfiguration
#
# The domain this object is working with.
#
attr_reader :domain
attr_accessor :root_path, :certificate_file, :key_file
attr_writer :certificate_chain_file
#
# Constructor.
#
def initialize( domain )
@domain = domain
@certificate = nil
@key = nil
@bundle = nil
@root_path = "/"
end
#
# Returns the apache2 configuration directory
#
def apache_dir
File.join(@root_path, "etc", "apache2")
end
#
# Returns the configuration directory for this domain
#
def config_dir
File.join(@root_path, "srv", @domain, "config")
end
#
# Is SSL enabled for the domain?
#
# SSL is enabled if we have:
#
# /srv/$domain/config/ip
#
# And one of:
#
# /srv/$domain/config/ssl.key
# /srv/$doamin/config/ssl.combined
#
def ssl_enabled?
File.exists?( "#{self.config_dir}/ip" ) and
( File.exists?( "#{self.config_dir}/ssl.key" ) or
File.exists?( "#{self.config_dir}/ssl.combined" ) )
end
#
# Is there an Apache site enabled for this domain?
#
def site_enabled?
File.exists?( File.join( self.apache_dir, "sites-enabled", "#{@domain}.ssl" ) )
end
#
# Do we redirect to the SSL only version of this site?
#
def mandatory_ssl?
File.exists?( File.join( config_dir , "ssl-only" ) )
end
#
# Remove the apache file.
#
def remove_site
if ( File.exists?( "/etc/apache2/sites-enabled/#{@domain}.ssl" ) )
File.unlink( "/etc/apache2/sites-enabled/#{@domain}.ssl" )
end
if ( File.exists?( "/etc/apache2/sites-available/#{@domain}.ssl" ) )
File.unlink( "/etc/apache2/sites-available/#{@domain}.ssl" )
end
end
#
# Return the IP for this domain.
#
def ip
File.open( File.join( self.config_dir, "ip" ) ){|fh| fh.readlines}.first.chomp
end
#
# Returns the X509 certificate object
#
def certificate
OpenSSL::X509::Certificate.new(File.read(self.certificate_file))
end
#
# Returns the RSA key object
#
def key
OpenSSL::PKey::RSA.new(File.read(self.key_file))
end
#
# Returns the certificate chain file, if one exists, or one has been set.
#
def certificate_chain_file
if @certificate_chain_file.nil? and File.exists?( File.join( self.config_dir,"ssl.bundle" ) )
@certificate_chain_file = File.join( self.config_dir,"ssl.bundle" )
end
@certificate_chain_file
end
alias bundle_file certificate_chain_file
#
# Returns the X509 certificate store, including any specified chain file
#
def certificate_chain
certificate_chain = OpenSSL::X509::Store.new
certificate_chain.set_default_paths
certificate_chain.add_file(self.certificate_chain_file) if self.certificate_chain_file
certificate_chain
end
#
# Return the available certificate files
#
def available_certificate_files
# Try a number of permutations
%w(combined key crt cert pem).collect do |ext|
fn = File.join(self.config_dir, "ssl.#{ext}")
#
# Try and open the certificate
#
begin
OpenSSL::X509::Certificate.new(File.read(fn))
fn
rescue Errno::ENOENT, Errno::EPERM
# Skip if the file doesn't exist
nil
rescue OpenSSL::OpenSSLError
# Skip if we can't read the cert
nil
end
end.reject do |fn|
begin
raise Errno::ENOENT if fn.nil?
#
# See if there is a key in the same file
#
this_key = OpenSSL::PKey::RSA.new(File.read(fn))
this_cert = OpenSSL::X509::Certificate.new(File.read(fn))
#
# If the cert can't validate the private key, reject!
#
true unless this_cert.check_private_key(this_key)
rescue OpenSSL::OpenSSLError
#
# Keep if there is no key in this file
#
false
rescue Errno::ENOENT
#
# Reject if the file can't be found
#
true
end
end
end
#
# Return the available key files
#
def available_key_files
# Try a number of permutations
%w(combined key cert crt pem).collect do |ext|
fn = File.join(self.config_dir, "ssl.#{ext}")
#
# Try to open and read the key
#
begin
OpenSSL::PKey::RSA.new(File.read(fn))
fn
rescue Errno::ENOENT, Errno::EPERM
# Skip if the file doesn't exist
nil
rescue OpenSSL::OpenSSLError
# Skip if we can't read the cert
nil
end
end.reject do |fn|
begin
raise Errno::ENOENT if fn.nil?
#
# See if there is a key in the same file
#
this_cert = OpenSSL::X509::Certificate.new(File.read(fn))
this_key = OpenSSL::PKey::RSA.new(File.read(fn))
#
# If the cert can't validate the private key, reject!
#
true unless this_cert.check_private_key(this_key)
rescue OpenSSL::OpenSSLError
#
# Keep if there is no certificate in this file
#
false
rescue Errno::ENOENT
#
# Reject if the file can't be found
#
true
end
end
end
#
# Tests each of the available key and certificate files, until a matching
# pair is found. Returns an array of [certificate filename, key_filename],
# or nil if no match is found.
#
def find_matching_certificate_and_key
#
# Test each certificate...
self.available_certificate_files.each do |cert_fn|
cert = OpenSSL::X509::Certificate.new(File.read(cert_fn))
#
# ...with each key
self.available_key_files.each do |key_fn|
key = OpenSSL::PKey::RSA.new(File.read(key_fn))
#
# This tests the private key, and returns the current certificate and
# key if they verify.
return [cert_fn, key_fn] if cert.check_private_key(key)
end
end
#
# Return nil if no matching keys and certs are found
return nil
end
def verify
# Firstly check that the certificate is valid for the domain.
#
#
unless OpenSSL::SSL.verify_certificate_identity(self.certificate, @domain) or OpenSSL::SSL.verify_certificate_identity(self.certificate, "www.#{@domain}")
raise OpenSSL::X509::CertificateError, "Certificate subject is not valid for this domain."
end
# Check that the certificate is current
#
#
if self.certificate.not_before > Time.now
raise OpenSSL::X509::CertificateError, "Certificate is not valid yet."
end
if self.certificate.not_after < Time.now
raise OpenSSL::X509::CertificateError, "Certificate has expired."
end
# Next check that the key matches the certificate.
#
#
unless self.certificate.check_private_key(self.key)
raise OpenSSL::X509::CertificateError, "Private key does not match certificate."
end
# Now check the certificate can be verified by the key. Well I *think*
# that is what the Certificate#verify method is for.
#
unless self.certificate.verify(self.key)
raise OpenSSL::X509::CertificateError, "Private key does not match certificate."
end
# At this point, return if certificate is self-signed
#
#
return true if self.certificate.issuer.to_s == self.certificate.subject.to_s
# Now validate the certificate, using a bundle if needed.
#
#
raise OpenSSL::X509::CertificateError, "Certificate does not verify -- maybe a bundle is missing?" unless self.certificate_chain.verify(self.certificate)
#
#
return true
end
#
# Update Apache to create a site for this domain.
#
def create_ssl_site( tf = File.join(self.root_path, "etc/symbiosis/apache.d/ssl.template.erb") )
#
# Read the template file.
#
content = File.open( tf, "r" ).read()
#
# Create a template object.
#
template = ERB.new( content )
#
# Write out to sites-enabled
#
File.open( File.join( self.apache_dir, "sites-available/#{@domain}.ssl", "w" ) ) do |file|
file.write template.result(binding)
end
#
# Now link in the file
#
File.symlink( File.join( self.apache_dir, "sites-available/#{@domain}.ssl" ),
File.join( self.apache_dir, "sites-enabled/#{@domain}.ssl" ) )
end
#
# Does the SSL site need updating because a file is more
# recent than the generated Apache site?
#
def outdated?
#
# creation time of the (previously generated) SSL-site.
#
site = File.mtime( "/etc/apache2/sites-available/#{@domain}.ssl" )
#
# For each configuration file see if it is more recent
#
files = %w( ssl.combined ssl.key ssl.bundle ip )
files.each do |file|
if ( File.exists?( File.join( self.config_dir, file ) ) )
mtime = File.mtime( File.join( self.config_dir, file ) )
if ( mtime > site )
return true
end
end
end
false
end
end
end
# OK we're running this test locally
unless File.dirname( File.expand_path( __FILE__ ) ) == "/etc/symbiosis/test.d"
["../lib", "../../test/lib" ].each do |d|
if File.directory?(d)
$: << d
else
raise Errno::ENOENT, d
end
end
end
require 'test/unit'
require "tempfile"
require 'etc'
require 'pp'
require 'symbiosis/ssl_configuration'
require 'symbiosis/test/http'
module Symbiosis
module Test
class Http
def directory
File.join("/tmp", "srv", @name)
end
end
end
end
class SSLConfigTest < Test::Unit::TestCase
def setup
@domain = Symbiosis::Test::Http.new
@domain.user = Etc.getpwuid.name
@domain.group = Etc.getgrgid(Etc.getpwuid.gid).name
@domain.create
@ssl = Symbiosis::SSLConfiguration.new(@domain.name)
@ssl.root_path = "/tmp"
#
# Copy some SSL certs over
#
FileUtils.mkdir_p(@domain.directory+"/config")
@key = do_generate_key
@csr = do_generate_csr
@crt = do_generate_cert
end
def teardown
@domain.destroy unless $DEBUG
FileUtils.rmdir "/tmp/srv"
end
#
# Returns a private key
#
def do_generate_key
OpenSSL::PKey::RSA.new(512)
end
def do_generate_csr(key = @key, domain = @domain.name)
csr = OpenSSL::X509::Request.new
csr.version = 0
csr.subject = OpenSSL::X509::Name.new( [ ["C","GB"], ["CN", domain]] )
csr.public_key = key.public_key
csr.sign( key, OpenSSL::Digest::SHA1.new )
csr
end
def do_generate_cert(csr = @csr, key = @key, ca=nil)
cert = OpenSSL::X509::Certificate.new
cert.subject = csr.subject
cert.issuer = csr.subject
cert.public_key = csr.public_key
cert.not_before = Time.now
cert.not_after = Time.now + 60
cert.serial = 0x0
cert.version = 1
cert.sign( key, OpenSSL::Digest::SHA1.new )
cert
end
def test_ssl_enabled?
end
def test_site_enabled?
end
def test_mandatory_ssl?
end
def test_remove_site
end
def test_ip
end
def test_certificate
end
def test_key
end
def test_certificate_chain_file
end
def test_certificate_chain
end
def test_avilable_certificate_files
#
# Write the certificate in various forms
#
File.open(@domain.directory+"/config/ssl.combined","w+"){|fh| fh.write @crt.to_pem+@key.to_pem}
File.open(@domain.directory+"/config/ssl.key","w+"){|fh| fh.write @crt.to_pem+@key.to_pem}
File.open(@domain.directory+"/config/ssl.crt","w+"){|fh| fh.write @crt.to_pem}
File.open(@domain.directory+"/config/ssl.cert","w+"){|fh| fh.write @crt.to_pem}
File.open(@domain.directory+"/config/ssl.pem","w+"){|fh| fh.write @crt.to_pem}
#
# Combined is preferred
#
assert_equal( %w(combined key crt cert pem).collect{|ext| @domain.directory+"/config/ssl."+ext},
@ssl.available_certificate_files)
#
# If a combined file contains a non-matching cert+key, don't return it
#
new_key = do_generate_key
File.open(@domain.directory+"/config/ssl.combined","w+"){|fh| fh.write @crt.to_pem + new_key.to_pem}
assert_equal( %w(key crt cert pem).collect{|ext| @domain.directory+"/config/ssl."+ext},
@ssl.available_certificate_files )
end
def test_available_keys
#
# Write the key to a number of files
#
File.open(@domain.directory+"/config/ssl.combined","w+"){|fh| fh.write @crt.to_pem+@key.to_pem}
File.open(@domain.directory+"/config/ssl.key","w+"){|fh| fh.write @key.to_pem}
File.open(@domain.directory+"/config/ssl.crt","w+"){|fh| fh.write @crt.to_pem}
#
# Combined is preferred
#
assert_equal( %w(combined key).collect{|ext| @domain.directory+"/config/ssl."+ext},
@ssl.available_key_files )
#
# If a combined file contains a non-matching cert+key, don't return it
#
new_key = do_generate_key
File.open(@domain.directory+"/config/ssl.combined","w+"){|fh| fh.write @crt.to_pem + new_key.to_pem}
assert_equal( [@domain.directory+"/config/ssl.key"],
@ssl.available_key_files )
end
def test_find_matching_certificate_and_key
#
# Initially, the combined cert should contain both the certificate and the key
#
File.open(@domain.directory+"/config/ssl.combined","w+"){|fh| fh.write @crt.to_pem+@key.to_pem}
File.open(@domain.directory+"/config/ssl.key","w+"){|fh| fh.write @crt.to_pem+@key.to_pem}
assert_equal( [@domain.directory+"/config/ssl.combined"]*2,
@ssl.find_matching_certificate_and_key )
#
# Now delete that file, and see what comes out. We expect the key to be first now.
#
FileUtils.rm_f(@domain.directory+"/config/ssl.combined")
assert_equal( [@domain.directory+"/config/ssl.key"]*2,
@ssl.find_matching_certificate_and_key )
#
# Now recreate a key which is only a key, and see if we get the correct cert returned.
#
File.open(@domain.directory+"/config/ssl.key","w+"){|fh| fh.write @key.to_pem}
File.open(@domain.directory+"/config/ssl.crt","w+"){|fh| fh.write @crt.to_pem}
assert_equal( [@domain.directory+"/config/ssl.crt", @domain.directory+"/config/ssl.key"],
@ssl.find_matching_certificate_and_key )
#
# Now generate a new key. Watch it fail
#
new_key = do_generate_key
File.open(@domain.directory+"/config/ssl.combined","w+"){|fh| fh.write @crt.to_pem + new_key.to_pem}
assert_equal( [@domain.directory+"/config/ssl.crt", @domain.directory+"/config/ssl.key"],
@ssl.find_matching_certificate_and_key )
end
def test_verify
#
# Write a combined cert
#
File.open(@domain.directory+"/config/ssl.combined","w+"){|fh| fh.write @crt.to_pem+@key.to_pem}
#
# Now make sure it verifies OK
#
assert_nothing_raised{ @ssl.certificate_file = @domain.directory+"/config/ssl.combined" }
assert_nothing_raised{ @ssl.key_file = @domain.directory+"/config/ssl.combined" }
assert_nothing_raised{ @ssl.verify }
#
# TODO: test expired certificate
#
#
# Now write a combined cert with a duff key. This should not verify.
#
File.open(@domain.directory+"/config/ssl.combined","w+"){|fh| fh.write @crt.to_pem+do_generate_key.to_pem}
assert_raise(OpenSSL::X509::CertificateError){ @ssl.verify }
#
# TODO: Work out how to do bundled verifications. Ugh.
#
end
def test_create_ssl_site
end
def test_outdated?
end