diff options
Diffstat (limited to 'code/environments/production/modules/certregen/lib')
12 files changed, 505 insertions, 0 deletions
diff --git a/code/environments/production/modules/certregen/lib/facter/has_puppet.rb b/code/environments/production/modules/certregen/lib/facter/has_puppet.rb new file mode 100644 index 0000000..05f2e80 --- /dev/null +++ b/code/environments/production/modules/certregen/lib/facter/has_puppet.rb @@ -0,0 +1,10 @@ +Facter.add(:has_puppet) do + setcode do + begin + require 'puppet' + true + rescue LoadError + false + end + end +end diff --git a/code/environments/production/modules/certregen/lib/facter/hostcrl.rb b/code/environments/production/modules/certregen/lib/facter/hostcrl.rb new file mode 100644 index 0000000..1d69a66 --- /dev/null +++ b/code/environments/production/modules/certregen/lib/facter/hostcrl.rb @@ -0,0 +1,4 @@ +Facter.add(:hostcrl) do + confine :has_puppet => true + setcode { Puppet[:hostcrl] } +end diff --git a/code/environments/production/modules/certregen/lib/facter/localcacert.rb b/code/environments/production/modules/certregen/lib/facter/localcacert.rb new file mode 100644 index 0000000..278ca8b --- /dev/null +++ b/code/environments/production/modules/certregen/lib/facter/localcacert.rb @@ -0,0 +1,4 @@ +Facter.add(:localcacert) do + confine :has_puppet => true + setcode { Puppet[:localcacert] } +end diff --git a/code/environments/production/modules/certregen/lib/puppet/application/certregen.rb b/code/environments/production/modules/certregen/lib/puppet/application/certregen.rb new file mode 100644 index 0000000..73d6ca2 --- /dev/null +++ b/code/environments/production/modules/certregen/lib/puppet/application/certregen.rb @@ -0,0 +1,4 @@ +require 'puppet/application/face_base' + +class Puppet::Application::Certregen < Puppet::Application::FaceBase +end diff --git a/code/environments/production/modules/certregen/lib/puppet/face/certregen.rb b/code/environments/production/modules/certregen/lib/puppet/face/certregen.rb new file mode 100644 index 0000000..24c4b30 --- /dev/null +++ b/code/environments/production/modules/certregen/lib/puppet/face/certregen.rb @@ -0,0 +1,205 @@ +require 'puppet/face' +require 'puppet_x/certregen/ca' +require 'puppet_x/certregen/certificate' +require 'puppet_x/certregen/crl' +require 'puppet/feature/chloride' + +Puppet::Face.define(:certregen, '0.1.0') do + copyright "Puppet", 2016 + summary "Regenerate the Puppet CA and client certificates" + + description <<-EOT + This subcommand provides tools for monitoring the health of the Puppet CA, regenerating + expiring CA certificates, and remediation for expired CA certificates. + EOT + + action(:ca) do + summary "Refresh the Puppet CA certificate and CRL" + + option('--ca_serial SERIAL') do + summary 'The serial number (in hexadecimal) of the CA to rotate.' + end + + when_invoked do |opts| + cert = Puppet::Face[:certregen, :current].cacert(:ca_serial => opts[:ca_serial]) + crl = Puppet::Face[:certregen, :current].crl() + [cert, crl] + end + + when_rendering :console do |(cert, crl)| + "CA expiration is now #{cert.content.not_after}\n" + \ + "CRL next update is now #{crl.content.next_update}" + end + end + + action(:cacert) do + summary "Regenerate the Puppet CA certificate" + + description <<-EOT + This subcommand generates a new CA certificate that can replace the existing CA certificate. + The new CA certificate uses the same subject as the current CA certificate and reuses the + key pair associated with the current CA certificate, so all certificates signed by the old + CA certificate will remain valid. + EOT + + option('--ca_serial SERIAL') do + summary 'The serial number (in hexadecimal) of the CA to rotate.' + end + + when_invoked do |opts| + ca = PuppetX::Certregen::CA.setup + + current_ca_serial = ca.host.certificate.content.serial.to_s(16) + if opts[:ca_serial].nil? + raise "The serial number of the CA certificate to rotate must be provided. If you " \ + "are sure that you want to rotate the CA certificate, rerun this command with " \ + "--ca_serial #{current_ca_serial}" + elsif opts[:ca_serial] != current_ca_serial + raise "The serial number of the current CA certificate (#{current_ca_serial}) "\ + "does not match the serial number given on the command line (#{opts[:ca_serial]}). "\ + "If you are sure that you want to rotate the CA certificate, rerun this command with "\ + "--ca_serial #{current_ca_serial}" + end + + PuppetX::Certregen::CA.backup + PuppetX::Certregen::CA.regenerate(ca) + Puppet::SSL::Certificate.indirection.find(Puppet::SSL::CA_NAME) + end + + when_rendering(:console) do |cert| + "CA expiration is now #{cert.content.not_after}" + end + end + + action(:crl) do + summary 'Update the lastUpdate and nextUpdate field for the CA CRL' + + when_invoked do |opts| + ca = PuppetX::Certregen::CA.setup + PuppetX::Certregen::CRL.refresh(ca) + end + + when_rendering(:console) do |crl| + "CRL next update is now #{crl.content.next_update}" + end + end + + action(:healthcheck) do + summary "Check for expiring certificates" + + description <<-EOT + This subcommand checks for certificates that are nearing or past expiration. + EOT + + option('--all') do + summary "Report certificate expiry for all nodes, including nodes that aren't near expiration." + end + + when_invoked do |opts| + ca = PuppetX::Certregen::CA.setup + + certs = Puppet::SSL::Certificate.indirection.search('*').select do |cert| + opts[:all] || PuppetX::Certregen::Certificate.expiring?(cert) + end + + cacert = ca.host.certificate + certs << cacert if (opts[:all] || PuppetX::Certregen::Certificate.expiring?(cacert)) + + certs.sort { |a, b| a.content.not_after <=> b.content.not_after } + end + + when_rendering :console do |certs| + if certs.empty? + "No certificates are approaching expiration." + else + certs.map do |cert| + str = "#{cert.name.inspect} #{cert.digest.to_s}\n" + expiry = PuppetX::Certregen::Certificate.expiry(cert) + str << "Status: #{expiry[:status]}\n" + str << "Expiration date: #{expiry[:expiration_date]}\n" + if expiry[:expires_in] + str << "Expires in: #{expiry[:expires_in]}\n" + end + str + end + end + end + + when_rendering :pson do |certs| + certs.map do |cert| + { + :name => cert.name, + :digest => cert.digest.to_s, + :expiry => PuppetX::Certregen::Certificate.expiry(cert) + } + end + end + + when_rendering :yaml do |certs| + certs.map do |cert| + { + :name => cert.name, + :digest => cert.digest.to_s, + :expiry => PuppetX::Certregen::Certificate.expiry(cert) + } + end + end + end + + action(:redistribute) do + summary "Redistribute the regenerated CA certificate and CRL to nodes in PuppetDB" + + description <<-EOT + Redistribute the regenerated CA certificate and CRL to active nodes in PuppetDB. This command is + only necessary if the CA certificate is expired and a new CA certificate needs to be manually + distributed via SSH. + + This subcommand depends on the `chloride` gem, which is not included with this Puppet face. + + Distributing the CA certificate via SSH requires either a private ssh key (given by the + `--ssh_key_file` flag) or entering the password when prompted. If password auth is used, + the `highline` gem should be installed so that the entered password is not echoed to the + terminal. + EOT + + option('--username USER') do + summary "The username to use when logging into the remote machine" + end + + option('--ssh_key_file FILE') do + summary "The SSH key file to use for authentication" + default_to { "~/.ssh/id_rsa" } + end + + when_invoked do |opts| + unless Puppet.features.chloride? + raise "Unable to distribute CA certificate: the chloride gem is not available." + end + + config = {} + + config.merge!(username: opts[:username]) if opts[:username] + config.merge!(ssh_key_file: File.expand_path(opts[:ssh_key_file])) if opts[:ssh_key_file] + + ca = PuppetX::Certregen::CA.setup + cacert = ca.host.certificate + if PuppetX::Certregen::Certificate.expiring?(cacert) + Puppet.err "Refusing to distribute CA certificate: certificate is pending expiration." + exit 1 + end + + rv = {succeeded: [], failed: []} + PuppetX::Certregen::CA.certnames.each do |certname| + begin + PuppetX::Certregen::CA.distribute(certname, config) + rv[:succeeded] << certname + rescue => e + Puppet.log_exception(e) + rv[:failed] << certname + end + end + + rv + end + end +end diff --git a/code/environments/production/modules/certregen/lib/puppet/feature/chloride.rb b/code/environments/production/modules/certregen/lib/puppet/feature/chloride.rb new file mode 100644 index 0000000..ea777cb --- /dev/null +++ b/code/environments/production/modules/certregen/lib/puppet/feature/chloride.rb @@ -0,0 +1,3 @@ +require 'puppet/util/feature' + +Puppet.features.add(:chloride, libs: 'chloride') diff --git a/code/environments/production/modules/certregen/lib/puppet/functions/is_classified_with.rb b/code/environments/production/modules/certregen/lib/puppet/functions/is_classified_with.rb new file mode 100644 index 0000000..0f6d54b --- /dev/null +++ b/code/environments/production/modules/certregen/lib/puppet/functions/is_classified_with.rb @@ -0,0 +1,9 @@ +Puppet::Functions.create_function(:is_classified_with) do + dispatch :is_classified_with do + param 'String', :str + end + + def is_classified_with(str) + closure_scope.find_global_scope.compiler.node.classes.keys.include?(str.to_s) + end +end diff --git a/code/environments/production/modules/certregen/lib/puppet/parser/functions/is_classified_with.rb b/code/environments/production/modules/certregen/lib/puppet/parser/functions/is_classified_with.rb new file mode 100644 index 0000000..1e17887 --- /dev/null +++ b/code/environments/production/modules/certregen/lib/puppet/parser/functions/is_classified_with.rb @@ -0,0 +1,4 @@ +Puppet::Parser::Functions::newfunction(:is_classified_with, :arity => 1, + :type => :rvalue) do |(str)| + compiler.node.classes.keys.include?(str) +end diff --git a/code/environments/production/modules/certregen/lib/puppet_x/certregen/ca.rb b/code/environments/production/modules/certregen/lib/puppet_x/certregen/ca.rb new file mode 100644 index 0000000..c9e7457 --- /dev/null +++ b/code/environments/production/modules/certregen/lib/puppet_x/certregen/ca.rb @@ -0,0 +1,137 @@ +require 'securerandom' +require 'shellwords' + +require 'puppet' +require 'puppet/util/execution' +require 'puppet/util/package' + +require 'puppet/feature/chloride' + +module PuppetX + module Certregen + module CA + module_function + + def setup + Puppet::SSL::Host.ca_location = :only + Puppet.settings.preferred_run_mode = "master" + + if !Puppet::SSL::CertificateAuthority.ca? + raise "Unable to set up CA: this node is not a CA server." + end + + if Puppet::SSL::Certificate.indirection.find('ca').nil? + raise "Unable to set up CA: the CA certificate is not present." + end + + Puppet::SSL::CertificateAuthority.instance + end + + def backup + cacert_backup_path = File.join(Puppet[:cadir], "ca_crt.#{Time.now.to_i}.pem") + Puppet.notice("Backing up current CA certificate to #{cacert_backup_path}") + FileUtils.cp(Puppet[:cacert], cacert_backup_path) + end + + # Generate an updated CA certificate with the same subject as the existing CA certificate + # and synchronize the new CA certificate with the local CA certificate. + def regenerate(ca, cert = Puppet::SSL::Certificate.indirection.find("ca")) + Puppet[:ca_name] = cert.content.subject.to_a[0][1] + + request = Puppet::SSL::CertificateRequest.new(Puppet::SSL::Host::CA_NAME) + request.generate(ca.host.key) + PuppetX::Certregen::CA.sign(ca, Puppet::SSL::CA_NAME, + {allow_dns_alt_names: false, self_signing_csr: request}) + FileUtils.cp(Puppet[:cacert], Puppet[:localcacert]) + end + + # Copy the current CA certificate and CRL to the given host. + # + # @note Only Linux systems are supported and requires that the localcacert/hostcrl setting on the + # given host is the default path. + # + # @param [String] hostname The host to copy the CA cert to + # @param [Hash] config the Chloride host config + # @return [void] + def distribute(hostname, config) + host = Chloride::Host.new(hostname, config) + host.ssh_connect + + Puppet.debug("SSH status for #{hostname}: #{host.ssh_status}") + + log_events = lambda do |event| + event.data[:messages].each do |data| + Puppet.info "[#{data.severity}:#{data.hostname}]: #{data.message.inspect}" + end + end + + distribute_cacert(host, log_events) + distribute_crl(host, log_events) + end + + def distribute_cacert(host, blk) + src = Puppet[:cacert] + dst ='/etc/puppetlabs/puppet/ssl/certs/ca.pem' # @todo: query node for localcacert + distribute_file(host, src, dst, blk) + end + + def distribute_crl(host, blk) + src = Puppet[:cacrl] + dst ='/etc/puppetlabs/puppet/ssl/crl.pem' # @todo: query node for hostcrl + distribute_file(host, src, dst, blk) + end + + def distribute_file(host, src, dst, blk) + tmp = "#{File.basename(src)}.tmp.#{SecureRandom.uuid}" + + copy_action = Chloride::Action::FileCopy.new(to_host: host, from: src, to: tmp) + copy_action.go(&blk) + if copy_action.success? + Puppet.info "Copied #{src} to #{host.hostname}:#{tmp}" + else + raise "Failed to copy #{src} to #{host.hostname}:#{tmp}: #{copy_action.status}" + end + + move_action = Chloride::Action::Execute.new(host: host, cmd: "cp #{tmp} #{dst}", sudo: true) + move_action.go(&blk) + + if move_action.success? + Puppet.info "Updated #{host.hostname}:#{dst}" + else + raise "Failed to copy #{tmp} to #{host.hostname}:#{dst}" + end + + end + + + # Enumerate Puppet nodes without relying on PuppetDB + # + # If the Puppet CA certificate has expired we cannot rely on PuppetDB working + # or being able to connect to Postgres via the network. In order to access + # this information while the CA is in a degraded state we perform the query + # directly via a local psql call. + def certnames + psql = '/opt/puppetlabs/server/bin/psql -d pe-puppetdb --pset format=unaligned --pset t=on -c %s' + query = 'SELECT certname FROM certnames WHERE deactivated IS NULL AND expired IS NULL;' + cmd = psql % Shellwords.escape(query) + Puppet::Util::Execution.execute(cmd, + uid: 'pe-postgres', + gid: 'pe-postgres').split("\n") + + end + + # Abstract API changes for CA cert signing + # + # @param ca [Puppet::SSL::CertificateAuthority] + # @param hostname [String] + # @param options [Hash<Symbol, Object>] + def sign(ca, hostname, options) + if Puppet::Util::Package.versioncmp(Puppet::PUPPETVERSION, "4.6.0") != -1 + ca.sign(hostname, options) + else + ca.sign(hostname, options[:allow_dns_alt_names], options[:self_signing_csr]) + end + end + end + end +end diff --git a/code/environments/production/modules/certregen/lib/puppet_x/certregen/certificate.rb b/code/environments/production/modules/certregen/lib/puppet_x/certregen/certificate.rb new file mode 100644 index 0000000..56ad970 --- /dev/null +++ b/code/environments/production/modules/certregen/lib/puppet_x/certregen/certificate.rb @@ -0,0 +1,42 @@ +require 'puppet_x/certregen/util' + +module PuppetX + module Certregen + module Certificate + module_function + + # @param cert [Puppet::SSL::Certificate] + # @return [Hash<Symbol, String>] + def expiry(cert) + if cert.content.not_after < Time.now + status = :expired + elsif expiring?(cert) + status = :expiring + else + status = :ok + end + + data = { + :status => status, + :expiration_date => cert.content.not_after + } + + if status != :expired + data[:expires_in] = PuppetX::Certregen::Util.duration(cert.content.not_after - Time.now) + end + + data + end + + # Is this certificate expiring or expired? + # + # @param cert [Puppet::SSL::Certificate] + # @param percent [Integer] + def expiring?(cert, percent = 10) + remaining = cert.content.not_after - Time.now + lifetime = cert.content.not_after - (cert.content.not_before + 86400) + remaining / lifetime < (percent / 100.0) + end + end + end +end diff --git a/code/environments/production/modules/certregen/lib/puppet_x/certregen/crl.rb b/code/environments/production/modules/certregen/lib/puppet_x/certregen/crl.rb new file mode 100644 index 0000000..e82f929 --- /dev/null +++ b/code/environments/production/modules/certregen/lib/puppet_x/certregen/crl.rb @@ -0,0 +1,56 @@ +require 'fileutils' +require 'openssl' + +module PuppetX + module Certregen + # @api private + # @see {Puppet::SSL::CertificateRevocationList} + module CRL + module_function + + FIVE_YEARS = 5 * 365*24*60*60 + + def refresh(ca) + crl = ca.crl + crl_content = crl.content + update_to_next_crl_number(crl_content) + update_valid_time_range_to_start_at(crl_content, Time.now) + sign_with(crl_content, ca.host.key.content) + Puppet::SSL::CertificateRevocationList.indirection.save(crl) + FileUtils.cp(Puppet[:cacrl], Puppet[:hostcrl]) + Puppet::SSL::CertificateRevocationList.indirection.find("ca") + end + + # @api private + def update_valid_time_range_to_start_at(crl_content, time) + # The CRL is not valid if the time of checking == the time of last_update. + # So to have it valid right now we need to say that it was updated one second ago. + crl_content.last_update = time - 1 + crl_content.next_update = time + FIVE_YEARS + end + + # @api private + def update_to_next_crl_number(crl_content) + crl_content.extensions = with_next_crl_number_from(crl_content, crl_content.extensions) + end + + # @api private + def with_next_crl_number_from(crl_content, existing_extensions) + existing_crl_num = existing_extensions.find { |e| e.oid == 'crlNumber' } + new_crl_num = existing_crl_num ? existing_crl_num.value.to_i + 1 : 0 + extensions_without_crl_num = existing_extensions.reject { |e| e.oid == 'crlNumber' } + extensions_without_crl_num + [crl_number_of(new_crl_num)] + end + + # @api private + def crl_number_of(number) + OpenSSL::X509::Extension.new('crlNumber', OpenSSL::ASN1::Integer(number)) + end + + # @api private + def sign_with(crl_content, cakey) + crl_content.sign(cakey, OpenSSL::Digest::SHA1.new) + end + end + end +end diff --git a/code/environments/production/modules/certregen/lib/puppet_x/certregen/util.rb b/code/environments/production/modules/certregen/lib/puppet_x/certregen/util.rb new file mode 100644 index 0000000..e21298a --- /dev/null +++ b/code/environments/production/modules/certregen/lib/puppet_x/certregen/util.rb @@ -0,0 +1,27 @@ +module PuppetX + module Certregen + module Util + module_function + + def duration(epoch) + seconds = epoch.to_i + minutes = (epoch / 60).to_i; seconds %= 60 if minutes > 0 + hours = (minutes / 60).to_i; minutes %= 60 if hours > 0 + days = (hours / 24).to_i; hours %= 24 if days > 0 + years = (days / 365).to_i; days %= 365 if years > 0 + + list = [] + list << "#{years} #{pluralize('year', years)}" if years > 0 + list << "#{days} #{pluralize('day', days)}" if days > 0 + list << "#{hours} #{pluralize('hour', hours)}" if hours > 0 + list << "#{minutes} #{pluralize('minute', minutes)}" if minutes > 0 + list << "#{seconds} #{pluralize('second', seconds)}" if seconds > 0 + list.join(", ") + end + + def pluralize(str, count) + count == 1 ? str : str + 's' + end + end + end +end |