Say you have a (long) list of sites that you have to check the SSL certificate of (SANs, expiration date, etc). This script does it all.
HOSTFILE="${1:?usage: $0 <hostfile> [warn_days]}"
WARN_DAYS="${2:-30}"
# Max seconds to wait per host, so one dead/firewalled host can't hang the loop.
TIMEOUT=10
while read -r entry; do
# Skip empty lines and comments so the host file can be annotated.
[[ -z "$entry" || "$entry" =~ ^[[:space:]]*# ]] && continue
# Split "host:port"; if there is no colon, ${entry##*:} returns the whole
# string, which is how we detect "no port given" and fall back to 443.
host="${entry%%:*}"
port="${entry##*:}"
[[ "$port" == "$entry" ]] && port=443
echo "=== ${host}:${port} ==="
# Connect and capture the full handshake output once.
# -servername: sends SNI. Without it, servers hosting several sites on one
# IP (nginx, haproxy, k3s ingress...) may hand you the default
# cert instead of the one for this hostname.
# </dev/null: closes s_client's stdin so it disconnects immediately (this
# replaces your manual "q<enter>"). It ALSO stops s_client from
# reading stdin, which here is the host file - without it,
# s_client would swallow the remaining hosts and the loop
# would only process the first one.
# 2>/dev/null: hides the handshake chatter that s_client writes to stderr.
out=$(timeout "$TIMEOUT" openssl s_client -connect "${host}:${port}" \
-servername "$host" </dev/null 2>/dev/null)
# openssl x509 picks the first PEM block out of the output - the server's
# own certificate. This replaces the scroll-back/copy/paste step.
cert=$(openssl x509 <<<"$out" 2>/dev/null)
if [[ -z "$cert" ]]; then
echo " ERROR: could not retrieve a certificate (unreachable, timeout, or no TLS)"
echo
continue
fi
# "notAfter=Dec 31 12:00:00 2026 GMT" -> keep only the date part.
enddate=$(openssl x509 -noout -enddate <<<"$cert" | cut -d= -f2)
# Convert expiry to epoch seconds (GNU date) to compute whole days left.
days_left=$(( ( $(date -d "$enddate" +%s) - $(date +%s) ) / 86400 ))
# -checkend N exits 0 if the cert is still valid N seconds from now.
# Check "already expired" first, then "expires within the warning window".
if ! openssl x509 -noout -checkend 0 <<<"$cert" >/dev/null; then
status="EXPIRED"
elif ! openssl x509 -noout -checkend $(( WARN_DAYS * 86400 )) <<<"$cert" >/dev/null; then
status="WARNING (expires within ${WARN_DAYS} days)"
else
status="OK"
fi
# s_client already verified the chain against the system CA store during
# the handshake; it prints the result as "Verify return code: 0 (ok)".
# Anything other than 0 means self-signed, missing intermediate, etc.
verify=$(grep -m1 'Verify return code' <<<"$out" | sed 's/.*Verify return code: //')
# -checkhost tests whether the name we connected to is covered by the cert
# (CN or any SAN, wildcards included). Catches "right server, wrong cert".
if openssl x509 -noout -checkhost "$host" <<<"$cert" | grep -q 'does match'; then
hostmatch="yes"
else
hostmatch="NO"
fi
echo " Subject: $(openssl x509 -noout -subject <<<"$cert" | sed 's/^subject=//')"
echo " Issuer: $(openssl x509 -noout -issuer <<<"$cert" | sed 's/^issuer=//')"
echo " Expires: ${enddate} (${days_left} days)"
echo " Status: ${status}"
echo " Chain: ${verify:-unknown}"
echo " Host match: ${hostmatch}"
echo " SANs:"
# -ext subjectAltName prints a header line, then one line with all SANs
# comma-separated ("DNS:a, DNS:b, IP Address:1.2.3.4").
# tail drops the header, sed puts each SAN on its own indented line.
openssl x509 -noout -ext subjectAltName <<<"$cert" 2>/dev/null \
| tail -n +2 \
| sed -e 's/^[[:space:]]*//' -e 's/, /\n/g' \
| sed 's/^/ /'
echo
done < "$HOSTFILE"
Comments