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

common: Add letsencrypt library support

parent 632fd19c
......@@ -3,14 +3,14 @@ Section: web
Priority: extra
Maintainer: James Carter <jcarter@bytemark.co.uk>
Uploaders: Patrick J Cherry <patrick@bytemark.co.uk>, Steve Kemp <steve@bytemark.co.uk>
Build-Depends: debhelper (>= 7.0.0), gem2deb, txt2man, ruby-mocha
Build-Depends: debhelper (>= 7.0.0), gem2deb, txt2man, ruby-mocha, ruby-webmock, ruby-acme-client
Standards-Version: 3.9.6
XS-Ruby-Versions: all
Package: symbiosis-common
Architecture: all
XB-Ruby-Versions: ${ruby:Versions}
Depends: ruby | ruby-interpreter, ruby-linux-netlink, ruby-cracklib, ruby-erubis, ruby-mocha, openssl, sudo, adduser, cracklib-runtime, ssl-cert, make, ${misc:Depends}
Depends: ruby | ruby-interpreter, ruby-acme-client, ruby-linux-netlink, ruby-cracklib, ruby-erubis, ruby-mocha, openssl, sudo, adduser, cracklib-runtime, ssl-cert, make, ${misc:Depends}
Replaces: symbiosis-firewall (<< 2011:1214), symbiosis-range, symbiosis-test, bytemark-vhost-range, bytemark-vhost-test, symbiosis-crack
Breaks: symbiosis-firewall (<< 2011:1214), symbiosis-email (<< 2012:0215)
Conflicts: symbiosis-range, symbiosis-test, symbiosis-crack, bytemark-vhost-range, bytemark-vhost-test, symbiosis-email (<< 2012:0215)
......
module Symbiosis
class SSL
PROVIDERS = []
end
end
require 'symbiosis/ssl'
require 'symbiosis/domain'
require 'symbiosis/domain/ssl'
begin
require 'symbiosis/domain/http'
rescue LoadError
# Do nothing
end
require 'symbiosis/host'
require 'symbiosis/utils'
require 'time'
require 'acme-client'
module Symbiosis
class SSL
class LetsEncrypt
include Symbiosis::Utils
ENDPOINT = "https://acme-v01.api.letsencrypt.org/directory"
attr_reader :config, :domain
def initialize(domain)
@domain = domain
@prefix = domain.prefix
@names = ([domain.name] + domain.aliases).uniq
@config = {}
end
#
# Returns the client instance.
#
def client
@client ||= Acme::Client.new(private_key: self.account_key, endpoint: self.endpoint)
end
#
# This returns a list of configuration directories.
#
# TODO: This is probably a site-wide thing.
#
def config_dirs
return @config_dirs if @config_dirs
paths = [ File.join(self.domain.config_dir,"letsencrypt") ]
#
# This last path is the default one that gets created.
#
if ENV["HOME"]
paths << File.join(ENV["HOME"],".symbiosis", "letsencrypt")
end
paths << "/etc/symbiosis/config/letsencrypt"
@config_dirs = paths.reject{|p| !File.directory?(p) }
if @config_dirs.empty?
mkdir_p(paths.first, {:uid => self.domain.uid, :gid => self.domain.gid})
@config_dirs << paths.first
end
@config_dirs
end
def config_dir
File.join(self.config_dirs.first, self.domain.name)
end
#
# Reads and returns the LetsEncrypt configuration
#
def config
return @config unless @config.empty?
@config = {:email => nil, :server => nil, :rsa_key_size => nil, :docroot => nil, :account_key => nil}
@config.each do |param, value|
@config[param] = get_param_with_dir_stack(param.to_s, self.config_dirs)
end
@config
end
#
# This returns the rsa_key_size. Defaults to 2048.
#
def rsa_key_size
return @config[:rsa_key_size] if @config[:rsa_key_size].is_a?(Integer) and @config[:rsa_key_size] >= 2048
rsa_key_size = nil
if self.config[:rsa_key_size].is_a?(String)
begin
rsa_key_size = Integer(self.config[:rsa_key_size])
rescue ArgumentError
# do nothing, but maybe we should warn.
end
end
#
# Default to 2048
#
if !rsa_key_size.is_a?(Integer) or rsa_key_size <= 2048
rsa_key_size = 2048
end
@config[:rsa_key_size] = rsa_key_size
end
#
# Returns the account key. If one has not been set, it generates and
# writes it to the configuration directory.
#
def account_key
return @config[:account_key] if @config[:account_key].is_a?(OpenSSL::PKey::RSA)
if self.config[:account_key].is_a? String
account_key = OpenSSL::PKey::RSA.new(self.config[:account_key])
else
account_key = OpenSSL::PKey::RSA.new(self.rsa_key_size)
set_param( "account_key", account_key.to_pem, self.config_dirs.first, :mode => 0600)
end
@config[:account_key] = account_key
end
#
# Returns the document root for the HTTP01 challenge
#
def docroot
return self.config[:docroot] if self.config[:docroot].is_a?(String) and File.directory?(self.config[:docroot])
#
# If symbiosis-http is installed, we use htdocs dir, otherwise default to public/htdocs.
#
if self.domain.respond_to?(:htdocs_dir)
@config[:docroot] = self.domain.htdocs_dir
else
@config[:docroot] = File.join(domain.directory, "public", "htdocs")
end
@config[:docroot]
end
#
# Returns the account's email address, defaulting to root@fqdn if nothing set.
#
def email
return self.config[:email] if self.config[:email].is_a?(String)
@config[:email] = "root@"+Symbiosis::Host.fqdn
end
#
# Returns the default endpoint, defaulting to the live endpoint
#
def endpoint
return self.config[:endpoint] if self.config[:endpoint].is_a?(String)
@config[:endpoint] = ENDPOINT
end
#
# Register the account RSA kay with the letsencrypt server
#
def register
#
# Send the key to the server.
#
registration = self.client.register(contact: 'mailto:'+self.email)
#
# Should probably check we accept the terms.
#
registration.agree_terms
true
end
alias :registered? :register
#
# Verifies all the names for a domain
#
def verify(names = @names)
names.map do |name|
self.verify_name(name)
end.all?
end
alias :verified? :verify
#
# This does the authorization. Returns true if the verification succeeds.
#
def verify_name(name)
#
# Set up the authorisation for the http01 challenge
#
authorisation = self.client.authorize(domain: name)
challenge = authorisation.http01
mkdir_p(File.join(self.docroot, File.dirname(challenge.filename)))
set_param(challenge.file_content,
File.basename(challenge.filename),
File.join(self.docroot, File.dirname(challenge.filename)))
if challenge.request_verification
20.times do
sleep(0.5)
break if challenge.verify_status == "valid"
end
challenge.verify_status == "valid"
else
false
end
end
def rsa_key
return @rsa_key if @rsa_key.is_a?(OpenSSL::PKey::RSA)
#
# Generate our expire our request, and generate the key.
#
@request = nil
@rsa_key = OpenSSL::PKey::RSA.new(self.rsa_key_size)
end
alias :key :rsa_key
def request(key = self.key, verify_names = true)
return @request if @request.is_a?(OpenSSL::X509::Request)
@acme_certificate = nil
#
# Here's the request.
#
request = OpenSSL::X509::Request.new
request.public_key = key.public_key
#
# Stick the domain name in
#
request.subject = OpenSSL::X509::Name.new([
['CN', self.domain.name, OpenSSL::ASN1::UTF8STRING]
])
#
# Add in our X509v3 extensions.
#
exts = []
ef = OpenSSL::X509::ExtensionFactory.new
#
# OK here we want to verify each domain before adding them to the cert
#
if verify_names
names = @names.reject{|name| !self.verify_name(name)}
else
names = @names
end
#
# Use the subjectAltName if one has been given. This is for SNI, i.e. SSL
# name-based virtual hosting (ish).
#
exts << ef.create_extension(
"subjectAltName",
names.collect{|a| "DNS:#{a}" }.join(","),
false
)
#
# Wrap our extension in a Set and Sequence
#
attrval = OpenSSL::ASN1::Set([OpenSSL::ASN1::Sequence(exts)])
request.add_attribute(OpenSSL::X509::Attribute.new("extReq", attrval))
request.sign(key, OpenSSL::Digest::SHA256.new)
@request = request
end
alias :verify_and_request_certificate! :request
def acme_certificate(request = self.request)
return @acme_certificate if @acme_certificate.is_a?(Acme::Certificate)
acme_certificate = client.new_certificate(request)
if acme_certificate.is_a?(Acme::Certificate)
@acme_certificate = acme_certificate
else
@acme_certificate = nil
end
@acme_certificate
end
#
# Returns the signed X509 certificate for the request.
#
def certificate(request = self.request)
if self.acme_certificate(request).is_a?(Acme::Certificate)
self.acme_certificate.x509
else
nil
end
end
#
# Returns the CA bundle as an array
#
def bundle(request = self.request)
if self.acme_certificate(request).is_a?(Acme::Certificate)
self.acme_certificate.x509_chain
else
[]
end
end
end
PROVIDERS << LetsEncrypt
end
class Domain
def ssl_from_letsencrypt(endpoint = nil)
client = Symbiosis::SSL::LetsEncrypt.new(self)
client.config()[:endpoint] = endpoint if endpoint
client.register
client.verify
client.certificate
store = self.ssl_certificate_store
store.add_cert(client.bundle.last) unless client.bundle.empty?
self.ssl_verify(client.certificate, client.key, store, true)
issue = nil
Dir.glob(File.join(client.config_dirs.last, self.name, "*")).each do |d|
next unless File.directory?(d)
d = File.basename(d)
if d =~ /^\d+$/
issue = Integer(d)
end
end
end
end
end
$:.unshift "../lib/" if File.directory?("../lib")
require 'test/unit'
require 'tmpdir'
if RUBY_VERSION =~ /^[2-9]\./
require 'symbiosis/ssl/letsencrypt'
end
require 'webmock/test_unit'
class SSLLetsEncryptTest < Test::Unit::TestCase
def setup
omit "acme-client requires ruby > 2.0" unless defined? Symbiosis::SSL::LetsEncrypt
@prefix = Dir.mktmpdir("srv")
@prefix.freeze
@domain = Symbiosis::Domain.new(nil, @prefix)
@domain.create
@endpoint = "https://imaginary.test.endpoint:443"
@http01_challenge = {} # This is where we store our challenges
@authz_template = Addressable::Template.new "#{@endpoint}/acme/authz/{sekrit}/0"
#
# Stub requests to our imaginary endpoint
#
stub_request(:head, /.*/).to_return{|r| do_head(r)}
stub_request(:post, "#{@endpoint}/acme/new-reg").to_return{|r| do_post_new_reg(r)}
stub_request(:post, "#{@endpoint}/acme/new-authz").to_return{|r| do_post_new_authz(r)}
stub_request(:post, @authz_template).to_return{|r| do_post_authz(r)}
stub_request(:get, @authz_template).to_return{|r| do_get_authz(r)}
stub_request(:post, "#{@endpoint}/acme/new-cert").to_return{|r| do_post_new_cert(r)}
stub_request(:get, "#{@endpoint}/bundle").to_return{|r| do_get_bundle(r)}
@client = Symbiosis::SSL::LetsEncrypt.new(@domain)
@client.config()[:endpoint] = @endpoint
end
def teardown
unless $DEBUG
@domain.destroy if @domain.is_a?( Symbiosis::Domain)
FileUtils.rm_rf(@prefix) if @prefix and File.directory?(@prefix)
end
end
#####
#
# Helper methods
#
#####
def setup_root_ca
#
# Our root CA.
#
root_ca_path = File.expand_path(File.join(File.dirname(__FILE__), "RootCA"))
root_ca_crt_file = File.join(root_ca_path, "RootCA.crt")
root_ca_key_file = File.join(root_ca_path, "RootCA.key")
@root_ca_crt = OpenSSL::X509::Certificate.new(File.read(root_ca_crt_file))
@root_ca_key = OpenSSL::PKey::RSA.new(File.read(root_ca_key_file))
end
def do_head(request)
{:status => 405, :headers => {"Replay-Nonce" => Symbiosis::Utils.random_string(20)}}
end
def do_post_new_reg(request)
{:status => 201,
:headers => {
"Location" => "#{@endpoint}/acme/reg/asdf",
"Link" => "<#{@endpoint}/acme/new-authz>;rel=\"next\",<#{@endpoint}/acme/terms>;rel=\"terms-of-service\""
}
}
end
def do_post_new_authz(request)
req = JSON.load(request.body)
payload = JSON.load(UrlSafeBase64.decode64(req["payload"]))
sekrit = Symbiosis::Utils.random_string(20).downcase
@http01_challenge[sekrit] = {
"type" => "http-01",
"uri" => "#{@endpoint}/acme/authz/#{sekrit}/0",
"token" => Symbiosis::Utils.random_string(20)
}
response_payload = {
"status" => "pending",
"identifier" => payload["identifier"],
"challenges" => [ @http01_challenge[sekrit] ],
"combinations" => [[0]]
}
{:status => 201, :body => JSON.dump(response_payload), :headers => {"Content-Type" => "application/json", "Location" => "#{@endpoint}/acme/authz/#{sekrit}", "Link" => "<#{@endpoint}/acme/new-authz>;rel=\"next\""}}
end
def do_post_authz(request)
req = JSON.load(request.body)
payload = JSON.load(UrlSafeBase64.decode64(req["payload"]))
sekrit = @authz_template.extract(request.uri)["sekrit"]
@http01_challenge[sekrit].merge!({
"keyAuthorization" => payload["keyAuthorization"],
"status" => "pending" })
{:status => 200, :body => JSON.dump(@http01_challenge[sekrit]), :headers => {"Content-Type" => "application/json"}}
end
def do_get_authz(request)
sekrit = @authz_template.extract(request.uri)["sekrit"]
@http01_challenge[sekrit].merge!({
"status" => "valid",
"validated" => Time.now,
"expires" => (Date.today + 90) })
{:status => 200, :body => JSON.dump(@http01_challenge[sekrit]), :headers => {"Content-Type" => "application/json"}}
end
def do_post_new_cert(request)
req = JSON.load(request.body)
payload = JSON.load(UrlSafeBase64.decode64(req["payload"]))
csr = OpenSSL::X509::Request.new(UrlSafeBase64.decode64(payload["csr"]))
setup_root_ca
crt = OpenSSL::X509::Certificate.new
crt.subject = csr.subject
crt.issuer = @root_ca_crt.subject
crt.public_key = csr.public_key
crt.not_before = Time.now
crt.not_after = Time.now + 90*86400
crt.serial = Time.now.to_i
crt.version = 2
#
# Add in our X509v3 extensions.
#
ef = OpenSSL::X509::ExtensionFactory.new
ef.subject_certificate = crt
ef.issuer_certificate = @root_ca_crt
crt.extensions = [
ef.create_extension("basicConstraints","CA:FALSE", true),
ef.create_extension("subjectKeyIdentifier", "hash"),
ef.create_extension("authorityKeyIdentifier", "keyid:always,issuer:always")
]
#
# Add all the other extension requests.
#
ext_req = csr.attributes.find{|a| a.oid == "extReq" }
ext_req.value.first.value.each do |ext|
crt.add_extension(OpenSSL::X509::Extension.new(ext))
end
crt.sign(@root_ca_key, OpenSSL::Digest::SHA256.new)
{:status => 200, :body => crt.to_s, :headers => {"link" => "<#{@endpoint}/bundle>;rel=\"up\""}}
end
def do_get_bundle(r)
setup_root_ca
{:status => 200, :body => @root_ca_crt}
end
####
#
# Tests start here.
#
#####
def test_register
omit unless @client
result = nil
result = @client.register
assert(result, "#register should return true")
end
def test_verify
omit unless @client
result = nil
result = @client.verify
assert(result, "#verify should return true")
end
def test_request
omit unless @client
req = nil
req = @client.request
assert_kind_of(OpenSSL::X509::Request, req)
assert_equal("/CN=#{@domain.name}", req.subject.to_s)
#
# Now test the altname stuff
#
ext_req = req.attributes.find{|a| a.oid == "extReq" }
extensions = []
ext_req.value.first.value.each do |ext|
extensions << OpenSSL::X509::Extension.new(ext)
end
san_ext = extensions.find{|e| "subjectAltName" == e.oid}
assert_kind_of(OpenSSL::X509::Extension, san_ext, "subjectAltName missing from CSR")
san_domains = san_ext.value.split(/[,\s]+/).map{|n| n.sub(/^DNS:(.+)$/,'\1')}
expected_domains = ([@domain.name] + @domain.aliases).uniq