Automating SSL certificate renewal for multi-site installs
By: Ernesto Buenrostro | January 22, 2020 | Web solutions and Business solutions
Recently, we built a platform running more than 80 websites on eZ Publish / eZ Platform. New sites would get added to this platform over time, so we needed to automate its ongoing maintenance, including automated SSL certificate renewals.
To generate the SSL certificates, we use Let's Encrypt, which issues free SSL certificates to make it easy for the internet to run on encrypted HTTPS connections. A key part of the automation was to also integrate with the DNS provider Oracle Dyn. Note that the described solution can be modified to work with any DNS provider that has an API.
Implementation
We need to generate and test each SSL certificate before the relevant domain is live on the new platform. Therefore, we use the DNS challenge that Let's Encrypt supports, and take advantage of Oracle Dyn's REST API to manage the DNS records without having to manually log in to its web interface.
Certbot is Let's Encrypt's command-line tool, and we used Certbot pre- and post-validation hooks to integrate Let's Encrypt with Oracle Dyn. On the pre-validation hook, we add the TXT record to the DNS zone and trigger the SSL certificate generation. On the post-validation hook, we take care of any cleanup required, such as removing the TXT record.
Setting up a new domain
The initial Let's Encrypt setup for a new site needs to be done manually. We use the following command to generate a new site's certificate:
certbot certonly \ --manual \ --preferred-challenges=dns \ -m email@example.com \ --server https://acme-v02.api.letsencrypt.org/directory \ --agree-tos \ --manual-auth-hook /letsencrypt/dnsauthenticate.sh \ --manual-cleanup-hook /letsencrypt/dnscleanup.sh \ -d '*.example.com,example.com'
Note that the domain names have to be updated according to the site we're adding.
The pre-validation hook "manual-auth-hook" performs the following steps:
- Open a session with Oracle Dyn DNS
- Get the zone where our domain is
- Create and publish a new TXT record with the information passed by Certbot
- Close the session
dnsauthenticate.sh
#!/bin/bash customer_name="client" user_name="client_user" password="password" echo "CERTBOT_DOMAIN: $CERTBOT_DOMAIN" echo "CERTBOT_VALIDATION: $CERTBOT_VALIDATION" # Get authorization echo "Logging in" token=$(curl -s -X POST "https://api.dynect.net/REST/Session/" \ -H "Content-Type: application/json" \ --data '{"customer_name":"'"$customer_name"'","user_name":"'"$user_name"'","password":"'"$password"'"}' \ | python -c "import sys,json;print(json.load(sys.stdin)['data']['token'])") echo # Get current zone zone=$(curl -s -X GET "https://api.dynect.net/REST/Zone/" \ -H "Content-Type: application/json" \ -H "Auth-Token:$token" \ | php -r '$result=json_decode(stream_get_contents(STDIN), true); $domain="'"$CERTBOT_DOMAIN"'"; $zone=""; foreach($result["data"] as $item) { $tmpZone = substr(substr($item, 11), 0, -1); if (stripos($domain, $tmpZone) !== false) { $zone = $tmpZone; break; } } echo($zone);') echo # Create the TXT record echo "Creating the record" curl -s -X POST "https://api.dynect.net/REST/TXTRecord/$zone/_acme-challenge.$CERTBOT_DOMAIN/" \ -H "Content-Type: application/json" \ -H "Auth-Token:$token" \ --data '{"rdata":{"txtdata":"'"$CERTBOT_VALIDATION"'"},"ttl":"120"}' echo # Publish the TXT record echo "Publishing the record" curl -s -X PUT "https://api.dynect.net/REST/Zone/$zone/" \ -H "Content-Type: application/json" \ -H "Auth-Token:$token" \ -d '{"publish":true}' echo # End the API session echo "Closing the session" curl -s -X DELETE "https://api.dynect.net/REST/Session/" \ -H "Content-Type: application/json" \ -H "Auth-Token:$token" echo # Save info for cleanup echo "Saving info for clean up" if [ ! -d /tmp/CERTBOT_$CERTBOT_DOMAIN ]; then mkdir -m 0700 /tmp/CERTBOT_$CERTBOT_DOMAIN fi echo "$CERTBOT_VALIDATION" >> /tmp/CERTBOT_$CERTBOT_DOMAIN/CERTBOT_ID # Sleep to make sure the change has time to propagate over to DNS sleep 25
After we have added the new DNS record, Certbot will confirm that we own the domain and Let's Encrypt will issue the new certificate.
The post-validation hook "manual-cleanup-hook" performs the following:
- Open a session to Oracle Dyn DNS
- Get the zone where our domain is
- Remove the TXT record added in the pre-validation hook
- Close the session
dnscleanup.sh
#!/bin/bash customer_name="client" user_name="client_user" password="password" echo "CERTBOT_DOMAIN: $CERTBOT_DOMAIN" if [ -f /tmp/CERTBOT_$CERTBOT_DOMAIN/CERTBOT_ID ]; then # Get authorization echo "Logging in" token=$(curl -s -X POST "https://api.dynect.net/REST/Session/" \ -H "Content-Type: application/json" \ --data '{"customer_name":"'"$customer_name"'","user_name":"'"$user_name"'","password":"'"$password"'"}' \ | python -c "import sys,json;print(json.load(sys.stdin)['data']['token'])") echo "/tmp/CERTBOT_$CERTBOT_DOMAIN/CERTBOT_ID" # Get current zone zone=$(curl -s -X GET "https://api.dynect.net/REST/Zone/" \ -H "Content-Type: application/json" \ -H "Auth-Token:$token" \ | php -r '$result=json_decode(stream_get_contents(STDIN), true); $domain="'"$CERTBOT_DOMAIN"'"; $zone=""; foreach($result["data"] as $item) { $tmpZone = substr(substr($item, 11), 0, -1); if (stripos($domain, $tmpZone) !== false) { $zone = $tmpZone; break; } } echo($zone);') echo # CERTBOT_ID=$(cat /tmp/CERTBOT_$CERTBOT_DOMAIN/CERTBOT_ID) while IFS='' read -r CERTBOT_ID || [[ -n "$CERTBOT_ID" ]]; do # Remove the TXT record command="php /var/www/site/bin/remove_txtrecord.php --zone=$zone --domain=$CERTBOT_DOMAIN --txt-content=$CERTBOT_ID --token=$token" $command; done < "/tmp/CERTBOT_$CERTBOT_DOMAIN/CERTBOT_ID" rm -f /tmp/CERTBOT_$CERTBOT_DOMAIN/CERTBOT_ID # Publishing the zone changes echo "Publishing the zone changes" curl -s -X PUT "https://api.dynect.net/REST/Zone/$zone/" \ -H "Content-Type: application/json" \ -H "Auth-Token:$token" \ -d '{"publish":true}' echo # End the API session echo "Closing the session" curl -s -X DELETE "https://api.dynect.net/REST/Session/" \ -H "Content-Type: application/json" \ -H "Auth-Token:$token" echo fi
remove_txtrecord.php
#!/usr/bin/env php <?php define('IS_CLI', PHP_SAPI === 'cli'); if (!IS_CLI) { exit(1); } $shortOptsList = []; $shortOpts = implode('', $shortOptsList); $longOptsList = [ "txt-content:", // Required value "token:", // Required value "zone:", // Required value "domain:", // Required value ]; $options = getopt($shortOpts, $longOptsList); // All the options must be passed if ( !( isset($options['txt-content']) && trim($options['txt-content']) !== '' && isset($options['token']) && trim($options['token']) !== '' && isset($options['domain']) && trim($options['domain']) !== '' && isset($options['zone']) && trim($options['zone']) !== '' ) ) { echo "All the options are required\n"; exit; } $txtContent = $options['txt-content']; $token = $options['token']; $domain = $options['domain']; $zone = $options['zone']; $restDomain = "https://api.dynect.net"; $hcurl = curl_init(); // Fetch all the TXT records curl_setopt_array( $hcurl, [ CURLOPT_URL => "$restDomain/REST/TXTRecord/$zone/_acme-challenge.$domain/", CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', "Auth-Token:$token" ], ] ); $curlResult = curl_exec( $hcurl ); $result = json_decode($curlResult, true); // look for the targeted record foreach( $result['data'] as $txtRecord ) { curl_setopt( $hcurl, CURLOPT_URL, "{$restDomain}{$txtRecord}"); $curlResult = curl_exec( $hcurl ); $recordResult = json_decode($curlResult, true); if ( $recordResult['data']['rdata']['txtdata'] === $txtContent ) { // Removing record found $recordID = $recordResult['data']['record_id']; curl_setopt( $hcurl, CURLOPT_CUSTOMREQUEST, "DELETE" ); $curlResult = curl_exec( $hcurl ); break; } } // Close the connection curl_close( $hcurl );
After we've generated the new certificate, we need to manually reload the web server (Nginx in our case) to make sure the new certificate is loaded.
Configuring the renewal hooks
Certbot stores the information that was used to generate the certificate in the first place; this includes the hooks used during the certificate generation.
To renew the certificates (every 90 days, as Let's Encrypt certificates have relatively short expiry dates), we use a cronjob:
0 0,12 * * * root certbot renew --renew-hook 'systemctl reload nginx'
This command is using the "renew-hook" to ensure that the web server (Nginx in our case) is reloaded after a certificate is successfully renewed.
The integration between Let's Encrypt and Oracle Dyn enabled us to move the existing websites efficiently, as we were able to generate and test the SSL certificates in advance. The renewal automation relieved our client from having to manage certificate deadlines, and perform manual renewal and installation every year. It also saves them money!