Automatically update DANE / TLSA records

I am using free Let's Encrypt SSL certificates for a while now. A small downside of Let's Encrypt is that licenses must be renewed every two months. So I searched the internet for some scripts to automate this and modified them.

#!/bin/sh if ! /opt/letsencrypt/letsencrypt-auto renew -a webroot --webroot-path /var/www/ > /opt/letsencrypt/renew.log 2>&1 ; then echo Automated renewal failed: cat /opt/letsencrypt/renew.log exit 1 else FILETIME=`stat -c %Y /etc/letsencrypt/live/vaniersel.net/fullchain.pem` NOW=`date +%s` # Only execute when the certificate file has changed recently if [ $(($NOW - 3600)) -lt $FILETIME ]; then # Create a combined file for Courier POP and IMAP cat /etc/letsencrypt/live/vaniersel.net/privkey.pem /etc/letsencrypt/live/vaniersel.net/fullchain.pem > /etc/letsencrypt/live/vaniersel.net/fullchain_privkey.pem # Sendmail requires the certificate to be in it's own directory cp /etc/letsencrypt/live/vaniersel.net/privkey.pem /etc/mail/tls cp /etc/letsencrypt/live/vaniersel.net/fullchain.pem /etc/mail/tls chown root.smmsp /etc/mail/tls/privkey.pem /etc/mail/tls/fullchain.pem chmod 640 /etc/mail/tls/privkey.pem /etc/mail/tls/fullchain.pem # Update TLSA records for DANE /usr/bin/php /opt/vaniersel.net/tlsa-records.php # Restart all applications using the certificate apachectl graceful /etc/init.d/sendmail restart /etc/init.d/courier-imap-ssl restart /etc/init.d/courier-pop-ssl restart fi fi

The difficult part was updating the TLSA records in the DNS, which i solved by writing a PHP-script which generates the records, replaces them in the zonefile, updates the serial of the zonefile, loads the new zonefile and signs it (for DNSSEC).

Let the magic begin!

<?php /** * @author Jurrian van Iersel * * This is just a quick and dirty script and needs to be finalized! */ <?php $ports = array(25, 443, 465, 993, 995); $zonefileDir = '/etc/bind/'; // Get all Let's Encrypt certificates $certfiles = glob('/etc/letsencrypt/live/*/cert.pem'); $records = array(); foreach ($certfiles as $certfile) { // parse certificate file $certinfo = openssl_x509_parse(file_get_contents($certfile)); // check date/time if (time()<$certinfo['validFrom_time_t'] && time()>$certinfo['validTo_time_t']) { // certificate is not valid at this moment continue; } // Didn't find a way to do this using PHP-functions (yet). Use a shell-command for now $cmd = 'openssl x509 -in ' . $certfile . ' -outform DER | openssl sha256'; $hash = trim(substr(`$cmd`, 8)); // Subject Common Name if (isset($certinfo['subject']['CN'])) { if (preg_match('/^((<?host>.+)\.)?(?<domain>[a-z0-9\-]+\.[a-z]+)$/', $certinfo['subject']['CN'], $matches)) { if (!array_key_exists($matches['domain'], $records)) { $records[$matches['domain']] = array(); } $records[$matches['domain']][] = array( 'host' => $matches['host'], 'hash' => $hash, ); } } // Subject Alternative names if (isset($certinfo['extensions']['subjectAltName'])) { // @todo regex only accepts second level domains, so does not work for example with .co.uk domains if (preg_match_all('/DNS:((?<host>[a-z0-9\-\.]+)\.)?(?<domain>[a-z0-9\-]+\.[a-z]+),/', $certinfo['extensions']['subjectAltName'] . ',', $matches, PREG_SET_ORDER)) { foreach ($matches as $match) { if (!array_key_exists($match['domain'], $records)) { $records[$match['domain']] = array(); } $records[$match['domain']][] = array( 'host' => $match['host'], 'hash' => $hash, ); } } } } // Now we have array $records where the domain is the key and the value is an array with the FQDN and the hash // If you're running PowerDNS, you might run some queries on your SQL backend here to update the TLSA records // I am running Bind, so we'll update the plain text zonefile foreach ($records as $domain => $data) { $zonefile = $zonefileDir . $domain . '.zone'; // get current contents $contents = ''; $fp = fopen($zonefile, 'r'); while (!feof($fp)) { $line = fgets($fp, 1024); if (preg_match('/IN\s+TLSA\s+[0-9]\s+[0-9]\s+[0-9]\s+/i', $line)) { // skip } elseif (preg_match('/^(?<pre>\s+)(?<serial>[0-9]+)(?<post>\s+;\s+serial\s*)$/', $line, $matches)) { $date = date('Ymd'); if (substr($matches['serial'], 0 ,8) == $date) { $matches['serial']++; } else { $matches['serial'] = $date . '01'; } $contents.= $matches['pre'] . $matches['serial'] . $matches['post']; } else { $contents.= $line; } } fclose($fp); // write new contents $fp = fopen($zonefile, 'w'); // copy everything except TLSA records fputs($fp, $contents); // Add TLSA records foreach ($data as $row) { foreach ($ports as $port) { $host = trim('_' . $port . '._tcp.' . $row['host'], '.'); fputs($fp, sprintf('%s 5M IN TLSA 3 0 1 %s' . PHP_EOL, $host, $row['hash'])); } } fclose($fp); } // reload zonefiles and sign them `rndc reload`; foreach (array_keys($records) as $domain) { `rndc sign $domain`; }

This script needs some improvements:

Share the knowledge

Did you like this article? Please share it on twitter!