Migrating from Certbot to acme.sh on an Old Zimbra Server
Published 8 February 2020
certbot-auto, certificate
chains and ACME clients have changed substantially since this
was written. Do not use these instructions as a current
installation guide without checking the present documentation.
What to do when a Zimbra server can no longer renew its Let's Encrypt certificate because the old certificate chain has expired and Certbot no longer supports the operating system.
The symptoms
The Zimbra server certificate would no longer update from Let's Encrypt. There turned out to be two separate problems:
- The old IdenTrust DST Root CA X3 certificate had expired.
certbot-autono longer supported the old operating system.
The server in this case was running CentOS 6.6. It was an old, well-isolated server that was still performing a specific task, so replacing the complete system was not immediately practical.
The expired IdenTrust root certificate
In the old Zimbra configuration, the root certificate had been appended to the Let's Encrypt chain:
cat /etc/letsencrypt/live/[ServerName]/chain.pem identrust.pem > zimbrachain.pem
Here identrust.pem was the previously downloaded
DST Root CA X3 certificate. The resulting
zimbrachain.pem was then passed to
zmcertmgr for verification and deployment.
Checking the old root certificate showed the problem:
# openssl verify identrust.pem
identrust.pem: O = Digital Signature Trust Co., CN = DST Root CA X3
error 10 at 0 depth lookup: certificate has expired
OK
At the time, Let's Encrypt was moving to its own ISRG Root X1 certificate. Simply replacing the old IdenTrust certificate was not sufficient to make the existing Certbot installation work, however, because there was another problem waiting.
Certbot no longer supported the operating system
Running Certbot manually produced:
/usr/local/bin/certbot-auto renew
WARNING: couldn't find Python 3.5+ to check for updates.
Bootstrapping dependencies for Legacy RedHat-based OSes that will use Python3...
yum is hashed (/usr/bin/yum)
To use Certbot on this operating system, packages from the SCL
repository need to be installed.
Enable the SCL repository and try running Certbot again.
The installed versions were:
# python3 --version
Python 3.4.8
# cat /etc/system-release
CentOS release 6.6 (Final)
Since Certbot wanted Python 3.5 or later, I first attempted to update the old CentOS installation.
# yum install centos-release-scl
YumRepo Error: All mirror URLs are not using ftp, http[s] or file.
Eg. Invalid release/repo/arch combination/
removing mirrorlist with no valid mirrors:
/var/cache/yum/x86_64/6/base/mirrorlist.txt
Error: Cannot find a valid baseurl for repo: base
CentOS 6 had reached end of life, so its normal repositories were no longer available in their previous locations. I replaced the repository configuration with an EOL repository configuration and updated the system.
# cp centos6-eol.repo /etc/yum.repos.d/CentOS-Base.repo
# yum update
After that:
# yum install centos-release-scl
The SCL repositories also needed to be redirected to the CentOS vault. The relevant repository entries were changed to:
[centos-sclo-rh]
baseurl=http://vault.centos.org/centos/6.10/sclo/$basearch/rh/
[centos-sclo-sclo]
baseurl=http://vault.centos.org/centos/6.10/sclo/$basearch/sclo/
Python 3.6 could then be installed:
# yum install rh-python36
# scl enable rh-python36 bash
# python3 --version
Python 3.6.12
Unfortunately, after all that work Certbot finally revealed the actual showstopper:
# /usr/local/bin/certbot-auto renew
Bootstrapping dependencies for Legacy RedHat-based OSes that will use Python3...
yum is /usr/bin/yum
...
Upgrading certbot-auto 1.3.0 to 1.20.0...
Replacing certbot-auto...
Your system is not supported by certbot-auto anymore.
Certbot cannot be installed.
Please visit https://certbot.eff.org/ to check for other alternatives.
So upgrading Python did not solve the problem. I also tried downgrading Certbot, without success.
Installing acme.sh instead
I therefore moved certificate handling to acme.sh.
Unlike the old certbot-auto installation, it did
not require the same Python bootstrap process.
The original installation was:
# sudo -s
# cd ~
# curl https://get.acme.sh | sh -s email="root@my.email.domain"
# cd ~/.acme.sh
# ./acme.sh --set-default-ca --server letsencrypt
# ./acme.sh --set-default-chain --preferred-chain ISRG --server letsencrypt
# yum install socat
socat was required for the standalone ACME mode
used in this installation.
Deploying the certificate to Zimbra
Obtaining a certificate was only the first part of the problem.
Zimbra also needed the certificate copied, verified and deployed
using zmcertmgr.
I therefore wrote an acme.sh deployment hook. It was stored as:
~/.acme.sh/deploy/zimbra.sh
The following is the original deployment script, with the actual
server name replaced by my.server.domain.
#!/bin/bash
#
# acme.sh, run as root, standalone.
# Zimbra/Jetty must release port 80 for standalone validation.
#
# Installation used at the time:
#
# yum install socat
#
# wget -O /root/.acme.sh/isrgrootx1.pem \
# https://letsencrypt.org/certs/isrgrootx1.pem.txt
#
# curl https://get.acme.sh | sh -s email="root@my.server.domain"
#
# cd ~/.acme.sh
#
# ./acme.sh --set-default-ca --server letsencrypt
#
# ./acme.sh --set-default-chain \
# --preferred-chain ISRG \
# --server letsencrypt
#
#
# Manual certificate issue:
#
# ./acme.sh --issue \
# --pre-hook /root/.acme.sh/zimbra-stop.sh \
# --standalone \
# -d my.server.domain \
# --force
#
#
# Deploy:
#
# ./acme.sh --deploy \
# --deploy-hook zimbra \
# -d my.server.domain
#
function mail_ok ()
{
/opt/zimbra/postfix/sbin/sendmail \
-froot@my.server.domain \
-F'root@my.server.domain' -t <<EOF
To: root@my.server.domain
Subject: Zimbra mail server certificate update $(date +%B)
Zimbra mail server certificate has been renewed.
$(cat lasterr.txt)
EOF
}
function mail_err ()
{
/opt/zimbra/postfix/sbin/sendmail \
-froot@my.server.domain \
-F'root@my.server.domain' -t <<EOF
To: root@my.server.domain
Subject: Zimbra mail server certificate update $(date +%B) failed
Zimbra mail server certificate update error:
$*
$(cat lasterr.txt)
EOF
}
# Arguments supplied by acme.sh:
#
# domain keyfile certfile cafile fullchain
zimbra_deploy()
{
_cdomain="$1"
_ckey="$2"
_ccert="$3"
_cca="$4"
_cfullchain="$5"
# ISRG Root X1 was downloaded separately.
ISG_X1="$(dirname "$_cca")/../isrgrootx1.pem"
#
# Zimbra Jetty is stopped before entering this function,
# because acme.sh standalone mode requires port 80.
#
# Append root certificate so zmcertmgr can verify the chain.
cat "$_cfullchain" "$ISG_X1" > "${_cca}.real"
chown zimbra:zimbra \
"$_ckey" \
"$_ccert" \
"${_cca}.real"
_debug "Verifying..."
/opt/zimbra/bin/zmcertmgr \
verifycrt comm \
"$_ckey" \
"$_ccert" \
"${_cca}.real" > lasterr.txt || {
echo "Verification of the issued certificate failed."
su - zimbra -c "zmcontrol restart" >> lasterr.txt
mail_err \
"/opt/zimbra/bin/zmcertmgr verifycrt comm \
$_ckey $_ccert ${_cca}.real failed."
return 1
}
# ----- Backup
cp -a \
/opt/zimbra/ssl/zimbra \
/opt/zimbra/ssl/zimbra.$(date "+%Y%m%d")
# ----- Copy private key
cp -f \
"$_ckey" \
/opt/zimbra/ssl/zimbra/commercial/commercial.key
# ----- Deploy
rm -f lasterr.txt
_debug "Deploy..."
/opt/zimbra/bin/zmcertmgr \
deploycrt comm \
"$_ccert" \
"${_cca}.real" > lasterr.txt || {
echo "Installation of the issued certificate failed."
su - zimbra -c "zmcontrol restart" >> lasterr.txt
mail_err \
"/opt/zimbra/bin/zmcertmgr deploycrt comm \
$_ckey $_ccert ${_cca}.real failed."
return 1
}
# ----- Restart the server
_debug "Restarting Zimbra..."
/usr/bin/openssl x509 \
-enddate \
-noout \
-in "$_ccert" >> lasterr.txt
echo "" >> lasterr.txt
time su - zimbra -c "zmcontrol restart" >> lasterr.txt
sleep 5
su - zimbra -c "zmcontrol status" >> lasterr.txt
mail_ok
_debug "Done."
return 0
}
Stopping Jetty before validation
Standalone ACME validation needed port 80. Zimbra's Jetty
process was using that port, so I added a pre-hook named
~/.acme.sh/zimbra-stop.sh:
#!/bin/bash
# Stop Zimbra mailboxd/Jetty so acme.sh can use port 80.
su - zimbra -c "zmmailboxdctl stop"
Only Jetty was stopped rather than the complete Zimbra system. LDAP needed to remain available when the certificate was subsequently deployed.
ISRG Root X1
In this historical setup, ISRG Root X1 was downloaded separately:
# wget -O /root/.acme.sh/isrgrootx1.pem \
https://letsencrypt.org/certs/isrgrootx1.pem.txt
The deployment hook appended this certificate to the chain
supplied to Zimbra's zmcertmgr.
Testing certificate issuance
acme.sh stored the pre-hook when a certificate was actually
issued. I therefore used --force for the initial
test:
# ./acme.sh --issue \
--pre-hook /root/.acme.sh/zimbra-stop.sh \
--standalone \
-d my.server.domain \
--force
For testing certificate retrieval without the pre-hook, it could be omitted, but Zimbra/Jetty still had to be stopped manually so that acme.sh could listen on port 80.
Testing deployment
# ./acme.sh --deploy \
--deploy-hook zimbra \
-d my.server.domain
LDAP needed to be running during deployment. Otherwise
zmcertmgr could report:
** Saving server config key zimbraSSLCertificate...failed.
** Saving server config key zimbraSSLPrivateKey...failed.
This was the reason that the pre-hook stopped only Jetty rather than shutting down the complete Zimbra installation.
Automating renewal
acme.sh initially installed a cron entry for the root user:
# crontab -l
21 0 * * * "/root/.acme.sh"/acme.sh --cron \
--home "/root/.acme.sh" > /dev/null
I preferred keeping system jobs in /etc/cron.d,
so I created a dedicated entry:
#
# Regular cron job for Zimbra mail-server certificate renewal
#
0 4 1,7,14,21 * * root \
[ -x "/root/.acme.sh"/acme.sh ] && \
"/root/.acme.sh"/acme.sh \
--cron \
--home "/root/.acme.sh" > /dev/null
I then removed the acme.sh-generated root crontab:
# crontab -r
crontab -r removes the entire
crontab for that user. Always check crontab -l
first and do not use this command if unrelated jobs are present.
Result
The final setup allowed the legacy Zimbra server to obtain
Let's Encrypt certificates without relying on
certbot-auto. acme.sh handled ACME renewal, while
custom pre- and deployment hooks integrated the process with
Zimbra.
This solved a specific legacy-system problem at the time. Today, replacing or upgrading an unsupported operating system should normally be preferred over extending its lifetime in this way.