Alertas de caducidad de certificados SSL: ya nadie te avisa por correo

Let's Encrypt dejó de avisar por correo y los certificados duran 200 días. Un script openssl probado, los umbrales correctos y alertas que sí te llegan.

Actualizado

Índice

Dos cosas han cambiado por debajo de la infraestructura de certificados de todo el mundo, y la mayoría de los equipos no se ha ajustado a ninguna de las dos.

Primero, desapareció la red de seguridad. Let's Encrypt cerró su servicio de avisos de caducidad el 4 de junio de 2025: esos correos que salvaron discretamente miles de sitios cuando la automatización se rompía. El razonamiento era sólido (casi todo el mundo renueva automáticamente, guardar millones de direcciones de correo es un pasivo de privacidad y el servicio costaba "decenas de miles de dólares al año") y el anuncio termina diciéndote que busques monitorización de terceros (Let's Encrypt). Mucha gente leyó eso, estuvo de acuerdo y nunca hizo la segunda mitad.

Segundo, se hundió el margen de error. Desde el 15 de marzo de 2026, un certificado TLS público puede ser válido como máximo 200 días, bajando a 100 días el 15 de marzo de 2027 y a 47 días el 15 de marzo de 2029 según la papeleta SC-081v3 del CA/Browser Forum. Let's Encrypt va más rápido que el límite: los certificados de 6 días pasaron a disponibilidad general el 15 de enero de 2026, y su plan lleva el perfil por defecto a 64 días en febrero de 2027 y a 45 días en febrero de 2028 (Let's Encrypt).

Ambos cambios empujan en la misma dirección. La renovación ocurre más a menudo, así que tiene más ocasiones de romperse, y cuando se rompe nadie te escribe. Esta guía es la comprobación que cierra ese hueco: un script de shell probado, umbrales con sentido para certificados de vida corta y una forma de hacer que el último caso haga sonar tu teléfono con Echobell.

¿Qué tan grave es una caída por certificado?

Lo bastante como para que más de un tercio de las organizaciones tuviera una el año pasado. En el 2026 Global Certificate Management Outlook de DigiCert —una encuesta de Propeller Insights a 1.001 responsables de TI y ciberseguridad en EE. UU., Reino Unido y Australia, realizada en mayo de 2026— más de un tercio de las organizaciones declaró una interrupción de servicio causada por un certificado caducado en el último año. Casi tres cuartas partes acumularon al menos cinco horas de caída relacionada con certificados, una de cada cinco llegó a 25 horas o más y casi una de cada cuatro dijo que su incidente más grave costó más de 250.000 dólares (DigiCert).

Lo interesante de esas cifras es la duración. Cinco horas no es lo que tarda renovar un certificado: renovar tarda segundos. Cinco horas es lo que tardó alguien en enterarse.

¿Por qué no basta con la renovación automática?

Porque la automatización de renovación falla en silencio, y un cron que dejó de ejecutarse no produce ninguna salida. Cada uno de estos casos es real y frecuente, y ninguno hace ruido:

  • El temporizador ya no se ejecuta. Una actualización de distribución, una reconstrucción de contenedor o un systemctl disable de hace tres meses que nadie recuerda. Un certbot.timer que no dispara se parece exactamente a uno que dispara bien.
  • La renovación funcionó pero el servicio nunca recargó. El certificado nuevo está en disco; nginx, HAProxy o Postfix siguen con el viejo en memoria. Esta es la forma más común de que una configuración "totalmente automática" caduque igual.
  • Se rompió la ruta del desafío. Alguien añadió una redirección, una regla de WAF o un Deny delante de /.well-known/acme-challenge/ y HTTP-01 falla. O caducó el token de API del proveedor DNS usado para DNS-01.
  • La renovación ocurrió en un solo nodo. Dos balanceadores, un cron. El segundo sigue sirviendo el certificado viejo hasta que deja de poder.
  • El certificado ni siquiera está en un servidor web. Clientes mTLS internos, un broker de Kafka, un servidor LDAP, un concentrador VPN, un certificado push de gestión de dispositivos. Nada en la internet pública lo ve y ningún cliente ACME lo gestiona.
  • La renovación está fijada al intervalo equivocado. Let's Encrypt es explícito: "renovar a un intervalo fijo de 60 días ya no será suficiente" cuando el perfil por defecto baje a 64 y luego a 45 días (Let's Encrypt).

El último merece énfasis porque es el fallo hacia el que el calendario está empujando a todo el mundo. Si tu cadencia de renovación es un número que alguien tecleó en 2022, la vida del certificado se está acercando a ese número.

¿Cómo compruebo la caducidad de un certificado desde la línea de comandos?

Una sola tubería de openssl, sin dependencias. Para un host en vivo:

openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -startdate -enddate

Para un archivo en disco:

openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -enddate

El flag -servername no es opcional en una IP compartida: sin SNI obtienes el certificado que el servidor considera su predeterminado, que puede no ser el que te preocupa.

Para una respuesta de sí o no, sáltate el parseo de fechas. openssl x509 -checkend <segundos> sale con 0 si el certificado sobrevive a esa ventana y con 1 si caduca dentro de ella (incluido si ya caducó):

openssl x509 -in fullchain.pem -noout -checkend $((14 * 86400)) \
  || echo "caduca en menos de 14 días"

Ese contrato de código de salida es toda la primitiva de monitorización. Lo demás es fontanería a su alrededor.

Cazar el caso "renovado pero no recargado"

Compara lo que hay en disco con lo que realmente se está sirviendo. Es la comprobación que casi nadie hace y que caza el fallo que la automatización de renovación no puede ver:

served=$(openssl s_client -connect 127.0.0.1:443 -servername example.com </dev/null 2>/dev/null \
         | openssl x509 -noout -fingerprint -sha256)
ondisk=$(openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -fingerprint -sha256)

[ "$served" = "$ondisk" ] || echo "el servicio sirve un certificado obsoleto: hace falta recargar"

Ambos comandos imprimen el mismo formato sha256 Fingerprint=AB:CD:..., así que basta con comparar cadenas. Ejecútalo unos minutos después de la ventana de tu temporizador de renovación.

¿Qué umbrales debe usar una alerta de certificado?

Fracciones de la vida del certificado, no días fijos. La regla "avisar a 30 días" era razonable para certificados de 90 días. Aplicada a uno de 47 días, salta con un certificado perfectamente sano; aplicada a uno de 6 días, salta permanentemente.

Ancla la escalera al punto de renovación. Let's Encrypt recomienda renovar "aproximadamente a dos tercios de la vida del certificado actual", así que un tercio de vida restante es cuando la renovación ya debería haber ocurrido. Todo lo que viene después es prueba de que no ocurrió:

Vida restanteQué significaTipo de notificación
1/3Se abrió la ventana de renovaciónNada: esto es normal
1/6Se perdió la ventana una vezPush normal
1/12La renovación falla, no llega tardeUrgente
< 1/24, caducado o inalcanzableEstás a horas de una caídaLlamada

En números concretos, para un certificado de 47 días es más o menos: silencio hasta 7,8 días, push a 3,9 días, urgente a 2 días y llamada por debajo de 1 día. Para uno de 90 días: 15 días, 7,5 días, 3,75 días. Para uno de 6 días son horas, y ahí una escalera humana deja de ser la herramienta adecuada: apóyate en ACME Renewal Information (ARI, publicado como RFC 9773), que deja que la CA le diga a tu cliente cuándo renovar, y alerta solo ante fallos repetidos del cliente.

Calcular la fracción son cuatro líneas, y hace que el mismo script sea correcto para todos tus certificados sea cual sea el emisor:

to_epoch() {
  date -u -d "$1" +%s 2>/dev/null || date -u -j -f '%b %d %T %Y %Z' "$1" +%s 2>/dev/null
}

pct_left() {  # lee un PEM por stdin e imprime el porcentaje de vida restante
  local pem nb na
  pem=$(cat)
  nb=$(to_epoch "$(printf '%s' "$pem" | openssl x509 -noout -startdate | cut -d= -f2)")
  na=$(to_epoch "$(printf '%s' "$pem" | openssl x509 -noout -enddate | cut -d= -f2)")
  echo $(( (na - $(date -u +%s)) * 100 / (na - nb) ))
}

La primera forma de date es GNU, la segunda es BSD/macOS; el || elige la que tengas.

¿Cómo convierto esto en una alerta que me llegue de verdad?

Echobell convierte un webhook o un correo en un push normal, una alerta urgente o una llamada real que atraviesa el modo Concentración y No molestar (ver cómo saltarse el modo Concentración de iOS). Para certificados eso importa porque el último escalón de la tabla anterior es el que te tocará a las 3 de la madrugada de un domingo.

Paso 1 — Crea dos canales, no uno

Crea un canal en la app, pon su tipo de notificación en Urgente y llámalo "Certificados por caducar". Crea un segundo con tipo Llamada y llámalo "Certificado a punto de caducar". Copia la URL de webhook de cada uno desde los detalles del canal; tienen la forma https://hook.echobell.one/t/<channel-token>. Trátalas como secretos: quien tenga la de Llamada puede hacer sonar tu teléfono (guía de webhooks).

Escribe las plantillas para que la notificación sea accionable desde la pantalla de bloqueo sin desbloquear nada:

Título: TLS {{state}}: {{host}}
Cuerpo: quedan {{daysLeft}} días, caduca {{notAfter}} — emitido por {{issuer}}

Cualquier clave JSON que envíes se convierte en variable (plantillas).

Paso 2 — Ejecuta la comprobación

Este es el script de las secciones anteriores, montado y probado de extremo a extremo. Guárdalo como /usr/local/bin/cert-watch:

#!/usr/bin/env bash
# cert-watch — envía un POST a Echobell cuando un certificado TLS está cerca de caducar.
set -uo pipefail

HOOK="${ECHOBELL_CERT_HOOK:?define ECHOBELL_CERT_HOOK con la URL de webhook de tu canal}"
WARN_DAYS="${WARN_DAYS:-14}"

post() {
  curl -sS -m 10 -X POST "$HOOK" \
    -H 'content-type: application/json' \
    -d "{\"host\":\"$1\",\"daysLeft\":$2,\"notAfter\":\"$3\",\"state\":\"$4\"}" \
    >/dev/null
}

days_left() {
  local end
  end=$(date -u -d "$1" +%s 2>/dev/null) ||
    end=$(date -u -j -f '%b %d %T %Y %Z' "$1" +%s 2>/dev/null) || return 1
  echo $(( (end - $(date -u +%s)) / 86400 ))
}

check() {
  local host="$1" port="$2" pem state not_after days

  pem=$(openssl s_client -connect "$host:$port" -servername "$host" \
        </dev/null 2>/dev/null | openssl x509 2>/dev/null)

  if [ -z "$pem" ]; then
    post "$host" 0 "" "unreachable"
    return
  fi

  if printf '%s' "$pem" | openssl x509 -noout -checkend 0 >/dev/null 2>&1; then
    printf '%s' "$pem" | openssl x509 -noout -checkend $((WARN_DAYS * 86400)) >/dev/null 2>&1 && return 0
    state="expiring"
  else
    state="expired"
  fi

  not_after=$(printf '%s' "$pem" | openssl x509 -noout -enddate | cut -d= -f2)
  days=$(days_left "$not_after") || days=-999
  post "$host" "$days" "$not_after" "$state"
}

for target in "$@"; do
  case "$target" in
    *:*) check "${target%:*}" "${target##*:}" ;;
    *) check "$target" 443 ;;
  esac
done

Reporta tres estados —expiring, expired y unreachable— y calla cuando todo va bien. Los objetivos son host o host:port, así que los certificados no web también quedan cubiertos:

ECHOBELL_CERT_HOOK="https://hook.echobell.one/t/<token>" \
  cert-watch example.com api.example.com mail.example.com:993 ldap.internal:636

unreachable es deliberadamente una alerta y no un salto silencioso. Un chequeo que trata "no pude mirar" como "todo está bien" es justamente por lo que caducan los certificados.

Paso 3 — Prográmalo y alerta cuando falle la propia programación

Una vez al día basta para certificados de 45 días o más; dos veces al día si usas certificados de 6 días. Un temporizador de systemd:

# /etc/systemd/system/cert-watch.service
[Unit]
Description=TLS certificate expiry check

[Service]
Type=oneshot
EnvironmentFile=/etc/echobell.env
ExecStart=/usr/local/bin/cert-watch example.com api.example.com mail.example.com:993
# /etc/systemd/system/cert-watch.timer
[Unit]
Description=Daily TLS certificate expiry check

[Timer]
OnCalendar=daily
RandomizedDelaySec=1h
Persistent=true

[Install]
WantedBy=timers.target

Persistent=true importa: sin él, una máquina apagada a la hora prevista simplemente se salta esa ejecución.

Luego cierra el círculo sobre la propia comprobación. Una unidad Type=oneshot que sale con código distinto de cero dispara OnFailure=, así que un solo drop-in hace que una renovación rota se anuncie sola:

# /etc/systemd/system/certbot.service.d/echobell.conf
[Unit]
OnFailure=echobell-alert@%n.service
# /etc/systemd/system/echobell-alert@.service
[Unit]
Description=Echobell alert for %i

[Service]
Type=oneshot
EnvironmentFile=/etc/echobell.env
ExecStart=/usr/local/bin/echobell-notify %i

Donde /usr/local/bin/echobell-notify son tres líneas:

#!/usr/bin/env bash
curl -sS -m 10 -X POST "$ECHOBELL_CERT_HOOK" \
  -H 'content-type: application/json' \
  -d "{\"host\":\"$(hostname -f)\",\"state\":\"renewal-failed\",\"unit\":\"$1\",\"daysLeft\":-1}"

certbot renew sale con código distinto de cero cuando falla cualquier renovación, que es exactamente la señal que quieres y exactamente la que hoy no va a ninguna parte. Ojo: el --deploy-hook de certbot solo se ejecuta al tener éxito, así que no sirve para esto; la ruta de fallo tiene que venir de la unidad.

Paso 4 — Divide la escalera con condiciones

Ambos canales reciben la misma carga; las condiciones deciden cuál dispara. En el canal Urgente:

state == "expiring" && daysLeft > 3

En el canal de Llamada:

state == "expired" || state == "unreachable" || state == "renewal-failed" || daysLeft <= 3

Ten en cuenta que <= convierte ambos lados con Number(), así que un daysLeft enviado como cadena sigue comparándose numéricamente. Las condiciones no tienen operador "contiene", y por eso el script envía un campo state explícito en vez de un texto libre que tendrías que reconocer por patrones.

Paso 5 — Pruébalo antes de confiar en él

Apunta el script a un host con un certificado conocido como malo y comprueba que llega la alerta:

ECHOBELL_CERT_HOOK="https://hook.echobell.one/t/<token>" cert-watch expired.badssl.com

Hazlo con No molestar activado en el teléfono que realmente va a recibirla y activa Reintentar llamada fallida en la app para que una llamada suprimida por Concentración se vuelva a intentar. Una ruta de escalado que nunca has disparado es una suposición.

¿Y si mi monitorización ya comprueba certificados?

Entonces conecta su webhook existente a un canal y sáltate el script. La mayoría de la monitorización ya conoce la fecha de caducidad; lo que suele faltarle es un camino que sobreviva a un humano dormido.

  • Uptime Kuma trae una notificación de caducidad de certificado incorporada: apúntala a un canal (guía de Uptime Kuma, configuración de llamadas).
  • Prometheus + Alertmanager con el blackbox exporter te da probe_ssl_earliest_cert_expiry; alerta sobre esa métrica y enrútala a un canal (guía de Prometheus, llamadas con Alertmanager).
  • Las reglas de alerta de Grafana hacen POST a un canal directamente (guía de Grafana).
  • Upptime y UptimeRobot cubren los endpoints públicos (Upptime, UptimeRobot).
  • Cualquier cosa que solo mande correo —el portal de la propia CA, los avisos de ACM de un proveedor cloud, una PKI interna— se resuelve con una regla de reenvío. Cada canal tiene su propia dirección, y from, to, subject, text y html están disponibles como variables (disparadores por correo).

Lo único que ninguna de esas opciones sustituye es que la comprobación se haga desde fuera de la máquina que sirve el certificado. Si tu monitorización vive en el mismo host, un fallo que tumbe el host se lleva también la alerta.

Lo que Echobell no hace

Ser preciso aquí importa, porque la gestión de certificados es una categoría llena de productos que hacen mucho más que esto.

Echobell sí: convierte un webhook o un correo en push normal, alerta urgente o llamada; filtra con condiciones; formatea con plantillas; entrega un disparo a cada suscriptor de un canal compartido, cada uno con su propia urgencia.

Echobell no:

  • Descubre ni inventaría tus certificados. No escanea tu red, no rastrea los registros de transparencia de certificados ni te habla del certificado que nadie recuerda haber emitido. El script de arriba solo comprueba los hosts que enumeres. La proliferación de certificados es un problema real y esto no es su solución.
  • Renueva nada. No tiene cliente ACME ni acceso a tus claves. Te dice que la renovación se rompió; arreglarla sigue siendo tuyo.
  • Comprueba certificados por su cuenta. No hay sonda alojada. Algo que ejecutes tú —un temporizador, un job de CI, tu monitorización actual— tiene que mirar.
  • Ofrece turnos de guardia, políticas de escalado ni confirmación. No existe "si nadie contesta en cinco minutos, llama al siguiente". Si necesitas eso, necesitas una plataforma de incidentes: mira la comparativa de alternativas a Opsgenie.
  • Garantiza la entrega. Una llamada depende de la infraestructura de push, de la red y de un teléfono cargado. Acorta la distancia entre la avería y el enterarse; no es un control en el que puedas apoyarte de forma absoluta.

Preguntas frecuentes

¿De verdad Let's Encrypt dejó de enviar correos de caducidad?

Sí. El servicio de avisos terminó el 4 de junio de 2025 y Let's Encrypt borró las direcciones que guardaba junto a los registros de emisión. El anuncio recomienda monitorización de terceros y menciona Red Sift Certificates Lite, gratis hasta 250 certificados, como una opción. Si llevas más de un año sin recibir un aviso de caducidad de Let's Encrypt, la razón es esa, no que nunca haya estado nada a punto de caducar.

¿Cuánto duran los certificados TLS en 2026?

Un máximo de 200 días para certificados TLS de confianza pública, desde el 15 de marzo de 2026. El límite baja a 100 días el 15 de marzo de 2027 y a 47 días el 15 de marzo de 2029 según la papeleta SC-081v3. Cada CA emite por debajo del límite por seguridad: DigiCert, por ejemplo, emite certificados de 199 días precisamente "para no exceder la validez máxima permitida". Let's Encrypt va más lejos y más rápido por su cuenta, con certificados de 6 días disponibles desde enero de 2026.

Si uso ARI, ¿debo alertar igualmente sobre caducidad?

Sí, pero sobre eventos distintos. ARI (RFC 9773) le dice a tu cliente ACME cuándo renovar, lo que elimina por completo el fallo del intervalo fijo, pero no garantiza que la renovación tenga éxito, que el servicio recargue ni que el cliente siga vivo. Alerta ante fallos repetidos de renovación y ante una divergencia entre el certificado servido y el que hay en disco, no ante una cuenta atrás que ya no necesitas gestionar.

¿Y los certificados que no están en un servidor web?

Esos son los que más se caducan, porque ningún cliente ACME los vigila y ningún navegador se queja hasta que algo se rompe. El script acepta host:port, así que IMAP en 993, LDAPS en 636, un broker de Kafka en 9093 o una API interna en 8443 funcionan igual. Los certificados que nunca tocan un socket —firma de código, certificados de notificaciones push, certificados de cliente en una flota de dispositivos— necesitan que saques la fecha de donde vivan y la publiques en el mismo canal.

¿Una comprobación diaria no se convierte en ruido?

No si calla cuando no pasa nada, que es justo por lo que el script no publica nada por encima del umbral. El diseño ruidoso es el que informa "certificado OK" todos los días: a las dos semanas nadie lo lee y el día que deja de llegar nadie se da cuenta. Si quieres un latido, ponlo en un canal Normal aparte y nunca en el que suena. En cómo arreglar la fatiga de alertas está la versión general de este argumento.

¿Puede todo el equipo recibir la alerta de certificado?

Sí. Comparte el canal y cada suscriptor recibe el disparo, eligiendo su propio tipo de notificación. Un reparto que funciona: quien esté de guardia se suscribe al canal de Llamada y el resto al Urgente, así una caducidad a las 3 de la madrugada despierta a una persona y no a seis.

¿Funciona en macOS?

Sí, con una salvedad: el date de BSD no acepta -d, y por eso days_left y to_epoch prueban primero la forma GNU y caen a date -u -j -f. El openssl de macOS es LibreSSL por defecto y admite -checkend y -fingerprint igual. Si instalas OpenSSL con Homebrew, nada cambia.

¿Es seguro enviar detalles del certificado en una notificación?

Los campos de aquí —nombre de host, fecha de caducidad, emisor— son públicos; cualquiera puede leerlos de tu servidor con el mismo comando openssl. No amplíes la carga con claves privadas, rutas internas ni nada de un certificado en un host no público más allá del nombre. Echobell guarda el contenido y el historial de notificaciones solo en tu dispositivo, dejando en el servidor únicamente cuentas, canales y suscripciones (modelo de privacidad), lo cual es un buen valor por defecto pero no una razón para enviar más de lo necesario.


Relacionado