#!/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///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