diff --git a/scripts/owni-certs-sync.sh b/scripts/owni-certs-sync.sh new file mode 100755 index 0000000..e6020bb --- /dev/null +++ b/scripts/owni-certs-sync.sh @@ -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///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