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;

sput.nl setup

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.