#!/bin/sh # cert-expiry-check.sh - POSIX-friendly TLS cert expiry checker template # Fetches certificate notAfter via openssl s_client, computes days left, prints status lines. # # Exit codes: # 0 = all OK # 1 = at least one WARN (expiring soon) # 2 = at least one EXPIRED or ERROR # 3 = usage/config error set -eu # -------------------- defaults (override via config) -------------------- WARN_DAYS="${WARN_DAYS:-30}" TIMEOUT="${TIMEOUT:-12}" FORCE_SEND="${FORCE_SEND:-0}" ENABLE_EMAIL="${ENABLE_EMAIL:-0}" DOMAINS_FILE="${DOMAINS_FILE:-./config/domains.txt}" CONF_FILE="${CONF_FILE:-./config/cert-expiry.conf}" MSMTP_CONFIG="${MSMTP_CONFIG:-./config/msmtprc}" LOG_FILE="${LOG_FILE:-}" # empty => no logfile RECIPIENT="${RECIPIENT:-}" SENDER="${SENDER:-}" CHECK_ONLY=0 # -------------------- helpers -------------------- die() { echo "ERROR: $*" >&2; exit 3; } have_cmd() { command -v "$1" >/dev/null 2>&1; } log() { ts="$(date '+%Y-%m-%d %H:%M:%S')" line="$ts $*" echo "$line" if [ -n "$LOG_FILE" ]; then printf '%s\n' "$line" >>"$LOG_FILE" 2>/dev/null || true fi } usage() { cat <<'USAGE' Usage: cert-expiry-check.sh [--check-only] [--domains FILE] [--config FILE] --check-only Do not send email, just print results and exit with status policy --domains FILE Use a custom domains file (default: ./config/domains.txt) --config FILE Use a custom config file (default: ./config/cert-expiry.conf) USAGE } # -------------------- args -------------------- while [ "${1:-}" != "" ]; do case "$1" in --check-only) CHECK_ONLY=1 ;; --domains) shift; [ -n "${1:-}" ] || die "--domains requires a file"; DOMAINS_FILE="$1" ;; --config) shift; [ -n "${1:-}" ] || die "--config requires a file"; CONF_FILE="$1" ;; -h|--help) usage; exit 0 ;; *) die "Unknown arg: $1" ;; esac shift done # -------------------- load config (optional) -------------------- if [ -f "$CONF_FILE" ]; then # shellcheck disable=SC1090 . "$CONF_FILE" fi # -------------------- validate -------------------- have_cmd openssl || die "openssl not found" have_cmd date || die "date not found" [ -f "$DOMAINS_FILE" ] || die "Domains file not found: $DOMAINS_FILE" # date string -> epoch (GNU date) to_epoch() { # input like: "Jul 5 12:00:00 2026 GMT" date -u -d "$1" +%s 2>/dev/null } normalize_target() { # host or host:port -> host:port (default 443) case "$1" in *:*) printf '%s\n' "$1" ;; *) printf '%s:443\n' "$1" ;; esac } get_notafter() { hp="$1" host="${hp%:*}" port="${hp##*:}" # Use timeout if available, else best-effort. if have_cmd timeout; then timeout "$TIMEOUT" openssl s_client -servername "$host" -connect "$host:$port" /dev/null \ | openssl x509 -noout -enddate 2>/dev/null \ | sed 's/^notAfter=//' else openssl s_client -servername "$host" -connect "$host:$port" /dev/null \ | openssl x509 -noout -enddate 2>/dev/null \ | sed 's/^notAfter=//' fi } send_email() { # Returns 0 on success, non-zero on failure. have_cmd msmtp || return 1 [ -n "$RECIPIENT" ] || return 1 [ -n "$SENDER" ] || return 1 [ -f "$MSMTP_CONFIG" ] || return 1 subject="[cert-expiry] warn=$warn_count expired=$expired_count error=$error_count" { printf 'From: %s\n' "$SENDER" printf 'To: %s\n' "$RECIPIENT" printf 'Subject: %s\n' "$subject" printf '\n' printf '%s\n' "$summary_line" printf '\n' # mail_body contains \n escapes printf "%b" "$mail_body" } | msmtp -C "$MSMTP_CONFIG" -t } # -------------------- run -------------------- now_epoch="$(date -u +%s)" ok_count=0 warn_count=0 expired_count=0 error_count=0 mail_body="" log "Certificate expiry report ($(date -u))" log "Domains file: $DOMAINS_FILE" log "Threshold: ${WARN_DAYS} days" while IFS= read -r raw || [ -n "$raw" ]; do # trim trailing spaces line="$(printf '%s' "$raw" | sed 's/[[:space:]]*$//')" # skip comments/empty case "$line" in ""|\#*) continue ;; esac target="$(normalize_target "$line")" notafter="$(get_notafter "$target" || true)" if [ -z "$notafter" ]; then error_count=$((error_count + 1)) log "ERROR $target could_not_fetch_cert_enddate" mail_body="${mail_body}ERROR $target could_not_fetch_cert_enddate\n" continue fi exp_epoch="$(to_epoch "$notafter" || true)" if [ -z "${exp_epoch:-}" ]; then error_count=$((error_count + 1)) log "ERROR $target could_not_parse_notAfter='$notafter'" mail_body="${mail_body}ERROR $target could_not_parse_notAfter='$notafter'\n" continue fi seconds_left=$((exp_epoch - now_epoch)) days_left=$((seconds_left / 86400)) if [ "$seconds_left" -lt 0 ]; then expired_count=$((expired_count + 1)) log "EXPIRED $target days_left=$days_left notAfter='$notafter'" mail_body="${mail_body}EXPIRED $target days_left=$days_left notAfter='$notafter'\n" elif [ "$days_left" -le "$WARN_DAYS" ]; then warn_count=$((warn_count + 1)) log "WARN $target days_left=$days_left notAfter='$notafter'" mail_body="${mail_body}WARN $target days_left=$days_left notAfter='$notafter'\n" else ok_count=$((ok_count + 1)) log "OK $target days_left=$days_left notAfter='$notafter'" fi done <"$DOMAINS_FILE" summary_line="SUMMARY ok=$ok_count warn=$warn_count expired=$expired_count error=$error_count warn_days=$WARN_DAYS" log "$summary_line" # Email decision should_send=0 if [ "$ENABLE_EMAIL" -eq 1 ] && [ "$CHECK_ONLY" -eq 0 ]; then if [ "$FORCE_SEND" -eq 1 ]; then should_send=1 elif [ "$warn_count" -gt 0 ] || [ "$expired_count" -gt 0 ] || [ "$error_count" -gt 0 ]; then should_send=1 fi fi if [ "$should_send" -eq 1 ]; then if send_email; then log "EMAIL sent_to=$RECIPIENT" else log "EMAIL failed (msmtp/config/recipient/sender missing)" error_count=$((error_count + 1)) fi else log "EMAIL skipped (enable_email=$ENABLE_EMAIL check_only=$CHECK_ONLY force_send=$FORCE_SEND)" fi # -------------------- exit code policy (FIXED) -------------------- if [ "$expired_count" -gt 0 ] || [ "$error_count" -gt 0 ]; then exit 2 elif [ "$warn_count" -gt 0 ]; then exit 1 else exit 0 fi