Skip to content

Instantly share code, notes, and snippets.

@jay7x
Last active March 9, 2026 04:52
Show Gist options
  • Select an option

  • Save jay7x/1284c8b3def0fb0b59f21de425ef7087 to your computer and use it in GitHub Desktop.

Select an option

Save jay7x/1284c8b3def0fb0b59f21de425ef7087 to your computer and use it in GitHub Desktop.
#!/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
@jay7x
Copy link
Author

jay7x commented Mar 9, 2026

ℹ️ FYI, all this is doable with puppet ssl bootstrap --certname foo.example.com (except certificate renewal, but I'm working on it)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment