Automating deployment of Let's Encrypt certificates.

Jan 06 2018

A couple of weeks back, somebody I know asked me how I went about deploying SSL certificates from the Let's Encrypt project across all of my stuff.  Without going into too much detail about what SSL and TLS are (but here's a good introduction to them), the Let's Encrypt project will issue SSL certificates to anyone who wants one, provided that they can prove somehow that they control what they're cutting a certificate for.  You can't use Let's Encrypt to generate a certificate for google.com because they'd try to communicate with the server (there isn't any such thing but bear with me) google.com to verify the request, not be able to, and error out.  The actual process is complex and kind of involved (it's crypto so this isn't surprising) but the nice thing is that there are a couple of software packages out there that automate practically everything so all you have to do is run a handful of commands (which you can then copy into a shell script to automate the process) and then turn it into a cron job.  The software I use on my systems is called Acme Tiny, and here's what I did to set everything up...

Let's assume that we have a brand new Linux machine running someplace called www.example.com, which you have root access to and you want to set up an HTTPS-enabled web server on.  I'm going to assume that you're going to use Nginx because it's fast and lightweight, but you should be able to use these instructions with any other web server with minimal modifications.  The first thing you're going to want to do is generate a new set of Diffie-Hellman parameters to harden SSL and TLS to the Logjam vulnerability, which is the only known way of fixing it.  I use the process documented here, and it's quite straightforward.  Sudo to the root user and do this:

me@www$ sudo -s
Password: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
root@www# openssl dhparam -out /etc/ssl/dhparams.pem 2048

Now, this process can be quick or it can be very slow indeed, so after you kick it off you may as well go and get a cup of coffee.  When it's done, you're going to need to modify the Nginx configuration file that defines the default site on the server (Nginx can serve up any number of websites from the same box; it's best to break each out into a separate sitefile, and if you harden the default file you can copy it as many times as you need to make it easier to stand up new sites).  Make a backup copy of that file and use your favorite text editor to edit /etc/nginx/sites-enabled/default and make the following changes in the server {} block:

server {
    ....
    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_ciphers ECDHE-RSA-AES256-SHA:DHE-RSA-AES256-SHA:DHE-DSS-AES256-SHA:D
HE-RSA-AES128-SHA:DHE-DSS-AES128-SHA;
    ssl_prefer_server_ciphers on;
    ssl_dhparam /etc/ssl/dhparam.pem;
    ....
}

Technically, you really don't want to use SSL anymore because it's so broken as to be obsolete now, you want to use TLS only.  Don't worry, it's not going to break anything (nothing anyone cares about or is likely to encounter, to be honest).  You're going to need a directory for the Let's Encrypt service and Acme Tiny to use to handshake with each other, so we need to create one and add a directive in the default Nginx sitefile so it can find it.  First, the directory:

root@www# mkdir -p /srv/html/challenges

Now edit /etc/nginx/sites-enabled/default and add the following to the end of the server {} block: 

location /.well-known/acme-challenge/ {
    alias /srv/html/challenges/;
    try_files $uri $uri/ =404;
}

What this does is tell the server "Hey - whenever somebody on the Net asks you for the directory /.well-known/acme-challenge/, I want you to look in your file system under /srv/html/challenges.  If a file can't be found, throw back a 404 error."

Reload Nginx's configuration so your changes will take effect:

root@www# /usr/sbin/nginx -s reload

Now, you want to add an unprivileged user account that Acme Tiny will run under.  You don't want a system account because you're going to need to do things with it manually first, and then you're going to have it run a shell script periodically, and you're going to need a real user environment for that.

root@www# adduser /home/letsencrypt --gecos "Let's Encrypt" letsencrypt
root@www# passwd -l letsencrypt
root@www# chown letsencrypt:letsencrypt /srv/html/challenges
root@www# su - letsencrypt
letsencrypt@www$ 

I recommend setting up a subdirectory to store the certificates for each website the server will host.  This makes it easier to keep everything straight in the long run, plus it makes it easy to automate everything later.

letsencrypt@www$ mkdir www

Install the Acme Tiny utility (hereafter referred to by its filename, acme_tiny.py)

# Broken up into two lines so you don't have to scroll around.
letsencrypt@www$ wget https://raw.githubusercontent.com/diafygi/acme-tiny/master
    /acme_tiny.py

letsencrypt@www$ chmod 0755 acme_tiny.py

The process for getting the certificate is a little fiddly the first time around, but if you run through it manually once you'll understand it better.  In truth, you'll have to do it manually the first time every time you add a new site to the server so you'll actually be automating certificate renewals.  More on that later.  Generate an account key to uniquely identify the site.  You can re-use it for all sites on the server but I recommend against it because it adds complexity that you don't need (I made that mistake and it made a mess that took weeks to straighten out.

letsencrypt@www$ openssl genrsa 4096 > www/account.key

Generate a private key for the site:

letsencrypt@www$ openssl genrsa 4096 > www/domain.key

Generate a certificate signing request for the new website.  acme_tiny.py will use this to ask Let's Encrypt to sign (think "validate" to simplify things a bit) the new certificate:

letsencrypt@www$ openssl req -new -sha256 -key www/domain.key
    -subj "/CN=www.example.com" > www/domain.csr

Now, use acme_tiny.py to get the new, signed SSL certificate for your site:

letsencrypt@www$ ./acme_tiny.py --account-key www/account.key --csr www/domain.csr
    --acme-dir /var/www/challenges > www/signed.crt

(acme_tiny.py needs to be able to read from and write to the /var/www/challenges directory, that's why you chown'd it earlier.)

Now, here's something fiddly that's a pain in the ass, which is why you're going to want to automate the process later.  Due to the fact that we're using Nginx in this example, we need to append the Let's Encrypt intermediate certificate to our shiny new SSL certificate.  In the immortal words of my namesake, I'll explain later:

letsencrypt@www$ wget -O- https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem
    > www/intermediate.pem
letsencrypt@www$ cat www/signed.crt www/intermediate.pem > www/chained.pem

What you are doing is downloading the latest iteration of the Let's Encrypt intermediate cert using wget, copying the contents of www/signed.crt into a new file (www/chained.pem), and then sticking the contents of www/intermediate.pem onto the end of www/chained.pem.  UNIX afficionados are undoubtedly grinding their teeth at my less technically accurate but easier to understand if you're not a UNIX nerd explanation, but the final result is the same.

letsencrypt@www$ exit
root@www# cd /etc/nginx/sites-enabled

Now we need to make a few more changes to our /etc/nginx/sites-enabled/default config file to finish enabling HTTPS.  Use your favorite text editor to open that file and, for each configuration line below, search for a matching line starting with the same directive (ssl_certificate, server_name, etc) and edit the rest to match.  If a line doesn't exist you'll have to add it manually.

server {
    listen 443;
    server_name www.example.com;
    ssl on;
    ssl_session_cache shared:SSL:10m;
    ssl_session timeout 10m;
    ssl_certificate /home/letsencrypt/www/chained.pem;
    ssl_certificate_key /home/letsencrypt/www/domain.key;
    ...
}

Reload Nginx's configuration again so your changes will take effect:

root@www# /usr/sbin/nginx -s reload

If everything worked, you should have a running Nginx install listening on port 443/TCP (HTTPS), so you should be able to hit https://www.example.com/ with your web browser and get your frontpage.

Remember when I said that Let's Encrypt certs are only good for 90 days?  This means that you'll need to repeat part of the process every couple of weeks to get a new signed certificate.  The official acme_tiny.py documentation suggests doing this on the first of each month; I'm a bit more conservative in this respect, I renew my certs every three months (give or take - sometimes things get a bit glitchy and I have to do it by hand anyway, so maybe I should split the difference and chance it to every two months).

I've taken the script that I run on my servers to renew their Let's Encrypt certificates, cut it down a bit to make it easier to understand, and added it to one of my Github repositories.  Here it is.  There is no warranty, read it before you use it.  You'll have to make one more change to your system for it to work: You'll need to give the letsencrypt user sudo access to so it can restart Nginx (and optionally any other servers that use Let's Encrypt certs).  As the root user, you'll need to create a file /etc/sudoers.d/letsencrypt that contains the following text:

letsencrypt ALL=(ALL) NOPASSWD: /usr/sbin/nginx -t
letsencrypt ALL=(ALL) NOPASSWD: /bin/systemctl restart nginx

# Other services would be specified this way.
# For example, the Prosody XMPP server would need a directive like this:
# letsencrypt ALL=(ALL) NOPASSWD: /bin/systemctl restart prosody

This will let the letsencrypt user use the sudo utility in a very limited way to do its job (namely restarting nginx and testing its configuration files).

Now you need to set up a cron job that'll run your script (let's say it's /home/letsencrypt/renew_cert.sh).  You'll need to jump back to the letsencrypt user and edit that account's cron table:

root@www# su - letsencrypt
letsencrypt@www$ crontab -e

Here's one idea of how to time automated renewals.  Let's say you want it to happen on the 23rd of every month:

# We want the output of this script mailed to us every month:
MAILTO="me@example.com"

# At midnight, on the 23rd of every month, regardless of the day of week it
# falls on, run the script /home/letsencrypt/renew_cert.sh.

# min hour day-of-month month day-of-week command
0 0 23 * * /home/letsencrypt/renew_cert.sh

Save and quit and you're good to go.

If you found this tutorial useful, please make a donation to the Electronic Frontier Foundation.  They went above and beyond the call of duty to set up the Let's Encrypt project and talk many web browser vendors and SSL/TLS libraries into adding it as a recognized CA to their products.