.

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 :

Environnement utilisé

Ce tutoriel part des conditions suivantes :

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.

Références

· macOS, launchd, Let's Encrypt, NGINX, acme.sh, Homebrew