Dev Ups

Published 2022-03-06 in certbot

Getting started with Nginx, TLS, and Certbot

This post describes doing everything from the (Ubuntu) shell, from first principles. It grew out of the notes I made for setting up my previous website. It uses Certbot to manage TLS certificates. Certbot enables others to trust our domain name. In this tutorial I'll demonstrate using a free subdomain from Azure. Certbot has practical uses in securing commercial sites also.

This post continues from Securing a LAMP VPS. Although we are starting from scratch in both articles, they follow an equivalent path. This post focuses on Lets Encrypt and Certbot using Nginx. The previous post focussed on the LAMP stack. To use Apache here we'll need to modify the installation and initialisation of Certbot. See the Apache version if you want to use Apache.

Introduction

Pre-requisite is a working Ubuntu installation, I'll leave securing your SSH access to you. For added safety, we want UFW:

apt update
apt install -y nginx jq
ufw default deny
ufw app list
ufw allow 'Nginx HTTPS'
ufw allow 'Nginx HTTP'
ufw allow 'OpenSSH'
ufw status
ufw status numbered
#ufw delete x or y  # delete numbered entries is easier than named entries
# Enable last of all to avoid race condition and lock out.
# Check you can SSH in before leaving current shell.
ufw enable
#iptables -L || iptables -S # List rules in long and short format.

We need to allow port 80 through so Certbot can complete initial challenge-validation using it.

Illustration in Azure

Azure provides a different IP address for each VM we create there. This helps us learn about Certbot without getting rate limited.

We need to allow port 80, at least until Certbot has validated its challenges to our domain ownership. In Azure, it's most expedient to do this whilst setting up an Azure VM, in the basics tab:

Azure VM basics tab. Port options

We should check that curl succeeds from an external address to our server's public IP, before invoking Certbot. Nginx provides a default welcome page upon installation.

We can get a DNS name by going to the VM on the Azure Portal and clicking underneath (or near) "DNS name" where it is initially "Not configured":

VM menu DNS not configured

This takes us to the settings for the Azure PIP (public IP address). From here we must:

  1. Choose a "DNS name label" (subdomain name). Note the suffix under the line that is appended to make the FQDN.
  2. Click Save.

PIP menu DNS label and static address

That's all the clicking around the portal needed for now. Eventually, you'll want to apply what you can learn here to your regular VPS.

Continuing in the shell

We want to set 3 convenience variables for the shell, or script:

  • domain_to_secure, ie domain name.
  • document_root is where requests to the domain name map to on the host's file system.
  • newusername originally I was creating a user to manage the server, for demonstration purposes it should be the user you shell into the VPS as.

If you aren't using Azure, I hope those explanations are clear enough for you to substitute. If you are using Azure, this script will help set the variables correctly:

# subdomain to be unique, and what you set inside of DNS name label for an Azure VM:
subdomain="porejemplo"
az_region=`curl -H Metadata:true --noproxy "*" "http://169.254.169.254/metadata/instance?api-version=2021-02-01" | jq -r .compute.location`
domain_to_secure="$subdomain.$az_region.cloudapp.azure.com"
echo $domain_to_secure
document_root="/var/www/${domain_to_secure}/html"
newusername="azureuser"

Provide the necessary files and folders for Nginx:

mkdir -p ${document_root}
chown -R $newusername:$newusername ${document_root}
chmod -R 755 ${document_root}
echo "hello <sub>world</sub> from $domain_to_secure" > /var/www/${domain_to_secure}/html/index.html

cat << EOF > /etc/nginx/sites-available/${domain_to_secure}
server {
        listen 80;

        root ${document_root};
        index index.html index.htm;

        server_name ${domain_to_secure} www.${domain_to_secure};

        location / {
                try_files \$uri \$uri/ =404;
        }
}
EOF
cat /etc/nginx/sites-available/${domain_to_secure}

We need† port 80 so Certbot can provide SSL on port 443. If we specify the --hsts option, Certbot will update this to prevent unencrypted HTTP. $uri is how Nginx refers to the rest of the URL path after the domain, Certbot needs it. See /etc/nginx/sites-available/default, for the syntax we want. Dollar signs are escaped in the above heredoc, where appropriate. cat it back it to make sure it looks right.

†I've seen Certbot modify server configurations, temporarily, whilst performing its validations; it could open port 80 on your server for you.

We need to link our newly available and unlink our old sites:

ln -s /etc/nginx/sites-available/${domain_to_secure} /etc/nginx/sites-enabled/
rm /etc/nginx/sites-enabled/default
sudo systemctl restart nginx

Introducing Certbot

Let's Encrypt and configure for secure-only access. This is the interactive way:

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d silverbullets.co.uk -d www.silverbullets.co.uk
# /var/log/letsencrypt/letsencrypt.log
# /etc/letsencrypt/renewal/porejemplo.eastus.cloudapp.azure.com.conf
# Please choose whether or not to redirect HTTP traffic to HTTPS, removing HTTP access.
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# 1: No redirect - Make no further changes to the webserver configuration.
# 2: Redirect - Make all requests redirect to secure HTTPS access. Choose this for
# new sites, or if you're confident your site works on HTTPS. You can undo this
# change by editing your web server's configuration.
# 2 redirect

There are two options shown in the output from Certbot above. No redirect and Redirect.

  • No redirect: causes Certbot to expand the server block listening on port 80 to listen on port 443. This still earns an A from ssllabs.com! We can do better, and so can Certbot.
  • Redirect: creates another server block listening to port 80 and forces all matching requests to redirect to use https. The initial listen 80 line is gone from the block listening for port 443 (HTTPS). Certbot intends to make this the default in future releases.

Hopefully that worked for you. If not, it's worth checking the domain at server-daten.de. The sections "9. Certificates" and "10. Last Certificates - Certificate Transparency Log Check" are particularly useful. In 10, try to check the issuer organisation, for "Let's Encrypt". The site is asynchronous, you need to submit your URL then wait, and "find" it. It will update the sections as results come in. This will also grade your site. sudo certbot --nginx -d silverbullets.co.uk gets a B, citing "Missing HSTS or Cookie-warnings":

B

We earned a B in server-daten, because of no hsts and no redirect to/from www. subdomain.

The same site earned an A at ssllabs.com, https://www.ssllabs.com/ssltest/analyze.html?d=porejemplo.eastus.cloudapp.azure.com:

ssllabs, grade a, certbot using neither hsts nor redirect

Adding --hsts (to prohibit HTTP connections) causes Certbot to add to the HTTPS server block:

add_header Strict-Transport-Security "max-age=31536000" always; # managed by Certbot

This gets us the A grade at server-daten.de:

A

Experimenting with Certbot

As a rate limited service, with a quota of 5 or 7 requests per week, per IP, it's far safer to experiment on somebody else's IP address; Azure's.

To delete certificates and try again was not afforded by my private VPS. I now realise I could back up the certificates and restore them.

certbot certificates
certbot delete

We must then rollback /etc/nginx/sites-available/${domain_to_secure} by reapplying the original heredoc above.

In 2022, we can script this (for Azure do not add the final -d www. subdomain option):

certbot --nginx --agree-tos --email <for-expiry-notification> -n --redirect --hsts -d ${domain_to_secure} -d www.${domain_to_secure}

Renewing with Certbot

Certbot includes automated certificate renewal (check with systemctl list-timers). There is no need to create a hook to reload Nginx. The Certbot service doesn't reload the web server.

/etc/letsencrypt/renewal-hooks/deploy is a directory containing scripts run in alphabetical order upon successful renewal. Its sister directory, post runs after each attempted renewal, which is equivalent to the --renew-hook argument specified in the unit file. They are empty for me, so evidently unnecessary for keeping the certificate valid.

Tests

Obviously this depends on what you're expecting. Best to automate with curl, not least because it isn't caching as much as in a browser.

Redirect to HTTP:

azureuser@cedric:~$ curl cedricsite.westeurope.cloudapp.azure.com
<html>
<head><title>301 Moved Permanently</title></head>
<body>
<center><h1>301 Moved Permanently</h1></center>
<hr><center>nginx/1.18.0 (Ubuntu)</center>
</body>
</html>

Response from functioning HTTPS:

azureuser@cedric:~$ curl https://cedricsite.westeurope.cloudapp.azure.com
hello <sub>world</sub> from cedricsite.westeurope.cloudapp.azure.com

Testing with the browser is another solution if you can be sure you're avoiding stale cache hits. Getting a response, the state of the padlock (a red strike through indicating unsecured HTTP or a problem with the certificate chain) and specific details of the certificate are all easily observable.

Use with existing certs

Besides the default and renew arguments to Certbot, we can also specify certonly. This obtains, or renews a certificate without installing it. This could then form part of a script to certify a server that isn't currently managed by Certbot.

We can read the certificate's expiry without Certbot. This is useful for gatekeeping access to the rate-limited letsencrypt service:

:~# openssl x509 -dates -noout < /etc/letsencrypt/live/cedricsite.westeurope.cloudapp.azure.com/cert.pem 
notBefore=Mar  5 14:24:13 2022 GMT
notAfter=Jun  3 14:24:12 2022 GMT
:~# openssl x509 -dates -noout < /etc/letsencrypt/live/cedricsite.westeurope.cloudapp.azure.com/fullchain.pem 
notBefore=Mar  5 14:24:13 2022 GMT
notAfter=Jun  3 14:24:12 2022 GMT
:~# openssl x509 -dates -noout < /etc/letsencrypt/live/cedricsite.westeurope.cloudapp.azure.com/chain.pem 
notBefore=Sep  4 00:00:00 2020 GMT
notAfter=Sep 15 16:00:00 2025 GMT