Let's encrypt with Dehydrated
Description
This document describes using the
Dehydrated
ACME
Let's Encrypt
client with dns-01 DNS challenge and wildcard domain ('*.domain.tld').
I use the Let's encrypt cert not just for my (Apache) webserver, but for
IMAP (UW-Imapd), SMTP (Exim) and VoIP (Asterisk) as well.
Note: I use Debian GNU Linux. Other systems might be slightly different.
Note: This setup is for ONE domain with wildcard as alias:
'My_Domain *.My_Domain'!
Certbot vs Dehydrated
If you want a plain vanilla HTTPS setup (one domain, no wildcard and web
challenge), you might be better of with Certbot.
But if you want to tweak things yourself, Dehydrated is easier to use.
Dehydrated is just a collection of shell scripts, which can be modified to
suit your needs.
My setup
I run my own server. The Linux server acts as a internet router; It contains a firewall, a VoIP PBX, webserver, nameserver, mailserver, timeserver and a web proxy server;
I use Bind as both resolver and authoritative name server with
split-horizon
DNS / views.
Note: More about the link setup
here.
Note: I do not use systemd. You may need to change a few things if you do.
Bind config
I use Bind with split horizon DNS / views; The IP addresses on the LAN are in
the internal view. The WAN link IPv4 address is in the external view.
There are two slaves. One does not support notify. So changes in the master
are not immediately propagated to all of the slaves! This is why the DNS
challenge does not involve the secondary nameservers.
In my EXTERNAL zonefile I added the following entries;
; Wildcard for acme * IN AAAA 2a10:3781:180b:1::1 IN A 45.83.234.41 ; acme Subdomain $ORIGIN _acme-challenge.sput.nl. @ IN NS ns.sput.nl.
The '_acme-challenge.sput.nl.' zone file has the following contents;
$TTL 86400 @ IN SOA ns.sput.nl. hostmaster.sput.nl. ( 2021100601 ; Serial 14400 ; Refresh time (4 hours) 7200 ; Retry (2 hours) 604800 ; Expire (7 days) 3600 ) ; Minimum (1 day) / Neg cache (1 hour) IN NS ns.sput.nl.
In the EXTERNAL view config I have the following;
// ACME zone "_acme-challenge.sput.nl" { type master; update-policy local; file "/var/lib/bind/db.acme.sput.nl"; };
Replace '2a10:3781:180b:1::1' and
'45.83.234.41' with your own IP addresses!
Replace 'ns.sput.nl' with your own nameserver!
Replace 'sput.nl' with your own domain!
Note: There are no slaves for the _acme-challenge subdomain.
Note: The directory '/var/lib/bind/' is used because it needs to be
writable by the Bind process owner ('drwxrwxr-x', 'root:bind').
Dehydrated config
/etc/dehydrated/domains.txt
This is 'My_Domain *.My_Domain';
sput.nl *.sput.nl
Replace 'sput.nl' with your own domain!
/etc/dehydrated/config
I added the following to the config;
CHALLENGETYPE="dns-01" WELLKNOWN="/var/lib/dehydrated/acme-challenges/" HOOK="/usr/local/sbin/hook.sh" HOOK_CHAIN="yes" CONTACT_EMAIL="webmaster at sput dot nl"
Replace 'webmaster at sput dot nl' with your own email address!
Apparently one needs HOOK_CHAIN="yes" for wildcard certs. I also have a wildcard in my DNS (both A an AAAA).
Scripts
update-certs.sh
/usr/local/sbin/update-certs.sh (click to download or right-click to save) is called from cron once a day;
#!/bin/bash # Set path PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" export PATH # Prime DNS cache host acme-v02.api.letsencrypt.org > /dev/null sleep 1 # Update certs if needed /usr/bin/dehydrated -a rsa -c -fc
You NEED '-a rsa' for imapd!
The '-fc' option enables full chain certs.
hook.sh
I use the example hook script that comes with Dehydrated. I copied it to
'/usr/local/sbin/' and made some changes.
I changed the following functions;
The changes are marked with my initials ('RvdP') and green text.
deploy_challenge
With HOOK_CHAIN="no" (the default) the hook script is called for
each domain.
With HOOK_CHAIN="yes" the script is called once for all
domains. So I added a little loop which cycles through these domains;
deploy_challenge() { # RvdP; Changed for HOOK_CHAIN="yes" # local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" # This hook is called once for every domain that needs to be # validated, including any alternative names you may have listed. # # Parameters: # - DOMAIN # The domain name (CN or subject alternative name) being # validated. # - TOKEN_FILENAME # The name of the file containing the token to be served for HTTP # validation. Should be served by your web server as # /.well-known/acme-challenge/${TOKEN_FILENAME}. # - TOKEN_VALUE # The token value that needs to be served for validation. For DNS # validation, this is what you want to put in the _acme-challenge # TXT record. For HTTP validation it is the value that is expected # be found in the $TOKEN_FILENAME file. # Simple example: Use nsupdate with local named # printf 'server 127.0.0.1\nupdate add _acme-challenge.%s 300 IN TXT "%s"\nsend\n' "${DOMAIN}" "${TOKEN_VALUE}" | \ # nsupdate -k /var/run/named/session.key # RvdP local argc=$# local argv=("$@") local NSIP="45.83.234.41" for (( j=2; j<argc; j+=3 )) do local DOMAIN="${argv[j - 2]}" TOKEN_FILENAME="${argv[j - 1]}" TOKEN_VALUE="${argv[j]}" printf 'server %s\nupdate add _acme-challenge.%s 300 IN TXT "%s"\nsend\n' "${NSIP}" "${DOMAIN}" "${TOKEN_VALUE}" | \ nsupdate -k /var/run/named/session.key done }
I use a split horizon DNS. The challenge uses the external nameserver.
'45.83.234.41' is the external IP address of my name server. It is NOT
in the internal view ACL.
Replace '45.83.234.41' with your own IP address!
clean_challenge
The changes are similar to those above.
clean_challenge() { # RvdP; Changed for HOOK_CHAIN="yes" # local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" # This hook is called after attempting to validate each domain, # whether or not validation was successful. Here you can delete # files or DNS records that are no longer needed. # # The parameters are the same as for deploy_challenge. # Simple example: Use nsupdate with local named # printf 'server 127.0.0.1\nupdate delete _acme-challenge.%s TXT "%s"\nsend\n' "${DOMAIN}" "${TOKEN_VALUE}" | \ # nsupdate -k /var/run/named/session.key # RvdP local argc=$# local argv=("$@") local NSIP="45.83.234.41" for (( j=2; j<argc; j+=3 )) do local DOMAIN="${argv[j - 2]}" TOKEN_FILENAME="${argv[j - 1]}" TOKEN_VALUE="${argv[j]}" printf 'server %s\nupdate delete _acme-challenge.%s 300 IN TXT "%s"\nsend\n' "${NSIP}" "${DOMAIN}" "${TOKEN_VALUE}" | \ nsupdate -k /var/run/named/session.key done }
Replace '45.83.234.41' with your own IP address!
deploy_cert
Here I call the script 'deploy-certs.sh';
deploy_cert() {
local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}"
# This hook is called once for each certificate that has been
# produced. Here you might, for instance, copy your new certificates
# to service-specific locations and reload the service.
#
# Parameters:
# - DOMAIN
# The primary domain name, i.e. the certificate common
# name (CN).
# - KEYFILE
# The path of the file containing the private key.
# - CERTFILE
# The path of the file containing the signed certificate.
# - FULLCHAINFILE
# The path of the file containing the full certificate chain.
# - CHAINFILE
# The path of the file containing the intermediate certificate(s).
# - TIMESTAMP
# Timestamp when the specified certificate was created.
# Simple example: Copy file to nginx config
# cp "${KEYFILE}" "${FULLCHAINFILE}" /etc/nginx/ssl/; chown -R nginx: /etc/nginx/ssl
# systemctl reload nginx
# RvdP, deploy certs for Apache, Asterisk, Exim and Imapd
/usr/local/sbin/deploy-certs.sh "${TIMESTAMP}"
}
invalid_challenge() and request_failure()
I also added an email in functions invalid_challenge() and request_failure().
deploy-certs.sh
/usr/local/sbin/deploy-certs.sh, which is called
from hook.sh deploys the certs.
You need the following directory to make this work;
/var/local/lib/certs/
This directory should NOT be world readable!
I use 'drwxr-s---', 'root:staff'.
The script copies the public and private keys to the configuration
directories of Apache, Asterisk and Exim, generates symlinks and restarts the
daemons. It also generates files for UW-Imapd in /etc/ssl/certs/.
Note: If you use systemd you need to change the restart lines from
'/etc/init.d/Daemon_Name reload' to
'systemctl reload Daemon_Name'.
E.G.: From '/etc/init.d/apache2 reload' to
'systemctl reload apache2'.
Replace 'sput.nl' with your own domain!
#!/bin/bash # Deploy let's encrypt certs # Files and dirs TIMESTAMP="${1}" CERT="fullchain-${TIMESTAMP}.pem" KEY="privkey-${TIMESTAMP}.pem" SRCDIR="/var/lib/dehydrated/certs/sput.nl" # Set later CURIMAPDHASH="" CURTIMESTAMP="" DSTDIR="" NEWIMAPDHASH="" copy_files() { cp "${SRCDIR}/${CERT}" "${DSTDIR}/" cp "${SRCDIR}/${KEY}" "${DSTDIR}/" cd "${DSTDIR}/" ln -sf "${CERT}" fullchain.pem ln -sf "${KEY}" privkey.pem } # Basic checks if [ -z "${TIMESTAMP}" ] then echo "No timestamp specified" echo "New cert not installed" exit 1 fi if ! [ -d "${SRCDIR}/" ] then echo "Directory ${SRCDIR}/ does not exist" echo "New cert not installed" exit 1 fi if ! [ -f "${SRCDIR}/${CERT}" ] then echo "Certificate ${CERT} does not exist" echo "New cert not installed" exit 1 fi if ! [ -f "${SRCDIR}/${KEY}" ] then echo "Private key ${KEY} does not exist" echo "New cert not installed" exit 1 fi # Current timestamp if [ -f "/var/local/lib/certs/current-timestamp" ] then CURTIMESTAMP=$( cat "/var/local/lib/certs/current-timestamp" ) if [ "${CURTIMESTAMP}" == "${TIMESTAMP}" ] then echo "Timestamp did not change" echo "New cert not installed" exit 1 fi else echo "Current timestamp file not found" fi # Current imapd cert hash if [ -f "/var/local/lib/certs/current-hash" ] then CURIMAPDHASH=$( cat "/var/local/lib/certs/current-hash" ) else echo "Current imapd cert hash file not found" fi # Apache DSTDIR="/etc/apache2/ssl" if [ -d "${DSTDIR}/" ] then copy_files sleep 1 # SysV; /etc/init.d/apache2 reload # Systemd; # systemctl reload apache2 else echo "Directory ${DSTDIR}/ does not exist" echo "New Apache cert not installed" fi # Asterisk DSTDIR="/etc/asterisk" if [ -d "${DSTDIR}/" ] then copy_files chmod 640 "${CERT}" chmod 640 "${KEY}" chown root:asterisk "${CERT}" chown root:asterisk "${KEY}" sleep 1 # SysV; /etc/init.d/asterisk reload # Systemd; # systemctl reload asterisk else echo "Directory ${DSTDIR}/ does not exist" echo "New Asterisk cert not installed" fi # Exim DSTDIR="/etc/exim4" if [ -d "${DSTDIR}/" ] then copy_files chmod 640 "${CERT}" chmod 640 "${KEY}" chown root:Debian-exim "${CERT}" chown root:Debian-exim "${KEY}" sleep 1 # SysV; /etc/init.d/exim4 reload # Systemd; # systemctl reload exim4 else echo "Directory ${DSTDIR}/ does not exist" echo "New Exim cert not installed" fi # Imapd DSTDIR="/etc/ssl/certs" if [ -d "${DSTDIR}/" ] then IMAPDCERT="imapd-${TIMESTAMP}.pem" > "${DSTDIR}/${IMAPDCERT}" chmod 640 "${DSTDIR}/${IMAPDCERT}" cp "${SRCDIR}/${KEY}" "${DSTDIR}/${IMAPDCERT}" cat "${SRCDIR}/${CERT}" >> "${DSTDIR}/${IMAPDCERT}" cd "${DSTDIR}/" ln -sf "${IMAPDCERT}" "imapd.pem" NEWIMAPDHASH="$( openssl x509 -noout -hash < imapd.pem ).0" if [ -L "${CURIMAPDHASH}" ] && [ "${NEWIMAPDHASH}" == "${CURIMAPDHASH}" ] then echo "Imapd cert hash not changed" else ln -sf imapd.pem "${NEWIMAPDHASH}" fi else echo "Directory ${DSTDIR}/ does not exist" echo "New Imapd cert not installed" fi # Store timestamp and hash echo "${TIMESTAMP}" > "/var/local/lib/certs/new-timestamp" if [ -n "${NEWIMAPDHASH}" ] then echo "${NEWIMAPDHASH}" > "/var/local/lib/certs/new-hash" fi sleep 1 /usr/local/sbin/remove-old-certs.sh
Note that UW-Imapd wants both the private- and public key in one file. In that order. There is also a hash of the cert, symlinking the cert.
remove-old-certs.sh
/usr/local/sbin/remove-old-certs.sh which is
called from deploy-certs.sh removes the old certs.
Replace 'sput.nl' with your own domain!
#!/bin/bash # Remove old let's encrypt certs # Files and dirs # Source dir SRCDIR="/var/lib/dehydrated/certs/sput.nl" # Time stamps TSTMPDIR="/var/local/lib/certs" NEWTSFILE="${TSTMPDIR}/new-timestamp" CURTSFILE="${TSTMPDIR}/current-timestamp" OLDTSFILE="${TSTMPDIR}/old-timestamp" # Imapd cert hashes NEWHSFILE="${TSTMPDIR}/new-hash" CURHSFILE="${TSTMPDIR}/current-hash" OLDHSFILE="${TSTMPDIR}/old-hash" # Set later CERTDIR="" CERT="" CURIMAPDHASH="" KEY="" NEWIMAPDHASH="" # Time stamps OLDTS=0 TIMESTAMP=0 remove_certs() { if [ -d "${CERTDIR}/" ] then if [ -f "${CERTDIR}/${CERT}" ] then rm "${CERTDIR}/${CERT}" else echo "File ${CERT} not found" fi if [ -f "${CERTDIR}/${KEY}" ] then rm "${CERTDIR}/${KEY}" else echo "File ${KEY} not found" fi else echo "Directory ${CERTDIR}/ not found" fi } # Basic checks if ! [ -d "${TSTMPDIR}/" ] then echo "Directory ${TSTMPDIR}/ not found" echo "Old cert not removed" exit 1 fi if ! [ -f "${NEWTSFILE}" ] then echo "New timestamp file not found" echo "Old cert not removed" exit 1 fi # Current timestamp if [ -f "${CURTSFILE}" ] then TIMESTAMP=$( cat "${CURTSFILE}" ) else echo "Old timestamp file not found" echo "Old cert not removed" # Probably first time; Prepare for next time mv "${NEWTSFILE}" "${CURTSFILE}" if [ -f "${NEWHSFILE}" ] then mv "${NEWHSFILE}" "${CURHSFILE}" else echo "New imapd hash file not found" fi exit 1 fi # If we got this far there are indeed old certs # Current and new imapd cert hashes if [ -f "${CURHSFILE}" ] then CURIMAPDHASH=$( cat "${CURHSFILE}" ) else echo "Old imapd hash file not found" fi if [ -f "${NEWHSFILE}" ] then NEWIMAPDHASH=$( cat "${NEWHSFILE}" ) else echo "New imapd hash file not found" fi # Current cert files CERT="fullchain-${TIMESTAMP}.pem" KEY="privkey-${TIMESTAMP}.pem" # Notification echo -n "Removing files from: " date -d "@${TIMESTAMP}" # Apache CERTDIR="/etc/apache2/ssl" remove_certs # Asterisk CERTDIR="/etc/asterisk" remove_certs # Exim CERTDIR="/etc/exim4" remove_certs # Imapd cert CERTDIR="/etc/ssl/certs" IMAPDCERT="imapd-${TIMESTAMP}.pem" if [ -f "${CERTDIR}/${IMAPDCERT}" ] then rm "${CERTDIR}/${IMAPDCERT}" else echo "File ${IMAPDCERT} not found" fi # Imapd cert hash if [ -n "${CURIMAPDHASH}" ] && [ -n "${NEWIMAPDHASH}" ] then # Both current and new hashes are set if [ "${CURIMAPDHASH}" == "${NEWIMAPDHASH}" ] then echo "Imapd cert hash not changed" else if [ -L "${CERTDIR}/${CURIMAPDHASH}" ] then rm "${CERTDIR}/${CURIMAPDHASH}" else echo "Link ${CURIMAPDHASH} not found" fi fi fi # Done; Update timestamp files if [ -f "${OLDTSFILE}" ] then OLDTS=$( cat "${OLDTSFILE}" ) rm "${OLDTSFILE}" fi mv "${CURTSFILE}" "${OLDTSFILE}" mv "${NEWTSFILE}" "${CURTSFILE}" # Update hash files if [ -f "${OLDHSFILE}" ] then rm "${OLDHSFILE}" fi if [ -f "${CURHSFILE}" ] then mv "${CURHSFILE}" "${OLDHSFILE}" fi if [ -f "${NEWHSFILE}" ] then mv "${NEWHSFILE}" "${CURHSFILE}" fi # Remove the next line when fully tested; exit 0 # Remove old files if [ "${OLDTS}" -ne 0 ] && [ -d "${SRCDIR}/" ] then cd "${SRCDIR}/" rm "cert-${OLDTS}.csr" rm "cert-${OLDTS}.pem" rm "chain-${OLDTS}.pem" rm "fullchain-${OLDTS}.pem" rm "privkey-${OLDTS}.pem" fi
After running these scripts, the directory '/var/local/lib/certs/' should contain the following files;
current-hash current-timestamp old-hash old-timestamp
Unless you run it for the first time, in which case the 'old' files do not exist.
The previous files in '/var/lib/dehydrated/certs/Your_Domain' are retained.
Files older than that are removed, provided you remove (or comment out) the
line 'exit 0' under
'Remove the next line when fully tested'.
Note that the '-gc' / '--cleanup' (archive) option isn't used.
Using the certs
The daemons use copies of the files generated by Dehydrated. Each daemon has it's set own of copies with different permissions.
Apache
In the config file of my https website (in the directory'/etc/apache2/sites-available/');
SSLEngine on SSLCertificateFile /etc/apache2/ssl/fullchain.pem SSLCertificateKeyFile /etc/apache2/ssl/privkey.pem
Note: You probably need to set the port to 443 as well.
'fullchain.pem' and 'privkey.pem' are in fact symlinks to fullchain-TimeStamp.pem and privkey-TimeStamp.pem ('1644143885' is the epoch timestamp);
8 -rw------- 1 root root 5934 2022-02-06 11:38 fullchain-1644143885.pem 0 lrwxrwxrwx 1 root root 24 2022-02-06 11:38 fullchain.pem -> fullchain-1644143885.pem 4 -rw------- 1 root root 3243 2022-02-06 11:38 privkey-1644143885.pem 0 lrwxrwxrwx 1 root root 22 2022-02-06 11:38 privkey.pem -> privkey-1644143885.pem
The 'deploy-certs.sh' script generates these with the right permissions.
Asterisk
In /etc/asterisk/sip.conf;
tlsenable=yes tlscertfile=/etc/asterisk/fullchain.pem tlsprivatekey=/etc/asterisk/privkey.pem
These are symlinks to files with mode 640, root:asterisk;
8 -rw-r----- 1 root asterisk 5934 2022-02-06 11:38 /etc/asterisk/fullchain-1644143885.pem 0 lrwxrwxrwx 1 root root 24 2022-02-06 11:38 /etc/asterisk/fullchain.pem -> fullchain-1644143885.pem 4 -rw-r----- 1 root asterisk 3243 2022-02-06 11:38 /etc/asterisk/privkey-1644143885.pem 0 lrwxrwxrwx 1 root root 22 2022-02-06 11:38 /etc/asterisk/privkey.pem -> privkey-1644143885.pem
Note: The files need to be readable by the process owner.
Exim
In /etc/exim4/conf.d/main/000_localmacros;
MAIN_TLS_ENABLE = 1 MAIN_TLS_CERTIFICATE = CONFDIR/fullchain.pem MAIN_TLS_PRIVATEKEY = CONFDIR/privkey.pem
These are symlinks to files with mode 640, root:Debian-exim;
8 -rw-r----- 1 root Debian-exim 5934 2022-02-06 11:38 /etc/exim4/fullchain-1644143885.pem 0 lrwxrwxrwx 1 root root 24 2022-02-06 11:38 /etc/exim4/fullchain.pem -> fullchain-1644143885.pem 4 -rw-r----- 1 root Debian-exim 3243 2022-02-06 11:38 /etc/exim4/privkey-1644143885.pem 0 lrwxrwxrwx 1 root root 22 2022-02-06 11:38 /etc/exim4/privkey.pem -> privkey-1644143885.pem
Note: 'Debian-exim' is the Exim process owner.
Imapd
I run imapd from inetd.
In /etc/inetd.conf;
#:MAIL: Mail, news and uucp services. imaps stream tcp nowait root /usr/sbin/tcpd /usr/sbin/imapd imap2 stream tcp nowait root /usr/sbin/tcpd /usr/sbin/imapd
The cert is in '/etc/ssl/certs/';
0 lrwxrwxrwx 1 root root 9 2021-10-08 19:06 xxxxxxxx.0 -> imapd.pem 12 -rw-r----- 1 root root 9177 2022-02-06 11:38 imapd-1644143885.pem 0 lrwxrwxrwx 1 root root 20 2022-02-06 11:38 imapd.pem -> imapd-1644143885.pem
'xxxxxxxx' is in fact an eight hex digit hash of the cert, generated by the
'deploy-certs.sh' script.
No additional configuration is required.