Released: script to automatically deploy letsencrypt certs on macOS High Sierra+ Server


#1

I’ve gotten a script to work as deploy-hook on macOS High Sierra + Server. Assuming you have certbot installed from brew, and based on information elsewhere (including what @JeffTheRocker already published) this script not only installs the certificate, it also checks if the previous certificate for the same domain is still in use by macOS Server and if not, it removes that certificate. Previous instructions I found here and elsewhere would create a slow buildup of no-longer in use certificates. This one keeps your system clean. No need to enter complex commands, this script will do most for you.

The script is called certbot-macosserver-deploy.sh and it can be installed in /etc/letsencrypt/renewal-hooks/deploy/ so it will run automatically every time a cert has been created. My crontab now shows:

10 5 * * * /usr/local/bin/certbot renew >>/var/log/certbot.log 2>&1

And that is all I have to do to keep my mail server and web server using an up-to-date Letsencrypt certificate.

What the script does:

  1. Check if it is running as root
  2. Check if this version of macOS is supported by the script. At this point in time, only High Sierra is, because that is what I use. (If it is reported to work with other versions, I can change this going forward)
  3. Check if Server.app is running on this Mac
  4. Some other minor checks
  5. Find out what certificate is currently in use for the domains for which a new cert was created (works with multiple domains)
  6. Generate passphrase, transforms the cert to a format macOS understands, import in in the System keychain (this part all thanks to @JeffTheRocker)
  7. Wait until the system is done with the configuration changes
  8. Check certificates in the System keychain and if the certificate that was originally in use is no longer in use by Server.app, remove it from the System keychain

It is possible to run the script independently. Pass the domain cert to install/replace as argument. Otherwise it gets the values from certbot.

Note: you have to be patient for the output to appear if the script runs as deploy-hook by certbot. If a script is run by certbot, the output is buffered and not delivered line-by-line.

The comments about danger are a bit overdone. It works fine for me.

Here is the script (I don’t know if it is possible to attach this as an attachment to this post):

#!/bin/bash

# deploy script for macOS + Server, inspired by JeffTheRocker on
# the Letsencrypt community
# No warranty (really) and public domain

# Version 1.0 2018-10-11 Gerben Wierda
# Version 1.01 2018-10-14 Gerben Wierda
# Version 1.02 2018-10-27 Gerben Wierda
# Version 1.03 2018-11-05 Gerben Wierda
VERSION="v1.03"

# NOTE: this script may remove identities that are related to the domain
# being installed/renewed (keys & certs) from the System keychain if the
# identity is no longer in use by Server.app. This is limited to identities
# that cover the domain name being updated/renewed only.
# It will thus remove identities that may be in use without Server.app being
# aware of them. DANGER! This may be unsafe on Mojave+ Server 5.7+ or in any
# situation where there are identities managed by certbot in use outside of
# Server.app's knowledge.
# Uncomment the next line to live dangerously (but useful if it applies to you):
MODE="REMOVE"
# Use at your own risk.

# This script should be executable and run as root

# Examples (all with force renewal, this is not normal usage):
# sudo certbot certonly --force-renewal \
#   --webroot -w /Library/Server/Web/Data/Sites/www.example.com \
#   -d www.example.com,foo.example.com \
#   --deploy-hook <LOCATIONOFTHISSCRIPT>/certbot-macosserver-deploy.sh
# Or install in directory /etc/letsencrypt/renewal-hooks/deploy and:
# sudo certbot certonly --force-renewal \
#   --webroot -w /Library/Server/Web/Data/Sites/www.example.com \
#   -d www.example.com
# Or install anywhere else and (2 commands):
# sudo certbot certonly --force-renewal \
#   --webroot -w /Library/Server/Web/Data/Sites/www.example.com \
#   -d www.example.com
# <LOCATIONOFTHISSCRIPT>/certbot-macosserver-deploy.sh www.example.com

# Actual normal use:
# 1. Install as /etc/letsencrypt/renewal-hooks/deploy/certbot-macosserver-deploy.sh
#    and it will be automatically run by certbot after installation of a new cert
# 2. Run once by hand to install the cert the first time (example for two domains):
#        sudo certbot certonly \
#            --webroot -w /Library/Server/Web/Data/Sites/www.example.com \
#            -d www.example.com,foo.example.com
#    This installs the cert for the first time
# 3. Add a line to the root crontab, such as
#        10 5 * * * /usr/local/bin/certbot renew >>/var/log/certbot.log 2>&1
#    This keeps your cert valid. Renewal is logged in /var/log/certbot.log
#    (which technically will grow endlessly, so for perfection, this log file should be trimmed)

# The actual script starts here

# Dat format for logging/output
DATE=$(date +"%C%y-%m-%d_%H:%M:%S")
SCRIPT=$0
LOGLABEL="${SCRIPT} (${VERSION}) [${DATE}]"

if [ "$(whoami)" != "root" ]; then
    echo "${LOGLABEL}: This script should be run as root."
    exit 1
fi

DARWINVERSION=`uname -r | sed 's/\..*//'`
if [ ${DARWINVERSION} -le 16 ]
then
    echo "${LOGLABEL}: This older version of macOS (Darwin ${DARWINVERSION}) is untested. It might work."
    echo "${LOGLABEL}: You can let it proceed anyway by commenting out this and following messages and the exit command after these messages in the script."
    echo "${LOGLABEL}: If it works, let me know at gerben.wierda@rna.nl. Exiting..."
    exit 1
fi
if [ ${DARWINVERSION} -gt 17 ]
then
    echo "${LOGLABEL}: This version of macOS (Darwin ${DARWINVERSION}) is not supported. It will not work. Exiting..."
    exit 1
fi

if (ps x|grep '/Applications/Server.app/'|grep -v grep>/dev/null 2>&1)
then
    echo "${LOGLABEL}: Server is running on this system. Proceeding..."
fi

# A commandline argument overrides inheriting the domain from the environment
# normally, when this is run as --deploy-hook for certbot, the domain name is
# passed via the environment

# If certbot was called for multiple domains, the first one is the certs main
# identity as used in Server.app, the others are aliases

if [ "$1" != "" ]
then
    # If used with command line argument: use these instead of what would be
    # inherited from certbot
    # Second argument is the override for ORIGINALIDENTITY (the one to be
    # removed at the end as it is no longer in use)
    DOMAINS=($1)
    DOMAIN=${DOMAINS[0]}
    PEM_FOLDER="/etc/letsencrypt/live/${DOMAIN}"
else
    DOMAINS=(${RENEWED_DOMAINS})
    DOMAIN=${DOMAINS[0]}
    PEM_FOLDER=${RENEWED_LINEAGE}
fi

# Uncomment if you want to be talkative
echo "${LOGLABEL}: Install in macOS Server for ${DOMAINS} from ${PEM_FOLDER}"

# Minimal check on valid arguments and environment
if [ "${DOMAINS}" = "" -o ! -d "${PEM_FOLDER}" ]
then
    echo "${LOGLABEL}: No domains given or the certificate folder for domain \"${DOMAINS}\" does not exist. Exiting..."
    exit 1
fi

# Actual work:

# Find out if the certificate for this domain is used by Server.app
# This is done by backtick command to get the value out of a subshell
# (I wish I had written this in python in the first place)
ORIGINALIDENTITY=`security find-identity -p ssl-server -s "${DOMAIN}" \
    /Library/Keychains/System.keychain | \
    awk 'BEGIN {FS = "[ \"]"} { if (length($4) == 40) print $6 " " $4}' | \
while read i ;\
do \
    if [ "${i% *}" = "${DOMAIN}" ] ;\
    then \
        if (serveradmin settings all | grep "${DOMAIN}.${i#* }" >/dev/null 2>&1) ;\
	then \
            echo -n "${i#* }" ;\
	    break ;\
	    fi ;\
    fi ;\
done`

if [ "${ORIGINALIDENTITY}" == "" ]
then
    echo "${LOGLABEL}: There is currently no identity for ${DOMAIN}, a new one will be created and needs to be linked by hand (once) to the service(s)"
else
    if [ "${MODE}" = "REMOVE" ]
    then
	echo "${LOGLABEL}: Identity ${ORIGINALIDENTITY} is currently in use by Server.app for ${DOMAIN}. It will be removed afterwards."
    else
	echo "${LOGLABEL}: Identity ${ORIGINALIDENTITY} is currently in use by Server.app for ${DOMAIN}"
    fi
fi

if [ "$2" != "" ]
then
    ORIGINALIDENTITY=$2
    echo "${LOGLABEL}: Using 2nd arg for OriginalIdentity override ${ORIGINALIDENTITY}"
fi

# Add the key to the System keychain for Server.app which will automatically
# apply it

# Generate a passphrase
PASS=$(openssl rand -base64 45 | tr -d /=+ | cut -c -30)

# Transform the pem files into a OS X Valid p12 file
openssl pkcs12 -export \
    -inkey "${PEM_FOLDER}/privkey.pem" \
    -in "${PEM_FOLDER}/cert.pem" \
    -certfile "${PEM_FOLDER}/fullchain.pem" \
    -out "${PEM_FOLDER}/letsencrypt_sslcert.p12" \
    -passout pass:$PASS

# import the p12 file in keychain
security import "${PEM_FOLDER}/letsencrypt_sslcert.p12" -f pkcs12 \
    -k /Library/Keychains/System.keychain \
    -P $PASS \
    -T /Applications/Server.app/Contents/ServerRoot/System/Library/CoreServices/ServerManagerDaemon.bundle/Contents/MacOS/servermgrd

# Give the system time to finish reconfiguring services

# Find out if the original certificate for this domain is still used by
# Server.app
# serveradmin may take some time to reflect the new reality, therefore, as
# long as serveradmin still reports the old identity in use for the domain
# it is still not done. We build a loop of 10 tries to wait for serveradmin
# to complete its work.
for ((j=1; j<11; j++))
do
    echo "${LOGLABEL}: Checking ${DOMAIN}.${ORIGINALIDENTITY} for usage (try $j of 10)"
    if serveradmin settings all | grep "${DOMAIN}.${ORIGINALIDENTITY}" >/dev/null 2>&1
    then
	echo "${LOGLABEL}: Serveradmin is not yet done configuring to use the new identity. Waiting 6 seconds and retrying..."
	sleep 6
    else
	echo "${LOGLABEL}: ${ORIGINALIDENTITY} is not/no longer part of serveradmin's settings. Proceeding..."
	break
    fi
done
if [ $j -eq 11 ]
then
    # This happens for instance when switching from certbot --staging to certbot. In that case
    # you need to set the certificate by hand in Server.app
    # It also happens when you go from a single-domain cert to multiple-domain
    echo "${LOGLABEL}: ${ORIGINALIDENTITY} is still part of serveradmin's settings. The certificate was apparently not automatically replaced. Manual intervention necessary."
fi

security find-identity -p ssl-server -s "${DOMAIN}" \
	/Library/Keychains/System.keychain | \
    awk 'BEGIN {FS = "[ \"]"} { if (length($4) == 40) print $6 " " $4}' | sort | uniq | \
while read i
do
    IDENTITY="${i#* }"
    if serveradmin settings all | grep "${i#* }" >/dev/null 2>&1
    then
	echo "${LOGLABEL}: Identity ${IDENTITY} is in use by Server.app for ${i% *}. It will not be removed."
    else
	if [ "${IDENTITY}" = "${ORIGINALIDENTITY}" ]
	then
	    if [ "${MODE}" = "REMOVE" ]
	    then
		echo "${LOGLABEL}: Identity ${ORIGINALIDENTITY} is no longer in use by Server.app. Removing..."
		security delete-identity -Z "${ORIGINALIDENTITY}" /Library/Keychains/System.keychain
	    else
		echo "${LOGLABEL}: Identity ${ORIGINALIDENTITY} is no longer in use by Server.app. It should probably be removed."
	    fi
	else
	    echo "${LOGLABEL}: Identity ${IDENTITY} is not in use by Server.app. It should probably be removed."
	fi
    fi
done
exit 0

I hope people find it useful.