summaryrefslogtreecommitdiff
path: root/code/environments/production/modules/certregen/lib
diff options
context:
space:
mode:
Diffstat (limited to 'code/environments/production/modules/certregen/lib')
-rw-r--r--code/environments/production/modules/certregen/lib/facter/has_puppet.rb10
-rw-r--r--code/environments/production/modules/certregen/lib/facter/hostcrl.rb4
-rw-r--r--code/environments/production/modules/certregen/lib/facter/localcacert.rb4
-rw-r--r--code/environments/production/modules/certregen/lib/puppet/application/certregen.rb4
-rw-r--r--code/environments/production/modules/certregen/lib/puppet/face/certregen.rb205
-rw-r--r--code/environments/production/modules/certregen/lib/puppet/feature/chloride.rb3
-rw-r--r--code/environments/production/modules/certregen/lib/puppet/functions/is_classified_with.rb9
-rw-r--r--code/environments/production/modules/certregen/lib/puppet/parser/functions/is_classified_with.rb4
-rw-r--r--code/environments/production/modules/certregen/lib/puppet_x/certregen/ca.rb137
-rw-r--r--code/environments/production/modules/certregen/lib/puppet_x/certregen/certificate.rb42
-rw-r--r--code/environments/production/modules/certregen/lib/puppet_x/certregen/crl.rb56
-rw-r--r--code/environments/production/modules/certregen/lib/puppet_x/certregen/util.rb27
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