SSL Cert Rotation with Runbook
Patrick Blesi
Posted on November 7, 2019
SSL cert rotation is a problem that plagues nearly every web developer. Some are fortunate to work with infrastructure where an automated solution is available. This eliminates the need for tedious SSL cert management. Others are fortunate to have a team dedicated to managing infrastructure needs, so they can toss this issue to another engineer.
Unfortunately, I do not fall into either of these two camps when it comes to SSL certificate management. I needed to develop my own certificate management solution to ensure new SSL certs got properly installed on my servers. This post covers how to use Braintree's Runbook to automate the rotation of SSL certificates.
My certificate infrastructure is based on Jamie Nguyen's OpenSSL Certificate Authority Tutorial. I store a root certificate authority and intermediate certificate authority on my local machine. From the intermediate CA, I issue certs for servers within my infrastructure. All servers in my infrastructure trust my intermediate CA, so they accept traffic from valid certs when connecting to other servers.
SSL certificate rotation is a process that's ripe for automation. It is a tedious, repetitive process. The process is performed infrequently enough that it is easy to forget how to do it. And it is mission critical; failing to properly rotate SSL certificates almost certainly results in an outage.
The automation process
Several iterations were required before I boiled the rotation process down to a single command entered on the command line. My first pass at rotating SSL certs involved a lot of trial and error. Ultimately, I nailed down all the steps required to successfully rotate SSL certificates in my infrastructure. I took diligent notes to be well-armed for my next inevitable encounter with SSL cert rotation.
On my second iteration, with notes in hand, I was able to successfully rotate certificates with only minor tweaks to my documented process. Nevertheless, having a handful of servers still made this a tedious process. Retyping the several-dozen commmand incantations over and over for each server is not how I like to spend my afternoons. Leveraging Runbook allowed me to bring this process down to single command for rotating my SSL certs. Below is the SSL cert rotation runbook I developed.
The runbook
The SSL Cert rotation process breaks down into three main steps. First, a new cert needs to be generated using the intermediate CA. Second, the new cert needs to be distributed to the target server. And finally, the service must be restarted to pick up the new certificate.
See Runbook's README.md for details on setting up and working with Runbook.
Setup
First, we must collect some input for our parameterized runbook. The two pieces of information we need are the host and service for the SSL certificate we are generating.
#!/usr/bin/env ruby
require "runbook"
host = ENV["HOST"] # e.x. ldap01.stg
raise "Error no host specified using HOST env var" unless host
service = ENV["SERVICE"] # e.x. slapd
raise "Error no service specified using SERVICE env var" unless service
local_user = ENV["USER"]
company_name = "patricks_pickles"
intermediate_ca_path = "/root/ca/intermediate"
local_git_dir = "/home/#{local_user}/dev/#{company_name}/pp-infrastructure"
local_cert_path = "modules/ldap/files"
local_cert_file = "#{host}_#{service}.cert.pem"
staging_suffix = "#{company_name}-staging.com"
prod_suffix = "#{company_name}.com"
We collect this info from the command line using environment variables. The reason we do not use Runbook's ask statement to collect this info is because these values are used prominently throughout the runbook. It makes for a cleaner implementation to collect these values on the command line and then parameterize our runbook with them before executing it.
Additionally, we initialize a number of other local variables that are used throughout our runbook.
All runbooks are initialized with a title. It is best-practice to provide a description of the runbook as well.
runbook = Runbook.book "Renew SSL Certs" do
description <<-DESC
This Runbook rotates SSL Certs.
DESC
layout [[:runbook, :commands]]
section "Setup" do
step { ruby_command { @env = host.split(".").last.to_sym } }
end
end
This runbook has a layout, which implies the runbook manages multiple panes using tmux. The layout is a stacked layout where the runbook executes in the top pane and commands are sent to the bottom pane. The setup section sets the @env
instance variable at runtime. This value is subsequently available in all other ruby_command
blocks.
Certificate generation
After initial setup, the next runbook section encompasses all steps required to create the new certificate.
runbook = Runbook.book "Renew SSL Certs" do
#...
section "Create New Cert" do
user "root"
step "Backup and update index.txt" do
capture %Q{ls #{intermediate_ca_path}/index.txt.old* | tail -n 1 | sed -E "s/.*([0-9]{2})/\\1/"}, into: :backup_num
ruby_command do
@backup_num = (backup_num.to_i + 1).to_s.rjust(2, "0")
command "cp #{intermediate_ca_path}/index.txt #{intermediate_ca_path}/index.txt.old#{@backup_num}"
command "cp #{intermediate_ca_path}/index.txt.attr #{intermediate_ca_path}/index.txt.attr.old#{@backup_num}"
command %Q{sed -i "/#{host} #{service.upcase}/d" #{intermediate_ca_path}/index.txt}
end
end
step "Generate new cert" do
ruby_command do
case env
when :stg
@expiration_days = 1035
when :prod
@expiration_days = 1095
else
raise "Unknown env: #{env}"
end
tmux_command "sudo openssl ca -config #{intermediate_ca_path}/openssl.cnf -extensions server_cert -days #{@expiration_days} -notext -md sha256 -in #{intermediate_ca_path}/csr/#{host}_#{service}.csr.pem -out #{intermediate_ca_path}/certs/#{local_cert_file}", :commands
confirm "Have you generated the cert?"
end
command "sudo chmod 444 #{intermediate_ca_path}/certs/#{local_cert_file}"
tmux_command "sudo openssl x509 -noout -text -in #{intermediate_ca_path}/certs/#{local_cert_file}", :commands
confirm "Does the cert look correct?"
tmux_command "sudo openssl verify -CAfile #{intermediate_ca_path}/certs/ca-chain.cert.pem #{intermediate_ca_path}/certs/#{local_cert_file}", :commands
confirm "Is the cert valid?"
end
end
end
The user
setter designates that all commands in this section exececute as the root
user. Because no host is specified, commands are executed locally. confirm
statements allow us to confirm everything is correct before moving on to the next step.
Because the case statement references the env
instance variable, it must be wrapped in a ruby_command
block so it is executed at runtime. Because the tmux_command
references @expiration_days
, it also must be defined in the ruby_command
block.
Uploading the certificate
In this section we copy the newly-generated certificate to our version-controlled infrastructure management repository, upload the certificate to the server, and install the certificate.
runbook = Runbook.book "Renew SSL Certs" do
#...
section "Upload Cert" do
step "Copy the cert to pp-infrastructure" do
user "root"
command "cp #{intermediate_ca_path}/certs/#{local_cert_file} #{local_git_dir}/#{local_cert_path}"
command "chown #{local_user}:#{local_user} #{local_git_dir}/#{local_cert_path}/#{local_cert_file}"
end
step "Upload the cert" do
server host
upload "#{local_git_dir}/#{local_cert_path}/#{local_cert_file}", to: "/home/#{local_user}"
end
step "Install the cert" do
server host
user "root"
command "mv ~#{local_user}/#{local_cert_file} /etc/ssl"
command "chown root:ssl-cert /etc/ssl/#{local_cert_file}"
command "chmod 444 /etc/ssl/#{local_cert_file}"
command "cp /etc/ssl/#{local_cert_file} /etc/ssl/certs"
end
end
end
The server
setter uses entries in my ~/.ssh/config
file to resolve the long-form host name, port, and user to log in as for a given host string passed to ssh.
Restarting the service
Lastly, we restart the service, verify our new certificate is valid, and commit our certificate to version control.
runbook = Runbook.book "Renew SSL Certs" do
#...
section "Upload Cert" do
#...
step "Restart the service" do
server host
user "root"
command "service #{service} restart"
end
step "Validate the service cert is valid" do
ruby_command do
case env
when :stg
@suffix = staging_suffix
when :prod
@suffix = prod_suffix
else
raise "Unknown env: #{env}"
end
tmux_command "ssh #{host}", :commands
tmux_command "openssl s_client -connect #{host}.#{@suffix}:12345 -CApath /etc/ssl/certs", :commands
confirm "Is the cert valid?"
end
end
step "Commit cert changes" do
path "#{local_git_dir}"
command "git add #{local_cert_path}/#{local_cert_file}"
command %Q{git commit -m "Update #{local_cert_file} certificate"}
command "git push"
end
end
end
Including the cert validation step allows us to confirm that the new cert is working as expected so we don't encounter an issue when the old certificate expires.
With our runbook in hand, rotating SSL certs is as simple as invoking the following command:
HOST=ldap01.stg SERVICE=slapd runbook exec runbooks/renew_ssl_certs.rb
Conclusion
The full runbook for rotating SSL certs is available as a gist. This runbook likely won't meet your SSL rotation needs as is. But hopefully it can serve as a basis for automating your SSL cert rotation process, so you are no longer plagued with manually managing SSL certificates.
Posted on November 7, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.