Let's Encrypt my servers with acme tiny

Let’s Encrypt is a project that offer free domain validated SSL/TLS certificates. The organisations and companies behind it includes EFF, Mozilla, Akamai and Cisco as well as many other.

EFF has long been working for HTTPS Everywhere and Let’s Encrypt is a big step in this direction. Let’s Encrypt is actually an implementation of Automatic Certificate Management Environment (ACME) which will allow other providers of free certs in the future.

The easiest way to get started is to use the official Let’s Encrypt ACME client Certbot. If you want use it read more at How It Works.

The official client is a rather large Python cli app with a lot of dependencies. I was looking for something small and simple that gave me full control.

Luckily there are already a large number of other client implementations and another list of client implementations. I opted for acme-tiny by Daniel Roesler. It’s less that 200 lines of code so easy to review and understand. It also seems to work really well.

I have incorporated it in an Ansible playbook so I can deploy it easily on my servers. They all now run with Let’s Encrypt certs. The certs are only valid for three month so it’s highly recommended to automate the certificate renewal process. See below for my solution.

First setup

Generate a account.key if you don’t already have one for Let’s Encrypt.

openssl genpkey -algorithm rsa -pkeyopt rsa_keygen_bits:4096 -out /etc/ssl/letsencrypt/account.key

Generate a domain.key. I’m using 2048 bits since that what Let’s Encrypt certs are using, anything larger would be pointless.

openssl genpkey -algorithm rsa -pkeyopt rsa_keygen_bits:2048 -out /etc/ssl/letsencrypt/domain.key

Make sure to set as restrictive permissions as possible on the keys.

Create a certificate signing request (CSR) for your domains.

openssl req -new -sha256 -key /etc/ssl/letsencrypt/domain.key -subj "/C=US/O=Acme/CN=example.com" -reqexts SAN -config <(cat /etc/ssl/openssl.cnf <(printf "[SAN]\nsubjectAltName=DNS:www.example.com,DNS:static.example.com")) -out /etc/ssl/letsencrypt/example.com/domain.csr

Set up the challenge directory so it works for both Apache and when starting a temporary python SimpleHTTPServer.

mkdir -p /var/www/challenges/.well-known
ln -s /var/www/challenges /var/www/challenges/.well-known/acme-challenge

Put this in a Apache conf file so all your sites use the same challenge directory.

# Letsencrypt/acme challenges directory
Alias /.well-known/acme-challenge /var/www/challenges

Get the certificate from Let’s Encrypt.

python acme_tiny.py --account-key /etc/ssl/letsencrypt/account.key --csr /etc/ssl/letsencrypt/domain.csr --acme-dir /var/www/challenges > /etc/ssl/letsencrypt/example.com/signed.crt

As you see in my cron script below I have set up a acme user for running the acme tiny script. The acme user only have access to exactly what it needs.

Download Let’s Encrypt intermediate cert and create a fullchain file with it and the signed cert.

wget -O - https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem > /etc/ssl/letsencrypt/chain.pem
cat /etc/ssl/letsencrypt/example.com/signed.crt /etc/ssl/letsencrypt/chain.pem > fullchain.pem

Just to make sure you can verify the cert with this command.

openssl verify -CAfile /etc/ssl/letsencrypt/chain.pem /etc/ssl/letsencrypt/example.com/signed.crt

Set up Apache

General SSL configurations, see Generate Mozilla Security Recommended Web Server Configuration Files.

<IfModule mod_ssl.c>
  SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
  SSLCipherSuite ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256
  SSLHonorCipherOrder on
  SSLCompression off
  SSLUseStapling on
  SSLStaplingResponderTimeout 5
  SSLStaplingReturnResponderErrors off
  SSLStaplingCache shmcb:${APACHE_RUN_DIR}/ocsp_scache(128000)
  SSLSessionCache shmcb:${APACHE_RUN_DIR}/ssl_scache(512000)
  SSLSessionCacheTimeout 300
</IfModule>

Apache vhost settings.

<IfModule mod_ssl.c>
  SSLEngine on
  SSLCertificateFile /etc/ssl/letsencrypt/example.com/signed.crt
  SSLCertificateKeyFile /etc/ssl/letsencrypt/domain.key
  SSLCertificateChainFile /etc/ssl/letsencrypt/chain.pem
  <IfModule mod_headers.c>
    Header always set Strict-Transport-Security: "max-age=15768000"
  </IfModule>
</IfModule>

Works equally well for postfix and dovecot

This is for postfix:

smtpd_tls_cert_file=/etc/ssl/letsencrypt/example.com/signed.crt
smtpd_tls_key_file=/etc/ssl/letsencrypt/domain.key
smtpd_tls_CAfile=/etc/ssl/letsencrypt/chain.pem

This is for dovecot:

ssl_cert = </etc/ssl/letsencrypt/example.com/fullchain.pem
ssl_key = </etc/ssl/letsencrypt/domain.key

Automatic renewal

I have built a cron script that handles this on my systems and set it to run every month. Got some good ideas from neurobin/letsacme: A tiny script to issue and renew TLS/SSL certs from Let’s Encrypt (interesting fork of acme-tiny).

Here is an example version of the cron script that I place in /etc/cron.monthly. It starts of by looking if there are something running on port 80, i.e. a web server, and if nothing is there it starts up a temporary python SimpleHTTPServer and temporary open up port 80. This is so I can use the same script on my web and my mail servers e.g.

#!/bin/sh
# Script to update letsencrypt cert every month.

# Check if we have a web server running.
PORT80=$(lsof -ti :80 | wc -l)

# If no web server then start one and open port 80.
if [ $PORT80 = 0 ]; then
  cd /var/www/challenges
  nohup python -m SimpleHTTPServer 80 > /dev/null 2>&1 &
  iptables -A INPUT -i venet0 -p tcp --dport 80 -m conntrack --ctstate NEW -j ACCEPT
  ip6tables -A INPUT -i venet0 -p tcp --dport 80 -m conntrack --ctstate NEW -j ACCEPT
fi

# Get a updated certificate.
while true; do
  if sudo -u acme /usr/bin/python /usr/local/bin/acme_tiny.py --account-key /etc/ssl/letsencrypt/account.key \
       --csr /etc/ssl/letsencrypt/example.com/domain.csr \
       --acme-dir /var/www/challenges \
       > /etc/ssl/letsencrypt/example.com/signed_new.crt \
       2>> /var/log/acme_tiny.log
  then
    wget -O - https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem > /etc/ssl/letsencrypt/chain_new.pem
    # Check that the cert is valid.
    if openssl verify -CAfile /etc/ssl/letsencrypt/chain_new.pem \
        /etc/ssl/letsencrypt/example.com/signed_new.crt
    then
      mv -f /etc/ssl/letsencrypt/chain_new.pem /etc/ssl/letsencrypt/chain.pem
      mv -f /etc/ssl/letsencrypt/example.com/signed_new.crt /etc/ssl/letsencrypt/example.com/signed.crt
      cat /etc/ssl/letsencrypt/example.com/signed.crt /etc/ssl/letsencrypt/chain.pem > /etc/ssl/letsencrypt/example.com/fullchain.pem
      echo ""
      echo "[Success] Acme tiny successfully renewed certificate."
      echo ""

      systemctl restart apache2
      systemctl restart dovecot
      systemctl restart postfix

    else
      echo "[Error] Acme tiny have problems."
    fi
    break
  else
    # Sleep for max 9999 seconds, then try again.
    sleep `tr -cd 0-9 < /dev/urandom | head -c 4`
    echo "[Notice] Acme tiny retry triggered."
  fi
done

# Stop temp web server and close port 80 if needed.
if [ $PORT80 = 0 ]; then
  iptables -D INPUT -i venet0 -p tcp --dport 80 -m conntrack --ctstate NEW -j ACCEPT
  ip6tables -D INPUT -i venet0 -p tcp --dport 80 -m conntrack --ctstate NEW -j ACCEPT
  pkill -f SimpleHTTPServer
fi

In my playbook it’s a template file that look like this:

#!/bin/sh
# Script to update letsencrypt cert every month.

# Check if we have a web server running.
PORT80=$(lsof -ti :80 | wc -l)

# If no web server then start one and open port 80.
if [ $PORT80 = 0 ]; then
  cd {{ acme_challenge_dir }}
  nohup python -m SimpleHTTPServer 80 > /dev/null 2>&1 &
  iptables -A INPUT -i {{ ansible_default_ipv4.interface }} -p tcp --dport 80 -m conntrack --ctstate NEW -j ACCEPT
  ip6tables -A INPUT -i {{ ansible_default_ipv6.interface }} -p tcp --dport 80 -m conntrack --ctstate NEW -j ACCEPT
fi

# Get a updated certificate.
while true; do
  if sudo -u acme /usr/bin/python /usr/local/bin/acme_tiny.py --account-key {{ acme_certs_dir }}/account.key \
       --csr {{ acme_certs_dir }}/{{ acme_domains[0] }}/domain.csr \
       --acme-dir {{ acme_challenge_dir }} \
       > {{ acme_certs_dir }}/{{ acme_domains[0] }}/signed_new.crt \
       2>> /var/log/acme_tiny.log
  then
    wget -O - https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem > {{ acme_certs_dir }}/chain_new.pem
    # Check that the cert is valid.
    if openssl verify -CAfile {{ acme_certs_dir }}/chain_new.pem \
        {{ acme_certs_dir }}/{{ acme_domains[0] }}/signed_new.crt
    then
      mv -f {{ acme_certs_dir }}/chain_new.pem {{ acme_certs_dir }}/chain.pem
      mv -f {{ acme_certs_dir }}/{{ acme_domains[0] }}/signed_new.crt {{ acme_certs_dir }}/{{ acme_domains[0] }}/signed.crt
      cat {{ acme_certs_dir }}/{{ acme_domains[0] }}/signed.crt {{ acme_certs_dir }}/chain.pem > {{ acme_certs_dir }}/{{ acme_domains[0] }}/fullchain.pem
      echo ""
      echo "[Success] Acme tiny successfully renewed certificate."
      echo ""

{% for acme_service in acme_services %}
      systemctl restart {{ acme_service }}
{% endfor %}

    else
      echo "[Error] Acme tiny have problems."
    fi
    break
  else
    # Sleep for max 9999 seconds, then try again.
    sleep `tr -cd 0-9 < /dev/urandom | head -c 4`
    echo "[Notice] Acme tiny retry triggered."
  fi
done

# Stop temp web server and close port 80 if needed.
if [ $PORT80 = 0 ]; then
  iptables -D INPUT -i {{ ansible_default_ipv4.interface }} -p tcp --dport 80 -m conntrack --ctstate NEW -j ACCEPT
  ip6tables -D INPUT -i {{ ansible_default_ipv6.interface }} -p tcp --dport 80 -m conntrack --ctstate NEW -j ACCEPT
  pkill -f SimpleHTTPServer
fi