Patching, backup & uptimebeginner5 min lezen

TLS-certificaten die stil verlopen: fleet-breed inventariseren en 14 dagen vooraf alerteren

Let's Encrypt vernieuwt zichzelf — tot de dag dat de hook faalt, de DNS-validatie breekt of iemand de cron heeft weggehaald. Dit script vindt elk certificaat op elke poort van elke host (ook interne, ook mail, ook de reverse proxy die niemand meer kent), berekent de resterende dagen en pusht een alert op 14 en 7 dagen. Zonder Nagios, zonder externe dienst.

Inhoud
  1. Stap 1: alle certificaten op één host vinden
  2. Stap 2: ook de certificaten die niet luisteren
  3. Stap 3: de alert — 14 en 7 dagen vooraf, en bij verlopen
  4. Stap 4: de fleet-versie
  5. Stap 5: van buitenaf controleren
  6. Valkuilen
  7. Wat je hiermee nog niet hebt
  8. Zo doet monsys het
  9. FAQ

Een verlopen certificaat is de meest voorspelbare storing die bestaat: de datum staat er letterlijk in. En toch gebeurt het elk jaar bij organisaties met een monitoring-budget van zes cijfers, omdat het certificaat op de interne API stond, of op de SMTP-poort van de mailserver, of op de load balancer van een klant die in 2022 werd gemigreerd. Het probleem is nooit "wanneer verloopt dit certificaat", het is "welke certificaten heb ik eigenlijk".

Stap 1: alle certificaten op één host vinden

Vraag niet aan de configuratie welke certificaten er zouden moeten zijn; vraag aan elke luisterende poort welk certificaat hij serveert. Dat vangt ook de vergeten stunnel, de Postgres met ssl=on en de Docker-container met een eigen nginx.

sudo tee /usr/local/sbin/cert-inventory.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
# Print per luisterende TCP-poort: poort, SNI-naam, dagen tot verloop, subject, issuer.
# Probeert plain TLS en STARTTLS (smtp, imap, pop3, postgres) op de bekende poorten.
# SNI: servers als Caddy en strikte nginx-configs weigeren een onbekende servername,
# dus we proberen per poort elke naam uit /etc/cert-inventory.names, de FQDN, en géén 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     # zelfde cert onder een andere naam: één keer tonen
    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
# Namen die je server via SNI bedient (één per regel); zonder dit bestand alleen de FQDN + geen 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    ← zelfde cert (SAN), wordt gededupliceerd
# web-01  8443  -                -12   internal-api      Example Internal CA     ← al 12 dagen verlopen
# web-01  25    mail.example.be  203   mail.example.be   Let's Encrypt

Negatieve dagen zijn verlopen certificaten. Op een server die je voor het eerst inventariseert, vind je er bijna altijd één.

Stap 2: ook de certificaten die niet luisteren

Sommige certificaten zitten in bestanden die pas bij gebruik worden gelezen: client-certificaten voor mTLS, een SAML-signing-cert, de CA van je VPN. Die vind je op schijf:

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

Private keys (*.key) sla je over: die hebben geen vervaldatum, en je wilt er geen tooling op loslaten. Let's Encrypt-bestanden onder /etc/letsencrypt/archive tonen álle historische versies; filter op /live/.

Stap 3: de alert — 14 en 7 dagen vooraf, en bij verlopen

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

Waarom 14 dagen: Let's Encrypt vernieuwt op 30 dagen voor verloop. Een certificaat dat op dag 14 nog niet vernieuwd is, heeft dus al zestien dagen lang een falende renewal — dat is het signaal, niet het verlopen zelf.

Stap 4: de fleet-versie

Draai de inventaris over al je hosts en verzamel op één plek. Met alleen ssh:

# hosts.txt: één hostnaam per regel
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

Het resultaat, gesorteerd op resterende dagen, is een lijst die je maandelijks kunt bijhouden — en die precies het bewijsstuk is dat NIS2 art. 21 §2 (h) en ISO 27001 A.8.24 bedoelen met "beheer van cryptografische middelen".

Stap 5: van buitenaf controleren

De inventaris kijkt van binnenuit. Wat een bezoeker ziet, kan anders zijn: een CDN of load balancer ervoor serveert zijn eigen certificaat. Controleer de publieke kant apart, vanaf een host buiten je netwerk:

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

En kijk één keer per kwartaal in de Certificate Transparency-logs (https://crt.sh/?q=%25.example.be) welke certificaten er überhaupt voor je domein zijn uitgegeven. Een certificaat dat jij niet hebt aangevraagd, is een incident.

Valkuilen

  • Let's Encrypt-renewal die faalt zonder mail. certbot renew logt naar /var/log/letsencrypt/ en stopt stil. systemctl status certbot.timer en certbot certificates horen in je maandelijkse ronde. De alert op 14 dagen vangt het ook, maar dan heb je twee weken minder.
  • Vernieuwd maar niet herladen. Het bestand op schijf is nieuw, maar nginx/postfix/haproxy serveert nog het oude uit het geheugen. Daarom controleert stap 1 de poort, niet het bestand. Zet een --deploy-hook "systemctl reload nginx postfix" op certbot.
  • SNI. Een server met meerdere virtual hosts geeft zonder -servername het default-certificaat — en Caddy of een strikte nginx weigert de handshake gewoon (tlsv1 alert internal error). Daarom leest het script /etc/cert-inventory.names; zet daar elke naam in die de host bedient, anders zie je het certificaat niet. Het dedupliceert op fingerprint, dus een SAN-certificaat verschijnt één keer.
  • Interne CA's met lange looptijden. Een intern certificaat van tien jaar is geen reden tot rust; de CA zelf verloopt ook, en dat merk je op alle clients tegelijk. Neem CA-bestanden op in stap 2.
  • Tijdzone en de laatste dag. notAfter is in GMT. Op de laatste dag kan "0 dagen" al verlopen zijn voor je klant in een andere tijdzone. Behandel 0 als verlopen.

Wat je hiermee nog niet hebt

  • Deduplicatie. Hetzelfde wildcard-certificaat op twaalf hosts geeft twaalf alerts. Het script weet niet dat het één certificaat is.
  • Historie. Wanneer is dit certificaat voor het laatst vernieuwd, en hoe lang duurde de renewal-storing vorige keer? Dat vraagt een database, geen tsv per dag.
  • Het buitenzicht, continu. Stap 5 is een handmatige loop vanaf één plek. Wat je wilt is een check die elke paar minuten vanaf buiten kijkt, en die ook ziet dat de poort dicht is — want dat is de storing die vóór het verlopen komt.

Zo doet monsys het

De monsys-agent doet stap 1 en 2 op elke host (TLS-poort-probe plus certificaat-bestanden), de hub dedupliceert op fingerprint en houdt per certificaat de historie van vernieuwingen bij. De uptime-checks doen stap 5 vanaf de hub: elke HTTPS-check leest de certificaatketen mee en geeft een uptime.cert_expiring-alert op 14 dagen, die vanzelf sluit na vernieuwing. Certificaten die te dicht bij verloop komen, verschijnen als capacity-as-CVE in dezelfde lijst als je echte kwetsbaarheden — met dezelfde prioritering, want een verlopen certificaat op een internet-facing host is operationeel gezien precies dat.

FAQ

Waarom alerteren op 14 dagen als Let's Encrypt op 30 dagen vernieuwt?

Omdat de alert dan betekent "de automatische vernieuwing faalt al twee weken", en je nog twee weken hebt om het te fixen. Op 30 dagen alerteren geeft ruis (de renewal is die dag misschien nog bezig); op 3 dagen alerteren geeft een weekend zonder marge.

Ik gebruik een CDN; moet ik mijn eigen certificaten nog controleren?

Ja. Het CDN serveert zijn certificaat aan bezoekers, maar tussen CDN en jouw origin staat meestal ook TLS, met jouw certificaat. Verloopt dat, dan geeft het CDN een 526- of 502-fout — voor bezoekers net zo dood als een verlopen publiek certificaat.

Kan ik dit ook voor Windows-servers doen?

Het principe is hetzelfde; het commando is Get-ChildItem Cert:\LocalMachine\My | Select Subject, NotAfter in PowerShell, en de poort-probe werkt met openssl vanaf een Linux-host tegen de Windows-poort. Let op IIS-bindings die nog naar een oud certificaat wijzen na een vernieuwing.

Geschreven door het monsys-team — sysadmins die dit dagelijks doen.

Zelf gedaan? Laat monsys het bijhouden.

Alles uit deze how-to draait in monsys als doorlopende check, met historie, alerts en audit-bewijs. 5 servers gratis, EU-gehost in België, geïnstalleerd in 60 seconden.