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).
<?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: