Date

A while ago, I wrote (but forgot to document) Yet Another ACME client in Python. You can see the code for this client here.

ACME is a protocol invented by the Let's Encrypt project for automatically requesting TLS certificates from a certificate authority. Other website describe both the protocol and the Let's Encrypt project in much more detail, but in essence they are an attempt to standardize the ad-hoc natural language instructions that most current certificate authorities use.

Let's Encrypt has an unusual feature in that its certificates have a short 90-day validity period and are designed to be automatically issued and renewed. To help with this automation, a huge number of ACME clients have been written by various people. However, various small issues made me unhappy with all of the clients I looked at, so I programmed my own.

This client is designed to only work with the way my particular web server is set up. The reason I felt I needed yet another client was because existing clients all seemed to be a little awkward to use with my particular setup involving many many virtual hosts that do not actually serve files but only redirects (and thus don't have a "web root" in the normal sense).

On my server, this script is run from a per-user crontab belonging to a special user acme-cert dedicated for running this script. The crontab is set up to invoke the following small shell script once a month (giving two whole months to notice that the script isn't working):

#!/bin/bash

set -eo pipefail
. /storage/certrenewal/venv/bin/activate
set -u

python /storage/certrenewal/le-rqou.py
sudo /bin/systemctl reload nginx.service
sudo /usr/sbin/postfix reload

This script loads a Python virtualenv containing the dependencies for this script, runs the script itself, and then reloads servers that use the certificate. Currently, this is only nginx and Postfix. This is allowed because the sudoers file contains the following lines:

acme-cert ALL=(ALL) NOPASSWD: /bin/systemctl reload nginx.service
acme-cert ALL=(ALL) NOPASSWD: /usr/sbin/postfix reload

If you examine the source code for this script, you will notice that it has the following constants at the top:

  • ACCOUNT_KEY_PATH - Account key JSON file. This file is created by the official Let's Encrypt client certbot. Only RSA keys are supported in this client. This client does not have the ability to generate this file.
  • CSR_PATH - CSR file. This file should be in PEM format and should contain a list of domains to issue for in the CN/subjectAltName fields.
  • REGISTRATION_EMAIL - The email address to associate with the account.
  • ACME_CHALLENGE_DIR - Common directory used for writing challenge files. This client only supports the http-01 challenge, and it requires all domains to server the .well-known/acme-challenge path from this directory. More on this later.
  • CERT_PATH_TMPL - A template to create a filename to save newly-issued certificates. The argument {} will be filled in with a date stamp. Old certificates are not automatically deleted in order to allow for easy rollback. There is currently not a mechanism to automatically prune old certificate files.
  • CERT_PATH_SYMLINK - A path to a symlink that will be updated to point to the latest issued certificate.
  • CHAIN_PATH - A path to a file where the certificate chain (issuers) will be saved.

As mentioned above, my server is set up to expect all virtual hosts to serve the .well-known/acme-challenge path from the same directory. This is achieved by a fragment similar to the following in the nginx configuration:

server {
    listen 80;
    listen [::]:80;

    server_name robertou.com  www.robertou.com
                robertou.net  www.robertou.net
                robertou.org  www.robertou.org
                robertou.mobi www.robertou.mobi
                robertou.me   www.robertou.me
                robertou.info www.robertou.info
                robertou.us   www.robertou.us;

    location = / {
        return 301 https://robertou.com;
    }

    location / {
        return 404;
    }

    location /.well-known/acme-challenge/ {
        alias /var/www/acme-challenge/;
    }
}

This fragment redirects the HTTP root of all domain variants to the HTTPS root of the "canonical" variant, refuses all paths other than the root (because there should never be a case where these paths get used as the site is HTTPS-only), and serves the .well-known/acme-challenge path from a fixed location in the filesystem for all domains.