Synchronisation automatique des certificats Let's Encrypt sur owni

ISPConfig ne recopie le certificat dans le répertoire ssl/ du site que
lorsqu'il le crée ou le recrée : un renouvellement par le cron certbot
passe inaperçu et le vhost continue de servir l'ancien jusqu'à son
expiration. Re-sauvegarder le site dans l'interface ne déclenche pas la
copie. static.alpinux.org et portail.alpinux.org ont ainsi servi un
certificat expiré à partir du 1er août 2026.

Le script audite en lecture seule par défaut et resynchronise avec --fix.
Il ne retient que les certificats réellement référencés par un vhost
activé ou un service : de nombreux répertoires ssl/ conservent des
certificats périmés dont plus personne ne se sert.

Déployé en /usr/local/sbin/owni-certs-sync.sh et lancé chaque jour à
04:20 par /etc/cron.d/owni-certs-sync, en complément du hook certbot
ispconfig-cert.sh généralisé à tous les sites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cédrix 2026-09-05 11:48:37 +02:00
parent 3a8c23d7a6
commit ab5cc0958f

175
scripts/owni-certs-sync.sh Executable file
View file

@ -0,0 +1,175 @@
#!/bin/bash
# Synchronise les certificats Let's Encrypt vers les sites ISPConfig.
# Cible : owni.alpinux.org — à exécuter en root sur le serveur.
#
# Les vhosts ISPConfig ne pointent pas sur /etc/letsencrypt/live mais sur une
# copie dans /var/www/clients/<client>/<web>/ssl/. ISPConfig ne rafraîchit
# cette copie que lorsqu'il crée ou recrée le certificat : un renouvellement
# par le cron certbot passe inaperçu et le vhost sert l'ancien certificat
# jusqu'à son expiration. Re-sauvegarder le site dans l'interface ne suffit
# pas à déclencher la copie.
#
# C'est ce qui a cassé le HTTPS de static.alpinux.org et portail.alpinux.org
# le 1er août 2026, constaté seulement le 5 septembre.
#
# Le hook /etc/letsencrypt/renewal-hooks/deploy/ispconfig-cert.sh couvre
# désormais le cas nominal. Ce script est le filet de sécurité : il rattrape
# les certificats que le hook n'a pas traités (hook absent lors d'un
# renouvellement passé, copie écrasée par ISPConfig, certificat posé à la main)
# et signale ceux que lui seul ne peut pas corriger.
#
# Usage :
# owni-certs-sync.sh audit seul, n'écrit rien (défaut)
# owni-certs-sync.sh --fix resynchronise et recharge Apache
# owni-certs-sync.sh --quiet n'affiche que les anomalies (pour le cron)
#
# Retour : 0 si tout est cohérent, 1 s'il subsiste une anomalie.
set -uo pipefail
LIVE=/etc/letsencrypt/live
CLIENTS=/var/www/clients
ALERTE_JOURS=21 # seuil d'alerte avant expiration
# Emplacements où un certificat peut être référencé. Apache est le cas usuel,
# mais un .pem concaténé peut aussi être consommé par un service de messagerie
# ou de transfert : les inclure évite de classer « inutilisé » un fichier qui
# sert ailleurs, et donc de cesser de le mettre à jour.
CONSOMMATEURS=(/etc/apache2/sites-enabled /etc/postfix /etc/dovecot /etc/pure-ftpd)
fix=0
quiet=0
anomalies=0
corriges=0
for arg in "$@"; do
case "$arg" in
--fix) fix=1 ;;
--quiet) quiet=1 ;;
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
*) echo "Option inconnue : $arg" >&2; exit 2 ;;
esac
done
[ "$(id -u)" -eq 0 ] || { echo "Ce script doit être exécuté en root." >&2; exit 2; }
info() { [ "$quiet" -eq 1 ] || printf '%s\n' "$*"; }
alerte() { printf '%s\n' "$*"; anomalies=$((anomalies + 1)); }
trace() { logger -t certs-sync "$*"; }
maintenant=$(date +%s)
while IFS= read -r crt; do
[ -n "$crt" ] || continue
fichier=$(basename "$crt")
domaine=${fichier%-le.crt}
ssl_dir=$(dirname "$crt")
live_crt="$LIVE/$domaine/fullchain.pem"
live_key="$LIVE/$domaine/privkey.pem"
# Un répertoire ssl/ peut contenir des fichiers qu'aucun vhost ne référence
# plus : wiki, gitea, alpid et installparty gardent ainsi des certificats
# de 2026-04 périmés alors que leurs vhosts pointent ailleurs et servent un
# certificat valide. Les corriger serait du bruit, et les signaler comme
# anomalie masquerait les vraies pannes. On les écarte donc du diagnostic.
# On cherche le .crt comme le .pem : un service peut ne consommer que le
# second. grep -R suit les liens symboliques, contrairement à -r — c'est
# indispensable, sites-enabled n'étant fait que de liens.
reference=0
for emplacement in "${CONSOMMATEURS[@]}"; do
[ -e "$emplacement" ] || continue
if grep -RqF -e "$crt" -e "$ssl_dir/$domaine-le.pem" "$emplacement" 2>/dev/null; then
reference=1
break
fi
done
if [ "$reference" -eq 0 ]; then
info "inutilisé $domaine$crt n'est référencé par aucun service"
continue
fi
fin=$(openssl x509 -in "$crt" -noout -enddate 2>/dev/null | cut -d= -f2)
if [ -z "$fin" ]; then
alerte "ILLISIBLE $domaine$crt n'est pas un certificat exploitable"
continue
fi
fin_ts=$(date -d "$fin" +%s 2>/dev/null || echo 0)
restant=$(( (fin_ts - maintenant) / 86400 ))
# certbot ne connaît pas ce domaine : seule une émission neuve peut le
# corriger, via l'onglet SSL d'ISPConfig. Hors de portée de ce script.
if [ ! -f "$live_crt" ]; then
if [ "$fin_ts" -lt "$maintenant" ]; then
alerte "ORPHELIN $domaine — expiré depuis $(( -restant )) j et inconnu de certbot (émission à refaire dans ISPConfig)"
elif [ "$restant" -lt "$ALERTE_JOURS" ]; then
alerte "ORPHELIN $domaine — expire dans $restant j et inconnu de certbot (émission à refaire dans ISPConfig)"
else
info "orphelin $domaine — inconnu de certbot, expire dans $restant j"
fi
continue
fi
empreinte_servie=$(openssl x509 -in "$crt" -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2)
empreinte_live=$(openssl x509 -in "$live_crt" -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2)
if [ "$empreinte_servie" = "$empreinte_live" ]; then
# La copie est fidèle : s'il reste peu de temps, c'est le
# renouvellement lui-même qui n'a pas eu lieu.
if [ "$restant" -lt "$ALERTE_JOURS" ]; then
alerte "PROCHE $domaine — copie à jour mais expire dans $restant j : le renouvellement certbot n'a pas eu lieu"
else
info "ok $domaine — à jour, expire dans $restant j"
fi
continue
fi
# Écart réel : certbot détient un certificat que le vhost ne sert pas.
fin_live=$(openssl x509 -in "$live_crt" -noout -enddate 2>/dev/null | cut -d= -f2)
fin_live_ts=$(date -d "$fin_live" +%s 2>/dev/null || echo 0)
restant_live=$(( (fin_live_ts - maintenant) / 86400 ))
if [ "$fin_live_ts" -le "$fin_ts" ]; then
# Le certificat de certbot n'est pas plus récent : écraser la copie
# servie ne réglerait rien et pourrait dégrader la situation.
alerte "ECART $domaine — copie et certbot diffèrent sans que certbot soit plus récent (à examiner à la main)"
continue
fi
alerte "DESYNC $domaine — Apache sert un certificat expirant dans $restant j, certbot en a un valable $restant_live j"
if [ "$fix" -eq 1 ]; then
install -m 644 -o root -g root "$live_crt" "$crt" || { alerte " -> échec de la copie du certificat"; continue; }
install -m 600 -o root -g root "$live_key" "$ssl_dir/$domaine-le.key" || { alerte " -> échec de la copie de la clé"; continue; }
# Certains services (owni) consomment un .pem concaténé ; on ne le
# régénère que s'il existait déjà, pour ne pas en semer ailleurs.
pem="$ssl_dir/$domaine-le.pem"
if [ -f "$pem" ]; then
cat "$live_crt" "$live_key" > "$pem"
chmod 600 "$pem"
fi
info " -> resynchronisé depuis $LIVE/$domaine"
trace "certificat $domaine resynchronisé vers $ssl_dir"
corriges=$((corriges + 1))
anomalies=$((anomalies - 1))
fi
done < <(find "$CLIENTS" -maxdepth 4 -path "*/ssl/*-le.crt" -type f 2>/dev/null | sort -u)
if [ "$corriges" -gt 0 ]; then
if apache2ctl configtest >/dev/null 2>&1; then
systemctl reload apache2 && info $'\n'"$corriges certificat(s) déployé(s), Apache rechargé."
trace "$corriges certificat(s) déployé(s), apache2 rechargé"
else
alerte "configtest en échec — Apache n'a PAS été rechargé, les nouveaux certificats ne sont pas servis"
fi
fi
if [ "$anomalies" -eq 0 ]; then
info $'\nTous les certificats servis sont à jour.'
exit 0
fi
printf '\n%d anomalie(s) subsistante(s).\n' "$anomalies"
[ "$fix" -eq 1 ] || printf 'Relancer avec --fix pour resynchroniser ce qui peut l'\''être.\n'
exit 1