Auto-renew letsencrypt certificates

Let's Encrypt is a great project that aims to increase security in the web by making it easy and cheap (free, in fact) to obtain SSL certificates. Part of their aim is to make sure web servers are configured correctly.

This article describes how to use acme_tiny.py, a small Python script that implements the ACME protocol to get a certificate for your domains. Please see the acme_tiny.py documentation on how to set it up and how to get the authentication key for the Let's Encrypt server.

Creating a key and a Certificate Signing Request

One cool thing about Let's Encrypt is that they allow more than one domain per certificate. You can add any number of domains in the SAN section. For this you need to enumerate all domains in the subjectAltName value in the [SAN] section of the openssl configuration file, e.g. this:

[SAN]
subjectAltName=DNS:example.com,DNS:www.example.com,DNS:example.net,DNS:www.example.net

The following command creates a secret key and a CSR. You'll need to change the values in the -subj option to your location and your details. Use the primary domain as the CN. If the -subj option is not used then openssl will query those values interactively.

openssl req \
    -new -newkey rsa:2048 -sha256 -nodes \
    -keyout privkey1.pem -out csr.pem \
    -subj "/C=UK/ST=Some State/L=Some Place/O=example.com/emailAddress=webmaster@example.com/CN=example.com" \
    -reqexts SAN

Submitting the CSR to the Let's Encrypt CA

The acme_tiny.py command needs the Let's Encrypt account key, the CSR and a webroot directory (acme-dir to submit the request). The webroot directory is the path on your file system to the top-level directory of your website, plus the directory .well-known/acme-challenge, in other words, the path that maps to the URL http://example.com/.well-known/acme-challenge on your server.

python /path/to/acme_tiny.py \
    --account-key /path/to/account.key \
    --csr csr.pem \
    --acme-dir /path/to/acme_dir > /path/to/cert.pem

For each domain in the SAN list the acme_tiny.py script will store a file in the webroot directory to prove that you control that domain.

Note that each domain you submit must be accessible both from the internet and from the computer where the acme_tiny.py script is run.

Automating it all

The following script automates the process described above. To use the script, change the variables country, state, town and email and call it with a list of domains you want to include in the certificate, separated by space.

#!/bin/sh
#
# Wrapper script for the acme_tiny.py client to generate a server certificate in
# manual mode. It uses openssl to generate the key and does not modify the
# server configuration.
#
# Call once to create a private key and a CSR and a first certificate:
#   make-cert.sh -r example.com,www.example.com,mx.example.com
#
# From then on, renew the certificate like this:
#   make-cert.sh -u example.com
#
set -e

prog_name=`basename $0`
country=UK
state="Some State"
town="Some Place"
email=webmaster@example.com


usage() {
    echo "$prog_name [OPTIONS]"
}

help() {
    echo "$prog_name: generate or renew letsencrypt certificates"
    echo
    echo "OPTIONS"
    echo "    -h            print this help message."
    echo "    -r DOMAINS    register a list of domains, separated by comma."
    echo "    -u DOMAIN     update the certificate for a domain."
}

primary_dom=
domains=
while getopts hr:u: opt; do
    case "$opt" in
        h)  help
            exit 0;;
        r)  domains="$OPTARG";;
        u)  primary_dom="$OPTARG";;
        \?)     # unknown flag
            usage >&2
            exit 1;;
    esac
done
shift `expr $OPTIND - 1`
if [ $# -ne 0 ]; then
    echo >&2 "$0: error: no arguments allowed."
    exit 1
fi

if [ -z "$primary_dom" ]; then
    primary_dom=`echo "$domains" | cut -d',' -f1`
fi

if [ -z "$primary_dom" ]; then
    echo >&2 "$0: error: no primary domain. use -u"
    exit 1
fi

account_key="/root/.config/letsencrypt/account.key"
csr="/etc/ssl/local/$primary_dom/csr.pem"
challenge_dir="/path/to/domains/$primary_dom/.well-known/acme-challenge"
key="/etc/ssl/local/$primary_dom/privkey1.pem"
cert="/etc/ssl/local/$primary_dom/0000_cert.pem"
lev1="/etc/ssl/local/$primary_dom/0000_chain.pem"
chain="/etc/ssl/local/$primary_dom/0001_chain.pem"

if [ ! -f "$account_key" ]; then
    echo >&2 "$0: error: no account key"
    exit 1
fi

if [ ! -d "$challenge_dir" ]; then
    echo >&2 "$0: error: no challenge dir for domain $primary_dom"
    echo >&2 "$0:        please create a .well-known/acme-challenge directory in your web space"
    exit 1
fi

if [ ! -d `dirname "$csr"` ]; then
    echo >&2 "$0: error: ssl directory `dirname "$csr"` does not exist"
    exit 1
fi


tmpdir=
cleanup() {
    if [ -n "$tmpdir" -a -d "$tmpdir" ]; then
        rm -rf "$tmpdir"
    fi
}
trap cleanup HUP INT QUIT TERM EXIT
tmpdir=`mktemp -d -t letsencrypt.XXXXXXXX`

tmp_key="$tmpdir/privkey1.pem"
tmp_crt="$tmpdir/cert.pem"
tmp_lev1="$tmpdir/chain.pem"

if [ -n "$domains" ]; then
    domains=`echo $domains | sed -e 's/,/,DNS:/g'`

    sslcnf="$tmpdir/openssl.cnf"
    cat /etc/ssl/openssl.cnf > "$sslcnf"
    echo "[SAN]" >> "$sslcnf"
    echo "subjectAltName=DNS:$domains" >> "$sslcnf"

    openssl req \
        -new -newkey rsa:2048 -sha256 -nodes \
        -keyout "$tmp_key" -out "$csr" \
        -subj "/C=$country/ST=$state/L=$town/O=$primary_dom/emailAddress=$email/CN=$primary_dom" \
        -reqexts "SAN" \
        -config "$sslcnf"

    mv -i "$tmp_key" "$key"
fi


if [ ! -f "$key" ]; then
    echo >&2 "$0: error: no key for domain $primary_dom"
    exit 1
fi

if [ ! -f "$csr" ]; then
    echo >&2 "$0: error: no CSR for domain $primary_dom"
    exit 1
fi

curl --silent https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem \
    https://letsencrypt.org/certs/lets-encrypt-x1-cross-signed.pem > "$tmp_lev1"
python /path/to/acme_tiny.py --quiet --account-key $account_key --csr $csr --acme-dir $challenge_dir > "$tmp_crt"

if [ -f "$tmp_key" ]; then
    mv "$tmp_key" "$key"
fi

mv "$tmp_crt" "$cert"

mv "$tmp_lev1" "$lev1"
cat "$cert" "$lev1" > "$chain"

Updates

30 November 2016
Fixed a couple of bugs when creating a key for a new domain.