Tested script

Check TLS certificate expiry for a list of hosts (Bash)

Connects to each host, reads its certificate and shows how many days are left. Run it weekly from cron so an expired certificate never surprises you.

Tested

The script

#!/usr/bin/env bash
# check-cert-expiry.sh: report how many days are left on TLS certificates.
#
# Usage: check-cert-expiry.sh [-w DAYS] HOST[:PORT]...
#   -w DAYS   warn when a certificate expires within DAYS (default 30)
#   HOST      a host name; the port defaults to 443
#
# Exit codes: 0 all fine, 1 a certificate is expiring or could not be read,
# 2 bad usage. Read-only: it only opens a TLS connection.
set -euo pipefail

usage() { sed -n '4,6p' "$0" | sed 's/^# \{0,1\}//'; }

warn_days=30
while getopts ':w:h' opt; do
  case "$opt" in
    w) warn_days="$OPTARG" ;;
    h) usage; exit 0 ;;
    *) usage >&2; exit 2 ;;
  esac
done
shift $((OPTIND - 1))

[[ "$warn_days" =~ ^[0-9]+$ ]] || { echo "Error: -w must be a whole number" >&2; exit 2; }
[[ $# -gt 0 ]] || { usage >&2; exit 2; }

status=0
now=$(date +%s)
for target in "$@"; do
  host="${target%%:*}"
  port="${target##*:}"
  [[ "$target" == *:* ]] || port=443

  end_date=$(timeout 15 openssl s_client -connect "$host:$port" -servername "$host" \
      </dev/null 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2) || true

  if [[ -z "$end_date" ]]; then
    echo "ERROR    $host:$port  could not read a certificate"
    status=1
    continue
  fi

  days_left=$(( ($(date -d "$end_date" +%s) - now) / 86400 ))
  if (( days_left < 0 )); then
    echo "EXPIRED  $host:$port  expired $(( -days_left )) days ago ($end_date)"
    status=1
  elif (( days_left < warn_days )); then
    echo "WARN     $host:$port  expires in $days_left days ($end_date)"
    status=1
  else
    echo "OK       $host:$port  expires in $days_left days ($end_date)"
  fi
done
exit "$status"

Run it

./check-cert-expiry.sh example.com mail.example.com:993
./check-cert-expiry.sh -w 14 $(cat hosts.txt)

How it works

Options

-w sets how many days of warning you want, 30 by default. The hosts are the remaining arguments, each with an optional :port. With no hosts the script prints usage and exits 2.

Loop over the hosts

A for loop handles each host in turn. Parameter expansion splits host:port: ${target%%:*} keeps what is before the colon and ${target##*:} keeps what is after. No colon means port 443.

Read the certificate

openssl s_client opens the connection. -servername sends the host name (SNI), which shared hosting needs to pick the right certificate. </dev/null closes the connection straight away and timeout 15 stops a dead host hanging the script. openssl x509 -noout -enddate prints the expiry date and cut -d= -f2 keeps just the date.

Work out the days

date -d ... +%s turns the date into seconds, and the difference divided by 86400 gives whole days. Each host gets an OK, WARN, EXPIRED or ERROR line.

Exit code

0 means every certificate is fine. 1 means at least one is expiring, expired or unreadable, so cron or a monitoring tool can alert you.

Note: it checks the expiry date only. It does not check that the certificate chain is trusted or that the name matches.