Mugo Web main content.

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!

Comments

blog comments powered by Disqus