Automatiser le renouvellement d’un certificat Let’s Encrypt avec launchd sur macOS
Le certificat Let’s Encrypt existe déjà et NGINX délivre déjà le site en HTTPS. Il reste maintenant à automatiser le renouvellement du certificat et à demander à NGINX de le recharger lorsqu’il change.
Ce guide explique pas à pas comment effectuer cette automatisation sur un Mac. acme.sh est installé par Homebrew et la vérification quotidienne est confiée à launchd, le gestionnaire de services intégré à macOS. Il ne traite volontairement ni de l’émission initiale du certificat ni de la configuration HTTPS de NGINX.
L’exemple utilise le domaine fictif mondomaine.com et le compte macOS webmaster. Il faut remplacer ces deux valeurs par votre domaine et votre nom d’utilisateur.
La méthode sépare volontairement les privilèges :
acme.shs’exécute avec le compte utilisateur qui possède ses fichiers ;- un petit auxiliaire protégé est le seul élément exécuté par
root; - NGINX n’est rechargé que si le certificat a réellement changé ;
- aucune règle
sudoerssans mot de passe n’est nécessaire.
Environnement utilisé
Ce tutoriel part des conditions suivantes :
- Homebrew est installé dans
/opt/homebrew, chemin habituel sur un Mac Apple Silicon ; - NGINX a été installé avec Homebrew ;
- le certificat ECC de
mondomaine.coma déjà été émis avecacme.sh; - NGINX utilise déjà ce certificat ;
- le compte qui gère les certificats s’appelle
webmaster.
Sur un Mac Intel, Homebrew utilise généralement /usr/local. Il faut alors adapter les chemins /opt/homebrew de ce guide.
1. Installer acme.sh avec Homebrew
Installez acme.sh avec Brew :
brew install acme.sh
Vérifiez le chemin et la version installée :
/opt/homebrew/bin/acme.sh --version
Dans la suite, les scripts appellent volontairement /opt/homebrew/bin/acme.sh avec son chemin absolu. Un service launchd ne reçoit pas nécessairement le même PATH qu’un Terminal interactif.
Le certificat doit déjà être connu d’acme.sh. Cette commande doit soit le renouveler, soit indiquer que son renouvellement n’est pas encore nécessaire :
/opt/homebrew/bin/acme.sh --renew -d mondomaine.com --ecc
N’ajoutez pas --force à l’automatisation quotidienne. Let’s Encrypt impose des limites d’émission et acme.sh sait déterminer si le renouvellement est nécessaire.
2. Vérifier les emplacements utilisés
Dans cet exemple, les fichiers sont organisés ainsi :
/Users/webmaster/.acme.sh/mondomaine.com_ecc/fullchain.cer
/Users/webmaster/scripts/renew-certificate-mondomaine.com.sh
/Users/webmaster/scripts/renew-certificates-launchd.sh
/Library/PrivilegedHelperTools/org.local.acme-renew
/Library/LaunchDaemons/org.local.acme-renew.plist
/opt/homebrew/var/log/acme-renew/
Créez le dossier qui recevra les scripts exécutés avec votre compte :
mkdir -p /Users/webmaster/scripts
chmod 755 /Users/webmaster/scripts
Vérifiez également le fichier PID du maître NGINX :
cat /opt/homebrew/var/run/nginx.pid
Il contient le numéro du processus maître auquel l’auxiliaire enverra le signal de rechargement.
3. Créer le script de renouvellement du domaine
Créez /Users/webmaster/scripts/renew-certificate-mondomaine.com.sh avec le contenu suivant :
#!/usr/bin/env bash
set -uo pipefail
umask 077
DOMAIN="mondomaine.com"
ACME_SH="/opt/homebrew/bin/acme.sh"
ACME_HOME="/Users/webmaster/.acme.sh"
CERTIFICATE="$ACME_HOME/${DOMAIN}_ecc/fullchain.cer"
LOG_FILE="/Users/webmaster/scripts/certificate-renew-${DOMAIN}.log"
mkdir -p "$(dirname "$LOG_FILE")"
touch "$LOG_FILE"
chmod 600 "$LOG_FILE"
log() {
printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_FILE"
}
certificate_state() {
if [[ -f "$CERTIFICATE" ]]; then
/usr/bin/shasum -a 256 "$CERTIFICATE"
else
printf 'MISSING %s\n' "$CERTIFICATE"
fi
}
if [[ ! -x "$ACME_SH" ]]; then
log "ERROR acme.sh introuvable ou non exécutable : $ACME_SH"
exit 1
fi
before_state="$(certificate_state)"
log "START renouvellement de $DOMAIN"
"$ACME_SH" --renew -d "$DOMAIN" --ecc >> "$LOG_FILE" 2>&1
acme_status=$?
after_state="$(certificate_state)"
if [[ "$before_state" != "$after_state" ]]; then
log "CHANGE certificat modifié ; rechargement confié au service launchd"
else
log "NOCHANGE certificat inchangé"
fi
if (( acme_status != 0 && acme_status != 2 )); then
log "ERROR acme.sh terminé avec le statut $acme_status"
exit "$acme_status"
fi
log "END vérification terminée"
Ce premier script exécute acme.sh sans privilège administrateur. Il compare l’empreinte SHA-256 du certificat avant et après la commande. Son journal est protégé en mode 600, car les informations techniques relatives aux certificats n’ont pas à être lisibles par tous les comptes locaux.
Rendez-le exécutable :
chmod 755 /Users/webmaster/scripts/renew-certificate-mondomaine.com.sh
4. Créer le coordinateur
Créez /Users/webmaster/scripts/renew-certificates-launchd.sh :
#!/usr/bin/env bash
set -uo pipefail
SCRIPTS=(
"/Users/webmaster/scripts/renew-certificate-mondomaine.com.sh"
)
status=0
for script in "${SCRIPTS[@]}"; do
if [[ ! -x "$script" ]]; then
printf 'Script introuvable ou non exécutable : %s\n' "$script" >&2
status=1
continue
fi
if ! "$script"; then
status=1
fi
done
exit "$status"
Avec un seul domaine, ce coordinateur paraît facultatif. Il permet cependant d’ajouter plus tard un autre domaine sans modifier le service système : il suffira de créer un second script de renouvellement et de l’ajouter au tableau SCRIPTS.
Rendez-le exécutable :
chmod 755 /Users/webmaster/scripts/renew-certificates-launchd.sh
5. Tester le renouvellement sans launchd
Avant d’installer le service, exécutez le script avec le compte webmaster :
/Users/webmaster/scripts/renew-certificate-mondomaine.com.sh
Puis consultez son journal :
tail -50 /Users/webmaster/scripts/certificate-renew-mondomaine.com.log
Si le certificat n’approche pas de son échéance, le résultat normal est un renouvellement ignoré et une ligne NOCHANGE. Vérifiez enfin que le journal est privé :
stat -f '%Sp %Su:%Sg %N' /Users/webmaster/scripts/certificate-renew-mondomaine.com.log
6. Créer l’auxiliaire privilégié
Le renouvellement doit rester exécuté par webmaster, mais le maître NGINX lancé par root ne peut être rechargé par un utilisateur ordinaire. Un auxiliaire très limité fait le lien entre les deux.
Préparez d’abord /Users/webmaster/scripts/renew-certificates-root-helper.sh :
#!/usr/bin/env bash
set -euo pipefail
umask 027
ACME_USER="webmaster"
ACME_HOME="/Users/webmaster/.acme.sh"
COORDINATOR="/Users/webmaster/scripts/renew-certificates-launchd.sh"
NGINX_PID_FILE="/opt/homebrew/var/run/nginx.pid"
CERTIFICATES=(
"$ACME_HOME/mondomaine.com_ecc/fullchain.cer"
)
certificate_state() {
local certificate
for certificate in "${CERTIFICATES[@]}"; do
if [[ -f "$certificate" ]]; then
/usr/bin/shasum -a 256 "$certificate"
else
printf 'MISSING %s\n' "$certificate"
fi
done
}
run_as_acme_user() {
/usr/bin/sudo -u "$ACME_USER" -H /usr/bin/env \
HOME="/Users/webmaster" \
PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" \
"$@"
}
if [[ "$(/usr/bin/id -u)" -ne 0 ]]; then
printf 'Ce programme auxiliaire doit être exécuté par root.\n' >&2
exit 1
fi
if [[ ! -x "$COORDINATOR" ]]; then
printf 'Coordinateur introuvable ou non exécutable : %s\n' "$COORDINATOR" >&2
exit 1
fi
before_state="$(certificate_state)"
set +e
run_as_acme_user "$COORDINATOR"
renew_status=$?
set -e
after_state="$(certificate_state)"
if [[ "$before_state" != "$after_state" ]]; then
printf 'Un certificat a changé ; demande de rechargement à NGINX.\n'
if [[ ! -r "$NGINX_PID_FILE" ]]; then
printf 'Fichier PID NGINX introuvable : %s\n' "$NGINX_PID_FILE" >&2
exit 1
fi
nginx_pid="$(<"$NGINX_PID_FILE")"
if [[ ! "$nginx_pid" =~ ^[0-9]+$ ]]; then
printf 'PID NGINX invalide : %s\n' "$nginx_pid" >&2
exit 1
fi
nginx_process="$(/bin/ps -p "$nginx_pid" -o user=,command=)"
if [[ ! "$nginx_process" =~ ^root[[:space:]]+nginx:\ master\ process[[:space:]] ]]; then
printf 'Le PID %s ne correspond pas au maître NGINX root.\n' "$nginx_pid" >&2
exit 1
fi
/bin/kill -HUP "$nginx_pid"
printf 'NGINX rechargé après renouvellement.\n'
else
printf 'Aucun certificat modifié ; NGINX reste inchangé.\n'
fi
exit "$renew_status"
Cet auxiliaire vérifie qu’il est exécuté par root, lance le coordinateur sous l’identité de webmaster, puis compare l’état des certificats. En cas de changement seulement, il contrôle que le PID appartient bien au maître NGINX lancé par root avant de lui envoyer HUP.
Le signal HUP demande à NGINX de relire sa configuration et ses certificats sans interrompre brutalement les connexions en cours. Le maître NGINX valide la nouvelle configuration et conserve l’ancienne si elle ne peut pas être appliquée.
7. Créer le LaunchDaemon
Préparez /Users/webmaster/scripts/org.local.acme-renew.plist :
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>org.local.acme-renew</string>
<key>ProgramArguments</key>
<array>
<string>/Library/PrivilegedHelperTools/org.local.acme-renew</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>3</integer>
<key>Minute</key>
<integer>17</integer>
</dict>
<key>WorkingDirectory</key>
<string>/Users/webmaster/scripts</string>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>/Users/webmaster</string>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>ProcessType</key>
<string>Background</string>
<key>LowPriorityIO</key>
<true/>
<key>Umask</key>
<integer>23</integer>
<key>StandardOutPath</key>
<string>/opt/homebrew/var/log/acme-renew/launchd.out.log</string>
<key>StandardErrorPath</key>
<string>/opt/homebrew/var/log/acme-renew/launchd.error.log</string>
<key>ThrottleInterval</key>
<integer>60</integer>
</dict>
</plist>
StartCalendarInterval programme une exécution quotidienne à 3 h 17. L’entier 23 utilisé par Umask correspond à la valeur octale 027. Les fichiers créés par le service ne seront donc pas accessibles à tous les comptes locaux.
8. Pourquoi launchd plutôt que cron ?
cron peut encore fonctionner sur macOS, mais launchd est le mécanisme natif prévu par Apple pour les services et tâches planifiées. Un LaunchDaemon peut démarrer avant l’ouverture d’une session et dispose d’une configuration explicite pour son environnement, ses journaux et ses permissions.
Autre différence utile pour un petit serveur Mac : une tâche StartCalendarInterval qui devait s’exécuter pendant la veille est lancée au réveil. Une tâche cron manquée pendant la veille attend normalement sa prochaine échéance. Une vérification quotidienne des certificats tolère très bien ce regroupement au réveil.
Il ne faut pas utiliser KeepAlive ici : acme.sh doit faire une vérification courte puis quitter, et non rester continuellement en mémoire.
9. Valider les quatre fichiers avant installation
Vérifiez la syntaxe des trois scripts :
/bin/bash -n /Users/webmaster/scripts/renew-certificate-mondomaine.com.sh
/bin/bash -n /Users/webmaster/scripts/renew-certificates-launchd.sh
/bin/bash -n /Users/webmaster/scripts/renew-certificates-root-helper.sh
Vérifiez le fichier plist :
plutil -lint /Users/webmaster/scripts/org.local.acme-renew.plist
La réponse attendue est OK.
10. Installer les éléments protégés
Créez le dossier des journaux avec des droits restrictifs :
sudo install -d -o root -g wheel -m 750 /opt/homebrew/var/log/acme-renew
sudo install -o root -g wheel -m 640 /dev/null /opt/homebrew/var/log/acme-renew/launchd.out.log
sudo install -o root -g wheel -m 640 /dev/null /opt/homebrew/var/log/acme-renew/launchd.error.log
Installez ensuite une copie protégée de l’auxiliaire. launchd n’exécute donc pas comme root un fichier modifiable par le compte webmaster :
sudo install -o root -g wheel -m 755 \
/Users/webmaster/scripts/renew-certificates-root-helper.sh \
/Library/PrivilegedHelperTools/org.local.acme-renew
Installez enfin le LaunchDaemon :
sudo install -o root -g wheel -m 644 \
/Users/webmaster/scripts/org.local.acme-renew.plist \
/Library/LaunchDaemons/org.local.acme-renew.plist
11. Charger et tester le service
Chargez le service au niveau système :
sudo launchctl bootstrap system /Library/LaunchDaemons/org.local.acme-renew.plist
S’il était déjà chargé après une modification, remplacez-le proprement :
sudo launchctl bootout system/org.local.acme-renew
sudo launchctl bootstrap system /Library/LaunchDaemons/org.local.acme-renew.plist
Déclenchez immédiatement un essai, sans attendre 3 h 17 :
sudo launchctl kickstart -k system/org.local.acme-renew
Affichez son état :
sudo launchctl print system/org.local.acme-renew
Consultez les journaux :
sudo tail -50 /opt/homebrew/var/log/acme-renew/launchd.out.log
sudo tail -50 /opt/homebrew/var/log/acme-renew/launchd.error.log
tail -50 /Users/webmaster/scripts/certificate-renew-mondomaine.com.log
Quand le certificat est encore valable, le comportement normal est le suivant : acme.sh ignore le renouvellement, le certificat reste identique et l’auxiliaire affiche qu’il ne recharge pas NGINX.
12. Vérifier les permissions
Contrôlez les propriétaires et les droits :
stat -f '%Sp %Su:%Sg %N' \
/Library/PrivilegedHelperTools/org.local.acme-renew \
/Library/LaunchDaemons/org.local.acme-renew.plist \
/opt/homebrew/var/log/acme-renew \
/opt/homebrew/var/log/acme-renew/launchd.out.log \
/opt/homebrew/var/log/acme-renew/launchd.error.log \
/Users/webmaster/scripts/certificate-renew-mondomaine.com.log
Les valeurs attendues sont notamment :
-rwxr-xr-x root:wheel /Library/PrivilegedHelperTools/org.local.acme-renew
-rw-r--r-- root:wheel /Library/LaunchDaemons/org.local.acme-renew.plist
drwxr-x--- root:wheel /opt/homebrew/var/log/acme-renew
-rw-r----- root:wheel /opt/homebrew/var/log/acme-renew/launchd.out.log
-rw-r----- root:wheel /opt/homebrew/var/log/acme-renew/launchd.error.log
-rw------- webmaster:staff /Users/webmaster/scripts/certificate-renew-mondomaine.com.log
13. Ce qui se passera lors d’un véritable renouvellement
Chaque jour, launchd démarre l’auxiliaire protégé. Celui-ci mémorise l’empreinte du certificat, exécute le coordinateur sous le compte webmaster, puis compare la nouvelle empreinte.
Si acme.sh ne renouvelle rien, NGINX n’est pas touché. Si le certificat change, l’auxiliaire vérifie le PID et l’identité du maître NGINX, puis lui envoie HUP. NGINX recharge alors le nouveau certificat sans arrêt complet du serveur.
Après un vrai renouvellement, vous pouvez vérifier le certificat présenté publiquement :
echo | openssl s_client -connect mondomaine.com:443 \
-servername mondomaine.com 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates -fingerprint -sha256
14. Ajouter un second domaine
Copiez le script propre au domaine, modifiez DOMAIN, rendez-le exécutable, puis ajoutez son chemin au tableau du coordinateur. Ajoutez également le chemin de son fullchain.cer au tableau CERTIFICATES de l’auxiliaire.
Après chaque modification de l’auxiliaire, réinstallez sa copie protégée :
sudo install -o root -g wheel -m 755 \
/Users/webmaster/scripts/renew-certificates-root-helper.sh \
/Library/PrivilegedHelperTools/org.local.acme-renew
Le fichier plist ne change pas.
15. Désinstaller l’automatisation
Déchargez le service avant de retirer ses fichiers :
sudo launchctl bootout system/org.local.acme-renew
sudo rm /Library/LaunchDaemons/org.local.acme-renew.plist
sudo rm /Library/PrivilegedHelperTools/org.local.acme-renew
Les scripts et journaux peuvent être conservés pour diagnostic. Leur suppression n’est pas nécessaire pour arrêter l’automatisation.