Advanced dehydration
Back when i started using Let’s Encrypt I wanted a lightweight client. Danimo recommended dehydrated. Well it still had a different name back then. But details. So for years I had been a very happy camper with it. Then I learned that many daemons now support that you can run ECDSA and RSA certs at the same time and the server picks the right cert depending on the client. So we will solve this first.
Some basics
Dehydrated supports that you do not have to touch any example files that shipped with the distro package. This makes it easier to update the package because you do not have to merge any files during the upgrade.
Instead of changing /etc/dehydrated/config
we will create /etc/dehydrated/config.d/99-salt.sh
.
Yes the .sh
extension is important, but it does not have to be executable. So every setting we want to change we put there. My file looks like this:
CONTACT_EMAIL="me@example.com"
HOOK="${BASEDIR}/hooks.sh"
PRIVATE_KEY_RENEW="yes"
DOMAINS_TXT="${BASEDIR}/salt-domains.txt"
DOMAINS_D="${BASEDIR}/domains.d/"
OCSP_FETCH="yes"
Why we use ${BASEDIR}
for everything we will cover in the 2nd part when we learn about how to talk to multiple certificate authorities.
Multiple certificate types
The format of the domains.txt
file is well explain in the official documentation. We need to use alias aliases for the certificates as we need multiple certs for the same domain lists. The aliases can be any string but I can recommend to use a format that you can parse easily in your hooks.sh
script later.
Our /etc/dehydrated/salt-domains.txt
looks like
somehost.example.com example.com > example.com_rsa
somehost.example.com example.com > example.com_ecdsa
This gives us a simple a certificate with 2 hostnames in the subject alternative field. But the suffix at the end of the alias does not do any magic yet. For this we need the directory configured via DOMAINS_D
. This directory allows us to specify settings per certificate. In theory we could also use /etc/dehydrated/certs/<certname>/config
, but i have the tendenc to just do rm /etc/dehydrated/certs/<certname>/
when i want to reset a certificate. So let us store the settings in a safe place.
The default default key algorithm is nowadays using ECDSA. So we only need to change the type for the RSA certs.
We create a /etc/dehydrated/domains.d/example.com_rsa
with the following content:
KEY_ALGO=rsa
You can find the list of all settings you can override here. The last bit we have to do is adapt our hooks.sh
.
#!/usr/bin/bash
# install -o dehydrated -g dehydrated -m 0751 -d /etc/ssl/services
export DEPLOY_TARGET_DIRECTORY="/etc/ssl/services"
do_cert() {
local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}"
CERTDIR="$(dirname $FULLCHAINFILE)"
CERTTYPE="${CERTDIR##*_}"
DHFILE="${CERTDIR}/dhparams"
ECFILE="${CERTDIR}/ecparams"
if [ ! -e $DHFILE ] ; then
openssl dhparam -outform PEM -out "$DHFILE" "2048"
fi
cat "$FULLCHAINFILE" "$KEYFILE" "$DHFILE" > "${DEPLOY_TARGET_DIRECTORY}/${DOMAIN}.with.chain.pem.${CERTTYPE}"
}
do_ocsp() {
local DOMAIN="${1}" OCSPFILE="${2}" TIMESTAMP="${3}"
CERTDIR="$(dirname $OCSPFILE)"
CERTTYPE="${CERTDIR##*_}"
DEPLOYED_FILE="${DEPLOY_TARGET_DIRECTORY}/${DOMAIN}.with.chain.pem.${CERTTYPE}.ocsp"
echo "Deploying OCSP response for '${DOMAIN}' at '${TIMESTAMP}' '${DEPLOYED_FILE}'"
cp "${OCSPFILE}" "${DEPLOYED_FILE}"
}
deploy_cert() {
local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}"
do_cert "${DOMAIN}" "${KEYFILE}" "${CERTFILE}" "${FULLCHAINFILE}" "${CHAINFILE}"
}
deploy_ocsp() {
local DOMAIN="${1}" OCSPFILE="${2}" TIMESTAMP="${3}"
do_ocsp "${DOMAIN}" "${OCSPFILE}" "${TIMESTAMP}"
}
unchanged_cert() {
local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}"
do_cert "${DOMAIN}" "${KEYFILE}" "${CERTFILE}" "${FULLCHAINFILE}" "${CHAINFILE}"
}
<snip all the other functions, consult the hook.sh example that ship with dehydrated>
HANDLER="$1"; shift
if [[ "${HANDLER}" =~ ^(deploy_challenge|clean_challenge|sync_cert|deploy_cert|deploy_ocsp|unchanged_cert|invalid_challenge|request_failure|generate_csr|startup_hook|exit_hook)$ ]]; then
"$HANDLER" "$@"
fi
You do not have to follow the same filename format. I am a huge fan of haproxy and it wants the certificate type as suffix in the filename. The full file can be found here.
Voila we can trigger our dehydrated service an it should hopefully all work:
systemctl start dehydrated.service
And check with …
journalctl -u dehydrated.service
Well the latter part actually only works if you use our system improvements. Let us review those next
Systemd Improvements
The past
For a long time our service file in openSUSE looked like this:
[Unit]
Description=Certificate Update Runner for Dehydrated
ConditionPathExists=/etc/dehydrated/config
After=network-online.target
Wants=acmeresponder.socket
[Service]
Type=oneshot
ExecStartPost=-/usr/bin/find -L /etc/dehydrated/postrun-hooks.d -maxdepth 1 -executable -type f -exec {} \;
ExecStart=/usr/bin/dehydrated --cron
# dehydrated --cron will drop permissions and run critical code as dehydrated user.
User=root
Group=root
This gave us 2 features in one go
- We do not need sudo calls in hooks.sh to restart services as we put this now into scripts in
/etc/dehydrated/postrun-hooks.d
and then call those inExecStartPost
- As the comment said … dehydrated will drop the permissions which then will use su/sudo.
But we were never quite happy with this mixture of scopes. One of the major annoyances was that the journalctl
commandline above did not contain any output from dehydrated after dehydrated internally dropped the root permissions, as those are not part of the systemd scope of the service anymore.
Split all the services
As you might have guessed we just split the 2 parts into distinct service files:
/usr/lib/systemd/system/dehydrated.service
[Unit]
Description=Certificate Update Runner for Dehydrated
ConditionPathExists=/etc/dehydrated/config
After=network-online.target
Wants=acmeresponder.socket
PartOf=dehydrated.target
[Service]
Type=oneshot
ExecStart=/usr/bin/dehydrated --cron
User=dehydrated
Group=dehydrated
/usr/lib/systemd/system/dehydrated-postrun-hooks.service
[Unit]
Description=Postrun Hooks Runner for Dehydrated
ConditionPathExists=/etc/dehydrated/postrun-hooks.d
After=dehydrated.service
PartOf=dehydrated.target
[Service]
Type=oneshot
ExecStart=-/usr/bin/find -L /etc/dehydrated/postrun-hooks.d -maxdepth 1 -executable -type f -exec {} \;
User=root
Group=root
[Install]
RequiredBy=dehydrated.service
Now if we just use
systemctl enable dehydrated.timer
or
systemctl start dehydrated.service
It will only run the cron part but not the postrun hooks. For this we also need to enable the other unit:
systemctl enable dehydrated-postrun-hooks.service
So when ever the dehydrated.service is finished, it will automatically trigger the postrun hook service.
Multiple Certificate Authorities
Using Let’s Encrypt for your internet facing services is all great but what about services inside your home network or in a DMZ? Yes there are ways to get certificates for those via Let’s Encrypt, but I actually prefer to run an internal certificate authority for that. For a long time it meant running custom tools for special certificate handling tools. Many implementations were either really complex setups like boulder or not meant for production.
Now we have smallstep as a small certificate to run internally, which not only supports their custom protocol for certificate provisioning, but also the ACME protocol. Which leads us to a problem. What do we do when we need multiple CAs? Let’s Encrypt for the external services and smallstep for the internal services. It should be noted that you need at least version 0.7.0 to get it working with smallstep.
Lukas said that implementing multi CA support would be nice but also not just a quick change. So we started to look elsewhere.
If you call /usr/bin/dehydrated --cron --config /path/to/config
it will set the ${BASEDIR}
variable to /path/to
. Because we used ${BASEDIR}
in all our settings in 99-salt.sh
it will find all the files relative to our new home. So with this we can do
cp -av /etc/dehydrated /etc/dehydrated-internal
cd /etc/dehydrated-internal
rm -rv accounts/* certs/* domains.d/*
After the cleanup is done, we can point our new config in /etc/dehydrated-internal/config.d/99-salt.sh
to the smallstep instance by adding this line:
CA="https://smallstep.dmz.example.com/acme/acme/directory"
Once this is done we can register with the new CA and grab our initial certificates
su -s /bin/bash - dehydrated
dehydrated --register --accept-terms
dehydrated --cron
Last but not least … hooking this all up with systemd. For this we will use systemd’s nifty Instantiated Services. First we need some unit files:
Unit files
/usr/lib/systemd/system/dehydrated@.service
[Unit]
Description=Certificate Update Runner for Dehydrated
ConditionPathExists=/etc/dehydrated-%i/config
After=network-online.target
Wants=acmeresponder.socket
PartOf=dehydrated.target
[Service]
Type=oneshot
ExecStart=/usr/bin/dehydrated --cron --config /etc/dehydrated-%i/config
User=dehydrated
Group=dehydrated
/usr/lib/systemd/system/dehydrated-postrun-hooks@.service
[Unit]
Description=Postrun Hooks Runner for Dehydrated: %i
ConditionPathExists=/etc/dehydrated-%i/postrun-hooks.d/
After=dehydrated@%i.service
PartOf=dehydrated.target
[Service]
Type=oneshot
ExecStart=-/usr/bin/find -L /etc/dehydrated-%i/postrun-hooks.d/ -maxdepth 1 -executable -type f -exec {} \;
User=root
Group=root
[Install]
RequiredBy=dehydrated@%i.service
/usr/lib/systemd/system/dehydrated@.timer
[Unit]
Description=Timer for Certificate Update Runner for Dehydrated
PartOf=dehydrated.target
[Timer]
OnCalendar=daily
# Two hour window
RandomizedDelaySec=7200
[Install]
WantedBy=timers.target
Finalize setup
So all left to go is:
systemctl enable dehydrated@internal.timer
systectl enable dehydrated-postrun-hooks@internal.service
If you are an openSUSE Tumbleweed user, all those new unit files are in your package already. Users of older openSUSE versions or other distributions can find packages built with build.opensuse.org.
All this work was done as part of the Hackweek 2021 at SUSE.