Last active
March 9, 2026 04:52
-
-
Save jay7x/1284c8b3def0fb0b59f21de425ef7087 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env ruby | |
| # frozen_string_literal: true | |
| # Simple script to renew or enroll a Puppet certificate | |
| # - If cert exists and is expiring: use certificate_renew endpoint | |
| # - If cert missing: generate CSR, submit to CA, poll until signed | |
| # | |
| # Usage: ruby puppetcertmanager.rb <certname> [--csr-attributes /path/to/file.yaml] [--dns-alt-names name1,name2] [--debug] | |
| require 'puppet' | |
| require 'puppet/ssl/certificate' | |
| require 'puppet/ssl/certificate_request' | |
| require 'puppet/ssl/oids' | |
| require 'uri' | |
| require 'optparse' | |
| # Manage certificates issued by Puppet CA | |
| class PuppetCertManager | |
| def initialize( | |
| certname, | |
| debug: false, | |
| dns_alt_names: [], | |
| expiry_days: 30, | |
| csr_attributes: File::NULL | |
| ) | |
| # Options | |
| @certname = certname | |
| @debug = debug | |
| @expiry_sec = expiry_days * 24 * 60 * 60 | |
| # Initialize Puppet (minimal config for standalone use) | |
| Puppet.initialize_settings | |
| Puppet::Util::Log.newdestination(:console) unless Puppet::Util::Log.destinations.include?(:console) | |
| Puppet.settings[:log_level] = (@debug ? 'debug' : 'info') | |
| Puppet.debug('Initializing...') | |
| Puppet.debug("Puppet version: #{Puppet.version}") | |
| # Set certname to set hostprivkey & hostcert to make ssl-related functions work | |
| Puppet.settings[:certname] = @certname | |
| # Value below is not really used atm, just should be >0 to inject required CSR attribute | |
| Puppet.settings[:hostcert_renewal_interval] = 60 * 24 * 60 * 60 | |
| # Set dns_alt_names to make cert_provider.create_request() function work | |
| Puppet.settings[:dns_alt_names] = dns_alt_names.join(',') | |
| # Set csr_attributes file path (by default we don't want attributes in custom certs) | |
| Puppet.settings[:csr_attributes] = csr_attributes | |
| # Initialize components | |
| @http_client = Puppet.runtime[:http] | |
| @cert_provider = Puppet::X509::CertProvider.new | |
| @ssl_provider = Puppet::SSL::SSLProvider.new | |
| # Ensure we have CA certificates | |
| state_machine = Puppet::SSL::StateMachine.new | |
| @root_ssl_context = state_machine.ensure_ca_certificates | |
| @full_ssl_context = nil | |
| @ca_route = @http_client.create_session.route_to(:ca, ssl_context: @root_ssl_context) | |
| @ca_route_full = nil | |
| @private_key = nil | |
| @cert_request = nil | |
| @cert = nil | |
| end | |
| def cert_expiring?(cert) | |
| (cert.not_after - Time.now) < @expiry_sec | |
| end | |
| def renew | |
| _, pem = @ca_route.post_certificate_renewal(@full_ssl_context) | |
| # Save the renewed certificate | |
| new_cert = OpenSSL::X509::Certificate.new(pem) | |
| @cert_provider.save_client_cert(@certname, new_cert) | |
| @cert_provider.delete_request(@certname) | |
| @cert = new_cert | |
| Puppet.info("#{@certname}: certificate renewed successfully") | |
| Puppet.info("#{@certname}: fingerprint #{fingerprint(new_cert)}") | |
| Puppet.info("#{@certname}: valid until #{new_cert.not_after}") | |
| end | |
| # This is copy of the same-named private function from lib/puppet/application/ssl.rb | |
| def create_private_key | |
| if Puppet[:key_type] == 'ec' | |
| Puppet.info format(_('Creating a new EC SSL key for %<name>s using curve %<curve>s'), name: @certname, | |
| curve: Puppet[:named_curve]) | |
| OpenSSL::PKey::EC.generate(Puppet[:named_curve]) | |
| else | |
| Puppet.info format(_('Creating a new SSL key for %<name>s'), name: @certname) | |
| OpenSSL::PKey::RSA.new(Puppet[:keylength].to_i) | |
| end | |
| end | |
| def load_or_create_private_key | |
| Puppet.debug('Trying to load private key...') | |
| @private_key = @cert_provider.load_private_key(@certname) | |
| return if @private_key | |
| @private_key = create_private_key | |
| Puppet.debug('Storing private key...') | |
| @cert_provider.save_private_key(@certname, @private_key) | |
| end | |
| def load_or_create_request | |
| # Register Puppet OIDs if needed (for extension requests) | |
| Puppet::SSL::Oids.register_puppet_oids if Puppet::SSL::Oids.respond_to?(:register_puppet_oids) | |
| Puppet.debug('Trying to load existing CSR...') | |
| @cert_request = @cert_provider.load_request(@certname) | |
| return if @cert_request | |
| Puppet.debug('Generating CSR...') | |
| @cert_request = @cert_provider.create_request(@certname, @private_key) | |
| Puppet.debug('Storing CSR...') | |
| @cert_provider.save_request(@certname, @cert_request) | |
| end | |
| def submit_request | |
| Puppet.debug('Submitting certificate request to CA server...') | |
| @ca_route.put_certificate_request(@certname, @cert_request, ssl_context: @root_ssl_context) | |
| rescue Puppet::HTTP::ResponseError => e | |
| unless e.response.code == 400 | |
| raise Puppet::Error.new(format('Failed to submit certificate request: %<message>s', message: e.message), e) | |
| end | |
| Puppet.warning("#{@certname}: unable to submit CSR as it seems already submitted") | |
| Puppet.warning("#{@certname}: sign it or clean it on CA") | |
| rescue StandardError => e | |
| raise Puppet::Error.new(format('Failed to submit certificate request: %<message>s', message: e.message), e) | |
| end | |
| def fetch_certificate | |
| Puppet.debug('Fetching certificate from CA...') | |
| success, cert_pem = @ca_route.get_certificate(@certname, ssl_context: @root_ssl_context) | |
| if success && cert_pem | |
| @cert = OpenSSL::X509::Certificate.new(cert_pem) | |
| return true | |
| end | |
| nil | |
| rescue Puppet::HTTP::ResponseError => e | |
| # 404 = cert not signed yet, keep polling | |
| raise unless e.response.code == 404 | |
| nil | |
| rescue OpenSSL::SSL::SSLError => e | |
| # SSL errors during polling might indicate CA issues - log but continue | |
| Puppet.debug("Polling SSL warning: #{e.message}") | |
| nil | |
| end | |
| def wait_for_certificate | |
| timeout = Puppet.settings[:waitforcert] | |
| return true if timeout.zero? | |
| Puppet.info("#{@certname}: waiting for certificate to be available on CA...") | |
| interval = 15 # seconds between polls | |
| elapsed = 0 | |
| while elapsed < timeout | |
| sleep interval | |
| elapsed += interval | |
| return true if fetch_certificate | |
| end | |
| Puppet.err("#{@certname}: timeout waiting for certificate signature") | |
| Puppet.err("#{@certname}: to complete enrollment, sign it on CA: puppetserver ca sign --cert #{@certname}") | |
| nil | |
| end | |
| def run | |
| Puppet.info("Working on certificate #{@certname}") | |
| # Check if we have an existing certificate | |
| Puppet.debug('Loading SSL context...') | |
| @full_ssl_context = begin | |
| @ssl_provider.load_context(certname: @certname) | |
| rescue StandardError | |
| nil | |
| end | |
| if @full_ssl_context | |
| if cert_expiring?(@full_ssl_context[:client_cert]) | |
| Puppet.info("#{@certname}: certificate is expiring at #{@full_ssl_context[:client_cert].not_after}, renewing...") | |
| renew | |
| else | |
| Puppet.info("#{@certname}: certificate is up-to-date, exiting...") | |
| end | |
| else | |
| # No cert. Check if it was already requested and signed by any chance | |
| Puppet.info("#{@certname}: certificate is missing, requesting...") | |
| unless fetch_certificate | |
| # No local nor remote certs. Generate a CSR and wait for cert to be signed | |
| load_or_create_private_key | |
| load_or_create_request | |
| submit_request | |
| # Poll for the signed certificate | |
| return 1 unless wait_for_certificate | |
| end | |
| # Save the certificate and delete CSR | |
| @cert_provider.save_client_cert(@certname, @cert) | |
| @cert_provider.delete_request(@certname) | |
| Puppet.info("#{@certname}: certificate received and saved") | |
| Puppet.info("#{@certname}: fingerprint #{fingerprint(@cert)}") | |
| Puppet.info("#{@certname}: valid until #{@cert.not_after}") | |
| end | |
| 0 | |
| end | |
| def fingerprint(cert) | |
| Puppet::SSL::Digest.new(nil, cert.to_der) | |
| end | |
| end | |
| # Run the script | |
| if __FILE__ == $PROGRAM_NAME | |
| options = {} | |
| OptionParser.new do |opts| | |
| opts.banner = "Usage: #{$PROGRAM_NAME} <certname> [options]" | |
| opts.on('--csr-attributes PATH', String, 'Path to a CSR attributes YAML file') do |v| | |
| path = v.strip | |
| raise ArgumentError, "CSR attributes file doesn't exist" unless File.exist?(path) | |
| options[:csr_attributes] = path | |
| end | |
| opts.on('--dns-alt-names NAMES', String, 'Comma-separated DNS alt names for certificate request') do |v| | |
| options[:dns_alt_names] = v.split(',').map(&:strip) | |
| end | |
| opts.on('--expiry-days DAYS', 'Renew certificate if expiring in this amount of days or sooner') do |v| | |
| options[:expiry_days] = v.to_i | |
| end | |
| opts.on('--debug', 'Enable debug logging') do | |
| options[:debug] = true | |
| end | |
| opts.on('-h', '--help', 'Show this help') do | |
| puts opts | |
| exit 0 | |
| end | |
| end.parse! | |
| certname = ARGV.shift | |
| unless certname && !certname.empty? | |
| warn 'Error: certname is required' | |
| warn 'See --help for usage' | |
| exit 1 | |
| end | |
| exit PuppetCertManager.new(certname, **options).run | |
| end |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
ℹ️ FYI, all this is doable with
puppet ssl bootstrap --certname foo.example.com(except certificate renewal, but I'm working on it)