Correctifs, sauvegardes & disponibilitédébutant5 min de lecture

Les certificats TLS qui expirent en silence : inventorier toute la flotte et alerter 14 jours à l'avance

Let's Encrypt se renouvelle tout seul — jusqu'au jour où le hook échoue, la validation DNS casse ou quelqu'un a retiré le cron. Ce script trouve chaque certificat sur chaque port de chaque hôte (les internes aussi, le mail aussi, le reverse proxy dont plus personne ne se souvient), calcule les jours restants et pousse une alerte à 14 et 7 jours. Sans Nagios, sans service externe.

Sommaire
  1. Étape 1 : trouver tous les certificats sur un hôte
  2. Étape 2 : les certificats qui n'écoutent pas
  3. Étape 3 : l'alerte — 14 et 7 jours avant, et à l'expiration
  4. Étape 4 : la version flotte
  5. Étape 5 : vérifier de l'extérieur
  6. Pièges
  7. Ce qu'il vous manque encore
  8. Comment monsys fait
  9. FAQ

Un certificat expiré est la panne la plus prévisible qui existe : la date est littéralement écrite dedans. Et pourtant, cela arrive chaque année à des organisations avec des budgets de supervision à six chiffres, parce que le certificat était sur l'API interne, ou sur le port SMTP du serveur de mail, ou sur le load balancer d'un client migré en 2022. Le problème n'est jamais « quand expire ce certificat », c'est « quels certificats est-ce que j'ai au juste ».

Étape 1 : trouver tous les certificats sur un hôte

Ne demandez pas à la configuration quels certificats devraient être là ; demandez à chaque port en écoute quel certificat il sert. Cela attrape aussi le stunnel oublié, le Postgres avec ssl=on et le conteneur Docker avec son propre nginx.

sudo tee /usr/local/sbin/cert-inventory.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
# Affiche par port TCP en écoute : port, nom SNI, jours avant expiration, sujet, émetteur.
# Essaie TLS direct et STARTTLS (smtp, imap, pop3, postgres) sur les ports connus.
# SNI : des serveurs comme Caddy et les configs nginx strictes refusent un servername inconnu,
# donc par port on essaie chaque nom de /etc/cert-inventory.names, le FQDN, et sans SNI.
HOST=$(hostname -s)
now=$(date +%s)
NAMES=( $(cat /etc/cert-inventory.names 2>/dev/null) "$(hostname -f 2>/dev/null || hostname)" "" )
for port in $(ss -tlnH | awk '{sub(/.*:/,"",$4); print $4}' | sort -un); do
  case $port in
    25|587) st="-starttls smtp" ;;
    143)    st="-starttls imap" ;;
    110)    st="-starttls pop3" ;;
    5432)   st="-starttls postgres" ;;
    3306)   st="-starttls mysql" ;;
    *)      st="" ;;
  esac
  seen=""
  for name in "${NAMES[@]}"; do
    sni=(); [ -n "$name" ] && sni=(-servername "$name")
    cert=$(echo | timeout 4 openssl s_client -connect "127.0.0.1:$port" "${sni[@]}" $st 2>/dev/null \
           | openssl x509 -noout -enddate -subject -issuer -fingerprint -sha256 2>/dev/null) || continue
    fp=$(sed -n 's/^sha256 Fingerprint=//p' <<<"$cert")
    case " $seen " in *" $fp "*) continue ;; esac     # même cert sous un autre nom : afficher une fois
    seen="$seen $fp"
    end=$(sed -n 's/^notAfter=//p' <<<"$cert")
    days=$(( ( $(date -d "$end" +%s) - now ) / 86400 ))
    subj=$(sed -n 's/^subject=//p' <<<"$cert" | sed 's/.*CN *= *//')
    iss=$(sed -n 's/^issuer=//p' <<<"$cert" | sed 's/.*O *= *\([^,]*\).*/\1/')
    printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$HOST" "$port" "${name:--}" "$days" "$subj" "$iss"
  done
done
EOF
sudo chmod 0755 /usr/local/sbin/cert-inventory.sh
# Noms que votre serveur sert via SNI (un par ligne) ; sans ce fichier, seulement le FQDN + sans SNI
printf 'shop.example.be\napi.example.be\nmail.example.be\n' | sudo tee /etc/cert-inventory.names
sudo /usr/local/sbin/cert-inventory.sh | column -t -s $'\t'
# web-01  443   shop.example.be   61   shop.example.be   Let's Encrypt
# web-01  443   api.example.be    61   shop.example.be   Let's Encrypt    ← même cert (SAN), dédupliqué
# web-01  8443  -                -12   internal-api      Example Internal CA     ← expiré depuis 12 jours
# web-01  25    mail.example.be  203   mail.example.be   Let's Encrypt

Les jours négatifs sont des certificats expirés. Sur un serveur que vous inventoriez pour la première fois, vous en trouvez presque toujours un.

Étape 2 : les certificats qui n'écoutent pas

Certains certificats sont dans des fichiers lus seulement à l'usage : certificats client pour mTLS, un certificat de signature SAML, la CA de votre VPN. Ceux-là, vous les trouvez sur le disque :

sudo find /etc /opt /srv /var/lib -type f \( -name '*.crt' -o -name '*.pem' -o -name '*.cer' \) 2>/dev/null \
  | while read -r f; do
      end=$(openssl x509 -in "$f" -noout -enddate 2>/dev/null | sed 's/notAfter=//') || continue
      days=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 ))
      printf '%s\t%s\t%s\n' "$days" "$f" "$(openssl x509 -in "$f" -noout -subject 2>/dev/null | sed 's/.*CN *= *//')"
    done | sort -n | head -20

Sautez les clés privées (.key) : elles n'ont pas de date d'expiration, et vous ne voulez pas d'outillage dessus. Les fichiers Let's Encrypt sous /etc/letsencrypt/archive montrent toutes* les versions historiques ; filtrez sur /live/.

Étape 3 : l'alerte — 14 et 7 jours avant, et à l'expiration

sudo tee /usr/local/sbin/cert-check.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
NTFY="https://ntfy.example.be/certs"
WARN=14; CRIT=7
MSG=()
while IFS=$'\t' read -r host port name days subj iss; do
  if   [ "$days" -lt 0 ];        then MSG+=("EXPIRED ${days#-}d ago: $subj ($host:$port, $iss)")
  elif [ "$days" -le "$CRIT" ];  then MSG+=("CRITICAL ${days}d: $subj ($host:$port, $iss)")
  elif [ "$days" -le "$WARN" ];  then MSG+=("warning ${days}d: $subj ($host:$port, $iss)")
  fi
done < <(/usr/local/sbin/cert-inventory.sh)
if [ ${#MSG[@]} -gt 0 ]; then
  prio=default; printf '%s\n' "${MSG[@]}" | grep -qE '^(EXPIRED|CRITICAL)' && prio=urgent
  printf '%s\n' "${MSG[@]}" | curl -s -H "Title: certs@$(hostname -s)" -H "Priority: $prio" --data-binary @- "$NTFY" >/dev/null
fi
EOF
sudo chmod 0755 /usr/local/sbin/cert-check.sh
echo '0 8 * * * root /usr/local/sbin/cert-check.sh' | sudo tee /etc/cron.d/cert-check

Pourquoi 14 jours : Let's Encrypt renouvelle à 30 jours avant expiration. Un certificat pas encore renouvelé au jour 14 a donc un renouvellement qui échoue depuis seize jours — c'est ça le signal, pas l'expiration elle-même.

Étape 4 : la version flotte

Lancez l'inventaire sur tous vos hôtes et rassemblez en un seul endroit. Avec ssh seulement :

# hosts.txt : un nom d'hôte par ligne
while read -r h; do ssh -o ConnectTimeout=5 "$h" sudo /usr/local/sbin/cert-inventory.sh; done < hosts.txt \
  | sort -t$'\t' -k4 -n > /srv/inventory/certs-$(date +%F).tsv
column -t -s $'\t' /srv/inventory/certs-$(date +%F).tsv | head -20

Le résultat, trié par jours restants, est une liste que vous pouvez tenir mensuellement — et c'est exactement la pièce de preuve que NIS2 art. 21 §2 (h) et ISO 27001 A.8.24 entendent par « gestion des moyens cryptographiques ».

Étape 5 : vérifier de l'extérieur

L'inventaire regarde de l'intérieur. Ce qu'un visiteur voit peut être différent : un CDN ou un load balancer devant sert son propre certificat. Vérifiez la face publique séparément, depuis un hôte hors de votre réseau :

for d in shop.example.be api.example.be mail.example.be; do
  end=$(echo | timeout 5 openssl s_client -connect "$d:443" -servername "$d" 2>/dev/null | openssl x509 -noout -enddate | sed 's/notAfter=//')
  printf '%-24s %4s days\n' "$d" "$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 ))"
done

Et une fois par trimestre, regardez dans les journaux Certificate Transparency (https://crt.sh/?q=%25.example.be) quels certificats ont en général été émis pour votre domaine. Un certificat que vous n'avez pas demandé est un incident.

Pièges

  • Un renouvellement Let's Encrypt qui échoue sans mail. certbot renew journalise dans /var/log/letsencrypt/ et s'arrête en silence. systemctl status certbot.timer et certbot certificates font partie de votre tournée mensuelle. L'alerte à 14 jours l'attrape aussi, mais vous avez alors deux semaines de moins.
  • Renouvelé mais pas rechargé. Le fichier sur disque est nouveau, mais nginx/postfix/haproxy sert encore l'ancien depuis la mémoire. C'est pourquoi l'étape 1 vérifie le port, pas le fichier. Mettez un --deploy-hook "systemctl reload nginx postfix" sur certbot.
  • SNI. Un serveur avec plusieurs hôtes virtuels renvoie le certificat par défaut sans -servername — et Caddy ou un nginx strict refuse tout simplement la négociation (tlsv1 alert internal error). C'est pourquoi le script lit /etc/cert-inventory.names ; mettez-y chaque nom que l'hôte sert, sinon vous ne verrez pas le certificat. Il déduplique par empreinte, donc un certificat SAN n'apparaît qu'une fois.
  • CA internes à longue durée. Un certificat interne de dix ans n'est pas une raison de se détendre ; la CA elle-même expire aussi, et vous le remarquez sur tous les clients à la fois. Incluez les fichiers de CA à l'étape 2.
  • Fuseau horaire et le dernier jour. notAfter est en GMT. Le dernier jour, « 0 jour » peut déjà être expiré pour votre client dans un autre fuseau. Traitez 0 comme expiré.

Ce qu'il vous manque encore

  • La déduplication. Le même certificat wildcard sur douze hôtes donne douze alertes. Le script ne sait pas que c'est un seul certificat.
  • L'historique. Quand ce certificat a-t-il été renouvelé pour la dernière fois, et combien de temps a duré la panne de renouvellement la fois précédente ? Cela demande une base de données, pas un tsv par jour.
  • La vue extérieure, en continu. L'étape 5 est une boucle manuelle depuis un seul endroit. Ce que vous voulez, c'est un contrôle qui regarde de l'extérieur toutes les quelques minutes, et qui voit aussi que le port est fermé — car c'est la panne qui précède l'expiration.

Comment monsys fait

L'agent monsys fait les étapes 1 et 2 sur chaque hôte (sonde des ports TLS plus fichiers de certificats), le hub déduplique par empreinte et conserve par certificat l'historique des renouvellements. Les contrôles d'uptime font l'étape 5 depuis le hub : chaque contrôle HTTPS lit la chaîne de certificats et émet une alerte uptime.cert_expiring à 14 jours, qui se ferme d'elle-même après renouvellement. Les certificats trop proches de l'expiration apparaissent comme capacity-as-CVE dans la même liste que vos vraies vulnérabilités — avec la même priorisation, car un certificat expiré sur un hôte exposé à internet, c'est exactement cela sur le plan opérationnel.

FAQ

Pourquoi alerter à 14 jours si Let's Encrypt renouvelle à 30 ?

Parce que l'alerte signifie alors « le renouvellement automatique échoue depuis deux semaines », et qu'il vous reste deux semaines pour corriger. Alerter à 30 jours donne du bruit (le renouvellement est peut-être encore en cours ce jour-là) ; alerter à 3 jours donne un week-end sans marge.

J'utilise un CDN ; dois-je encore vérifier mes propres certificats ?

Oui. Le CDN sert son certificat aux visiteurs, mais entre le CDN et votre origine il y a généralement aussi du TLS, avec votre certificat. S'il expire, le CDN renvoie une erreur 526 ou 502 — pour les visiteurs, tout aussi mort qu'un certificat public expiré.

Puis-je faire cela aussi pour des serveurs Windows ?

Le principe est le même ; la commande est Get-ChildItem Cert:\LocalMachine\My | Select Subject, NotAfter en PowerShell, et la sonde de port fonctionne avec openssl depuis un hôte Linux vers le port Windows. Attention aux bindings IIS qui pointent encore vers un ancien certificat après un renouvellement.

Rédigé par l'équipe monsys — des sysadmins qui font cela tous les jours.

Fait à la main ? Laissez monsys s'en charger en continu.

Tout ce que contient ce guide tourne dans monsys comme contrôle permanent, avec historique, alertes et preuves d'audit. 5 serveurs gratuits, hébergés en Belgique, installés en 60 secondes.