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:
parent
3a8c23d7a6
commit
ab5cc0958f
1 changed files with 175 additions and 0 deletions
175
scripts/owni-certs-sync.sh
Executable file
175
scripts/owni-certs-sync.sh
Executable 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
|
||||
Loading…
Reference in a new issue