From 8116e007cfd4dbee8282247348cf45473dcde45e Mon Sep 17 00:00:00 2001 From: elijah Date: Wed, 31 Aug 2016 14:54:46 -0700 Subject: added support for Let's Encrypt --- lib/leap_cli/acme.rb | 101 ++++++++++++ lib/leap_cli/commands/ca.rb | 178 ++++++++++++++++++++- lib/leap_cli/config/manager.rb | 18 ++- lib/leap_cli/config/node_cert.rb | 4 +- lib/leap_cli/config/object.rb | 26 +++ lib/leap_cli/config/object_list.rb | 6 + lib/leap_cli/log_filter.rb | 4 +- lib/leap_cli/ssh/backend.rb | 7 +- lib/leap_cli/ssh/formatter.rb | 2 +- lib/leap_cli/ssh/scripts.rb | 10 ++ platform.rb | 2 + provider_base/common.json | 7 +- provider_base/common.rb | 72 +++++++++ puppet/modules/site_apache/files/conf.d/acme.conf | 10 ++ puppet/modules/site_apache/manifests/common.pp | 2 + .../modules/site_apache/manifests/common/acme.pp | 38 +++++ .../site_config/manifests/x509/commercial/ca.pp | 10 +- 17 files changed, 477 insertions(+), 20 deletions(-) create mode 100644 lib/leap_cli/acme.rb create mode 100644 provider_base/common.rb create mode 100644 puppet/modules/site_apache/files/conf.d/acme.conf create mode 100644 puppet/modules/site_apache/manifests/common/acme.pp diff --git a/lib/leap_cli/acme.rb b/lib/leap_cli/acme.rb new file mode 100644 index 00000000..6c7dbe98 --- /dev/null +++ b/lib/leap_cli/acme.rb @@ -0,0 +1,101 @@ +require 'openssl' +require 'acme-client' + +# +# A little bit of sugar around gem acme-client +# + +module LeapCli + class Acme + + if ENV['ACME_STAGING'] + ENDPOINT = 'https://acme-staging.api.letsencrypt.org/' + puts "using endpoint " + ENDPOINT + else + ENDPOINT = 'https://acme-v01.api.letsencrypt.org/' + end + + def initialize(domain: nil, key:) + @client = ::Acme::Client.new( + private_key: key, + endpoint: ENDPOINT, + connection_options: {request: {open_timeout: 5, timeout: 5}} + ) + @domain = domain + end + + # + # static methods + # + + def self.new_private_key + return OpenSSL::PKey::RSA.new(4096) + end + + def self.load_private_key(pem_encoded_key) + return OpenSSL::PKey::RSA.new(pem_encoded_key) + end + + def self.load_csr(pem_encoded_csr) + return OpenSSL::X509::Request.new(pem_encoded_csr) + end + + # + # instance methods + # + + # + # register a new account key with CA + # + def register(contact) + registration = @client.register(contact: 'mailto:' + contact) + if registration && registration.agree_terms + return registration + else + return false + end + end + + # + # authorize account key for domain + # + def authorize + authorization = @client.authorize(domain: @domain) + challenge = nil + begin + while true + if authorization.status == 'pending' + challenge = authorization.http01 + yield challenge + challenge.request_verification + sleep 1 + authorization.verify_status + if challenge.error + return 'error', challenge.error + end + elsif authorization.status == 'invalid' + challenge_msg = (challenge.nil? ? '' : challenge.error) + return 'error', 'Something bad happened. %s' % challenge_msg + elsif authorization.status == 'valid' + return 'valid', nil + else + challenge_msg = (challenge.nil? ? '' : challenge.error) + return 'error', 'status: %s, response message: %s' % [authorization.status, challenge_msg] + end + end + rescue Interrupt + return 'error', 'interrupted' + end + rescue ::Acme::Client::Error::Unauthorized => exc + return 'unauthorized', exc.to_s + end + + # + # get new certificate + # + def get_certificate(csr) + return @client.new_certificate(csr) + end + + end +end \ No newline at end of file diff --git a/lib/leap_cli/commands/ca.rb b/lib/leap_cli/commands/ca.rb index f998d0fe..d9ffa6a4 100644 --- a/lib/leap_cli/commands/ca.rb +++ b/lib/leap_cli/commands/ca.rb @@ -38,6 +38,7 @@ module LeapCli; module Commands cert.long_desc "Unless specified, the CSR is created for the provider's primary domain. "+ "The properties used for this CSR come from `provider.ca.server_certificates`, "+ "but may be overridden here." + cert.arg_name "DOMAIN" cert.command :csr do |csr| csr.flag 'domain', :arg_name => 'DOMAIN', :desc => 'Specify what domain to create the CSR for.' csr.flag ['organization', 'O'], :arg_name => 'ORGANIZATION', :desc => "Override default O in distinguished name." @@ -52,6 +53,23 @@ module LeapCli; module Commands generate_csr(global_options, options, args) end end + + cert.desc "Register an authorization key with the CA letsencrypt.org" + cert.long_desc "This only needs to be done once." + cert.command :register do |register| + register.action do |global, options, args| + do_register_key(global, options, args) + end + end + + cert.desc "Renews a certificate using the CA letsencrypt.org" + cert.arg_name "DOMAIN" + cert.command :renew do |renew| + renew.action do |global, options, args| + do_renew_cert(global, options, args) + end + end + end protected @@ -150,7 +168,7 @@ module LeapCli; module Commands assert_config! 'provider.ca.server_certificates.digest' server_certificates = provider.ca.server_certificates - options[:domain] ||= provider.domain + options[:domain] ||= args.first || provider.domain options[:organization] ||= provider.name[provider.default_language] options[:country] ||= server_certificates['country'] options[:state] ||= server_certificates['state'] @@ -166,4 +184,162 @@ module LeapCli; module Commands X509.create_csr_and_cert(options) end + # + # letsencrypt.org + # + + def do_register_key(global, options, args) + require 'leap_cli/acme' + assert_config! 'provider.contacts.default' + contact = manager.provider.contacts.default.first + + if file_exists?(:acme_key) && !global[:force] + bail! do + log "the authorization key for letsencrypt.org already exists" + log "run with --force if you really want to register a new key." + end + else + private_key = Acme.new_private_key + registration = nil + + log(:registering, "letsencrypt.org authorization key using contact `%s`" % contact) do + acme = Acme.new(key: private_key) + registration = acme.register(contact) + if registration + log 'success!', :color => :green, :style => :bold + else + bail! "could not register authorization key." + end + end + + log :saving, "authorization key for letsencrypt.org" do + write_file!(:acme_key, private_key.to_pem) + write_file!(:acme_info, JSON.sorted_generate({ + id: registration.id, + contact: registration.contact, + key: registration.key, + uri: registration.uri + })) + log :warning, "keep key file private!" + end + end + end + + def do_renew_cert(global, options, args) + require 'leap_cli/acme' + require 'leap_cli/ssh' + require 'socket' + require 'net/http' + + # + # sanity check the domain + # + domain = args.first + nodes = nodes_for_domain(domain) + domain_ready_for_acme!(domain) + + # + # load key material + # + assert_files_exist!([:commercial_key, domain], [:commercial_csr, domain], + :msg => 'Please create the CSR first with `leap cert csr %s`' % domain) + csr = Acme.load_csr(read_file!([:commercial_csr, domain])) + assert_files_exist!(:acme_key, + :msg => "Please run `leap cert register` first. This only needs to be done once.") + account_key = Acme.load_private_key(read_file!(:acme_key)) + + # + # check authorization for this domain + # + log :checking, "authorization" + acme = Acme.new(domain: domain, key: account_key) + status, message = acme.authorize do |challenge| + log(:uploading, 'challenge to server %s' % domain) do + SSH.remote_command(nodes) do |ssh, host| + ssh.scripts.upload_acme_challenge(challenge.token, challenge.file_content) + end + end + log :waiting, "for letsencrypt.org to verify challenge" + end + if status == 'valid' + log 'authorized!', color: :green, style: :bold + elsif status == 'error' + bail! :error, message + elsif status == 'unauthorized' + bail!(:unauthorized, message, color: :yellow, style: :bold) do + log 'You must first run `leap cert register` to register the account key with letsencrypt.org' + end + end + + log :fetching, "new certificate from letsencrypt.org" + cert = acme.get_certificate(csr) + write_file!([:commercial_cert, domain], cert.fullchain_to_pem) + end + + # + # Returns a hash of nodes that match this domain. It also checks: + # + # * a node configuration has this domain + # * the dns for the domain exists + # + # This method will bail if any checks fail. + # + def nodes_for_domain(domain) + bail! { log 'Argument DOMAIN is required' } if domain.nil? || domain.empty? + nodes = manager.nodes['dns.aliases' => domain] + if nodes.empty? + bail! :error, "There are no nodes configured for domain `%s`" % domain + end + begin + ips = Socket.getaddrinfo(domain, 'http').map {|record| record[2]}.uniq + nodes = nodes['ip_address' => ips] + if nodes.empty? + bail! do + log :error, "The domain `%s` resolves to [%s]" % [domain, ips.join(', ')] + log :error, "But there no nodes configured for this domain with these adddresses." + end + end + rescue SocketError + bail! :error, "Could not resolve the DNS for `#{domain}`. Without a DNS " + + "entry for this domain, authorization will not work." + end + return nodes + end + + # + # runs the following checks on the domain: + # + # * we are able to get /.well-known/acme-challenge/ok + # + # This method will bail if any checks fail. + # + def domain_ready_for_acme!(domain) + begin + uri = URI("https://#{domain}/.well-known/acme-challenge/ok") + options = { + use_ssl: true, + open_timeout: 5, + verify_mode: OpenSSL::SSL::VERIFY_NONE + } + Net::HTTP.start(uri.host, uri.port, options) do |http| + http.request(Net::HTTP::Get.new(uri)) do |response| + if !response.is_a?(Net::HTTPSuccess) + bail!(:error, "Could not GET %s" % uri) do + log "%s %s" % [response.code, response.message] + log "You may need to run `leap deploy`" + end + end + end + end + rescue Errno::ETIMEDOUT, Net::OpenTimeout + bail! :error, "Connection attempt timed out: %s" % uri + rescue Interrupt + bail! + rescue StandardError => exc + bail!(:error, "Could not GET %s" % uri) do + log exc.to_s + end + end + end + end; end diff --git a/lib/leap_cli/config/manager.rb b/lib/leap_cli/config/manager.rb index e39334c8..bdd5b255 100644 --- a/lib/leap_cli/config/manager.rb +++ b/lib/leap_cli/config/manager.rb @@ -405,15 +405,23 @@ module LeapCli end # - # returns a list of 'control' files for this node. - # a control file is like a service or a tag JSON file, but it contains - # raw ruby code that gets evaluated in the context of the node. - # Yes, this entirely breaks our functional programming model - # for JSON generation. + # Returns a list of 'control' files for this node. A control file is like + # a service or a tag JSON file, but it contains raw ruby code that gets + # evaluated in the context of the node. + # + # Yes, this entirely breaks our functional programming model for JSON + # generation. + # + # Control files are evaluated last, after everything else has run. # def control_files(node) files = [] [Path.provider_base, @provider_dir].each do |provider_dir| + # add common.rb + common = File.join(provider_dir, 'common.rb') + files << common if File.exist?(common) + + # add services/*.rb and tags/*.rb, as appropriate for this node [['services', :service_config], ['tags', :tag_config]].each do |attribute, path_sym| node[attribute].each do |attr_value| path = Path.named_path([path_sym, "#{attr_value}.rb"], provider_dir).sub(/\.json$/,'') diff --git a/lib/leap_cli/config/node_cert.rb b/lib/leap_cli/config/node_cert.rb index 64842ffa..da63d621 100644 --- a/lib/leap_cli/config/node_cert.rb +++ b/lib/leap_cli/config/node_cert.rb @@ -109,10 +109,10 @@ module LeapCli; module Config path = Path.relative_path([:commercial_cert, domain]) if cert.not_after < Time.now.utc Util.log :error, "the commercial certificate '#{path}' has EXPIRED! " + - "You should renew it with `leap cert csr --domain #{domain}`." + "You should renew it with `leap cert renew #{domain}`." elsif cert.not_after < Time.now.advance(:months => 2) Util.log :warning, "the commercial certificate '#{path}' will expire soon (#{cert.not_after}). "+ - "You should renew it with `leap cert csr --domain #{domain}`." + "You should renew it with `leap cert renew #{domain}`." end end end diff --git a/lib/leap_cli/config/object.rb b/lib/leap_cli/config/object.rb index b117c2f0..16c41999 100644 --- a/lib/leap_cli/config/object.rb +++ b/lib/leap_cli/config/object.rb @@ -153,6 +153,28 @@ module LeapCli end end + # + # works like Hash#store(key, value), but supports our nested dot notation, + # just like get() does. + # + def set(key, value) + key = key.to_s + # for keys with with '.' in them, we pop off the first part + # and recursively call ourselves. + if key =~ /\./ + keys = key.split('.') + parent_value = self.get!(keys.first) + if parent_value.is_a?(Config::Object) + parent_value.set(keys[1..-1].join('.'), value) + else + parent_value.store(keys[1..-1].join('.'), value) + end + else + self.store(key, value) + end + return nil + end + ## ## COPYING ## @@ -376,12 +398,16 @@ module LeapCli def fetch_value(key, context=@node) value = fetch(key, nil) if value.is_a?(String) && value =~ /^=/ + # strings prefix with '=' are evaluated as ruby code. if value =~ /^=> (.*)$/ value = evaluate_later(key, $1) elsif value =~ /^= (.*)$/ value = context.evaluate_ruby(key, $1) end self[key] = value + elsif value.is_a?(Proc) + # the value might be a proc, set by a 'control' file + self[key] = value.call end return value end diff --git a/lib/leap_cli/config/object_list.rb b/lib/leap_cli/config/object_list.rb index f9299a61..80f89d92 100644 --- a/lib/leap_cli/config/object_list.rb +++ b/lib/leap_cli/config/object_list.rb @@ -84,6 +84,12 @@ module LeapCli elsif operator == :not_equal && !value.include?(match_value) results[name] = config end + elsif match_value.is_a? Array + if operator == :equal && match_value.include?(value) + results[name] = config + elsif operator == :not_equal && !match_value.include?(value) + results[name] = config + end else if operator == :equal && value == match_value results[name] = config diff --git a/lib/leap_cli/log_filter.rb b/lib/leap_cli/log_filter.rb index 28504e1a..c73f3a91 100644 --- a/lib/leap_cli/log_filter.rb +++ b/lib/leap_cli/log_filter.rb @@ -119,14 +119,14 @@ module LeapCli { :match => /created/, :color => :green, :style => :bold }, { :match => /completed/, :color => :green, :style => :bold }, { :match => /ran/, :color => :green, :style => :bold }, - { :match => /registered/, :color => :green, :style => :bold }, + { :match => /^registered/, :color => :green, :style => :bold }, # cyan { :match => /note/, :replace => 'NOTE:', :color => :cyan, :style => :bold }, # magenta { :match => /nochange/, :replace => 'no change', :color => :magenta }, - { :match => /loading/, :color => :magenta }, + { :match => /^loading/, :color => :magenta }, ] def self.apply_message_filters(message) diff --git a/lib/leap_cli/ssh/backend.rb b/lib/leap_cli/ssh/backend.rb index c1afc993..3894d815 100644 --- a/lib/leap_cli/ssh/backend.rb +++ b/lib/leap_cli/ssh/backend.rb @@ -120,7 +120,12 @@ module LeapCli # def upload!(src, dest, options={}) if options[:mode] - super(StringIO.new(File.read(src)), dest, options) + if src.is_a?(StringIO) + content = src + else + content = StringIO.new(File.read(src)) + end + super(content, dest, options) else super(src, dest, options) end diff --git a/lib/leap_cli/ssh/formatter.rb b/lib/leap_cli/ssh/formatter.rb index bab43011..c2e386dc 100644 --- a/lib/leap_cli/ssh/formatter.rb +++ b/lib/leap_cli/ssh/formatter.rb @@ -16,7 +16,7 @@ module LeapCli } def initialize(logger, host, options={}) - @logger = logger + @logger = logger || LeapCli.new_logger @host = host @options = DEFAULT_OPTIONS.merge(options) end diff --git a/lib/leap_cli/ssh/scripts.rb b/lib/leap_cli/ssh/scripts.rb index 9fef6240..3dd6b604 100644 --- a/lib/leap_cli/ssh/scripts.rb +++ b/lib/leap_cli/ssh/scripts.rb @@ -134,6 +134,16 @@ module LeapCli ssh.execute 'cp', '/home/admin/.ssh/authorized_keys', '/root/.ssh/authorized_keys' end + # + # uploads an acme challenge for renewing certificates using Let's Encrypt CA. + # + # Filename is returned from acme api, so it must not be trusted. + # + def upload_acme_challenge(filename, content) + path = '/srv/acme/' + filename.gsub(/[^a-zA-Z0-9_-]/, '') + ssh.upload! StringIO.new(content), path, :mode => 0444 + end + private def flagize(hsh) diff --git a/platform.rb b/platform.rb index 5a286589..2ff0a27f 100644 --- a/platform.rb +++ b/platform.rb @@ -78,6 +78,8 @@ Leap::Platform.define do :client_ca_key => 'files/ca/client_ca.key', :client_ca_cert => 'files/ca/client_ca.crt', :dh_params => 'files/ca/dh.pem', + :acme_key => 'files/ca/lets-encrypt-account.key', + :acme_info => 'files/ca/lets-encrypt-account.json', :commercial_key => 'files/cert/#{arg}.key', :commercial_csr => 'files/cert/#{arg}.csr', :commercial_cert => 'files/cert/#{arg}.crt', diff --git a/provider_base/common.json b/provider_base/common.json index e9531eee..622bca38 100644 --- a/provider_base/common.json +++ b/provider_base/common.json @@ -29,12 +29,7 @@ "x509": { "use": true, "use_commercial": false, - "cert": "= x509.use ? file(:node_x509_cert, :missing => 'x509 certificate for node $node. Run `leap cert update`') : nil", - "key": "= x509.use ? file(:node_x509_key, :missing => 'x509 key for node $node. Run `leap cert update`') : nil", - "ca_cert": "= try_file :ca_cert", - "commercial_cert": "= x509.use_commercial ? file([:commercial_cert, try{webapp.domain}||domain.full_suffix], :missing => 'commercial x509 certificate for node $node. Add file $file, or run `leap cert csr --domain %s` to generate a temporary self-signed cert and CSR you can use to purchase a real cert.' % (try{webapp.domain}||domain.full_suffix)) : nil", - "commercial_key": "= x509.use_commercial ? file([:commercial_key, try{webapp.domain}||domain.full_suffix], :missing => 'commercial x509 certificate for node $node. Add file $file, or run `leap cert csr --domain %s` to generate a temporary self-signed cert and CSR you can use to purchase a real cert.' % (try{webapp.domain}||domain.full_suffix)) : nil", - "commercial_ca_cert": "= x509.use_commercial ? try_file(:commercial_ca_cert) : nil" + "ca_cert": "= try_file :ca_cert" }, "service_type": "internal_service", "development": { diff --git a/provider_base/common.rb b/provider_base/common.rb new file mode 100644 index 00000000..a8cc6717 --- /dev/null +++ b/provider_base/common.rb @@ -0,0 +1,72 @@ +## +## common.rb -- evaluated (last) for every node. +## +## Because common.rb is evaluated last, it is good practice to only modify +## values here if they are empty. This gives a chance for tags and services +## to set values. +## + +# +# X509 server certificates that use our own CA +# + +if self['x509.use'] + if self['x509.cert'].nil? + self.set('x509.cert', lambda{file( + :node_x509_cert, + :missing => "x509 certificate for node $node. Run `leap cert update` to generate it." + )}) + end + if self['x509.key'].nil? + self.set('x509.key', lambda{file( + :node_x509_key, + :missing => "x509 key for node $node. Run `leap cert update` to generate it." + )}) + end +else + self.set('x509.cert', nil) + self.set('x509.key', nil) +end + +# +# X509 server certificates that use an external CA +# + +if self['x509.use_commercial'] + domain = self['webapp.domain'] || self['domain.full_suffix'] + if self['x509.commercial_cert'].nil? + self.set('x509.commercial_cert', lambda{file( + [:commercial_cert, domain], + :missing => "commercial x509 certificate for node `$node`. " + + "Add file $file, or run `leap cert csr %s`." % domain + )}) + end + if self['x509.commercial_key'].nil? + self.set('x509.commercial_key', lambda{file( + [:commercial_key, domain], + :missing => "commercial x509 key for node `$node`. " + + "Add file $file, or run `leap cert csr %s`" % domain + )}) + end + + # + # the content of x509.commercial_cert might include the cert + # and the full CA chain, or it might just be the cert only. + # + # if it is the cert only, then we want to additionally specify + # 'commercial_ca_cert'. Otherwise, we leave this empty. + # + if self['x509.commercial_ca_cert'].nil? + self.set('x509.commercial_ca_cert', lambda{ + if self['x509.commercial_cert'].scan(/BEGIN CERTIFICATE/).length == 1 + try_file(:commercial_ca_cert) + else + nil + end + }) + end +else + self.set('x509.commercial_cert', nil) + self.set('x509.commercial_key', nil) + self.set('x509.commercial_ca_cert', nil) +end diff --git a/puppet/modules/site_apache/files/conf.d/acme.conf b/puppet/modules/site_apache/files/conf.d/acme.conf new file mode 100644 index 00000000..cdddf53e --- /dev/null +++ b/puppet/modules/site_apache/files/conf.d/acme.conf @@ -0,0 +1,10 @@ +# +# Allow ACME certificate verification if /srv/acme exists. +# + + Alias "/.well-known/acme-challenge/" "/srv/acme/" + + Require all granted + Header set Content-Type "application/jose+json" + + diff --git a/puppet/modules/site_apache/manifests/common.pp b/puppet/modules/site_apache/manifests/common.pp index 8a11759a..208c15d5 100644 --- a/puppet/modules/site_apache/manifests/common.pp +++ b/puppet/modules/site_apache/manifests/common.pp @@ -27,4 +27,6 @@ class site_apache::common { } include site_apache::common::tls + include site_apache::common::acme + } diff --git a/puppet/modules/site_apache/manifests/common/acme.pp b/puppet/modules/site_apache/manifests/common/acme.pp new file mode 100644 index 00000000..eda4148b --- /dev/null +++ b/puppet/modules/site_apache/manifests/common/acme.pp @@ -0,0 +1,38 @@ +# +# Allows for potential ACME validations (aka Let's Encrypt) +# +class site_apache::common::acme { + # + # well, this doesn't work: + # + # apache::config::global {'acme.conf':} + # + # since /etc/apache2/conf.d is NEVER LOADED BY APACHE + # https://gitlab.com/shared-puppet-modules-group/apache/issues/11 + # + + file { + '/etc/apache2/conf-available/acme.conf': + ensure => present, + source => 'puppet:///modules/site_apache/conf.d/acme.conf', + require => Package[apache], + notify => Service[apache]; + '/etc/apache2/conf-enabled/acme.conf': + ensure => link, + target => '/etc/apache2/conf-available/acme.conf', + require => Package[apache], + notify => Service[apache]; + } + + file { + '/srv/acme': + ensure => 'directory', + owner => 'www-data', + group => 'www-data', + mode => '0755'; + '/srv/acme/ok': + owner => 'www-data', + group => 'www-data', + content => 'ok'; + } +} diff --git a/puppet/modules/site_config/manifests/x509/commercial/ca.pp b/puppet/modules/site_config/manifests/x509/commercial/ca.pp index c76a9dbb..21d57445 100644 --- a/puppet/modules/site_config/manifests/x509/commercial/ca.pp +++ b/puppet/modules/site_config/manifests/x509/commercial/ca.pp @@ -5,7 +5,13 @@ class site_config::x509::commercial::ca { $x509 = hiera('x509') $ca = $x509['commercial_ca_cert'] - x509::ca { $site_config::params::commercial_ca_name: - content => $ca + # + # CA cert might be empty, if it was bundled with 'commercial_cert' + # instead of specified separately. + # + if ($ca) { + x509::ca { $site_config::params::commercial_ca_name: + content => $ca + } } } -- cgit v1.2.3