#!/usr/bin/env bash ######################################################################################### #### ssl-cert-checker.sh — Check SSL certificate expiry across multiple endpoints #### #### Reads hosts from CLI args, a file, or stdin and reports days remaining #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.00 #### #### #### #### Usage: #### #### ./ssl-cert-checker.sh example.com google.com:443 #### #### ./ssl-cert-checker.sh --file hosts.txt #### #### echo "example.com" | ./ssl-cert-checker.sh #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── CERT_CHECK_TIMEOUT="${CERT_CHECK_TIMEOUT:-5}" WARN_DAYS="${WARN_DAYS:-30}" CRIT_DAYS="${CRIT_DAYS:-7}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" JSON_OUTPUT="${JSON_OUTPUT:-false}" HOST_FILE="" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME HOSTS=() COUNT_OK=0 COUNT_WARN=0 COUNT_CRIT=0 COUNT_EXPIRED=0 COUNT_ERROR=0 COUNT_TOTAL=0 JSON_RESULTS=() # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" CYAN="" BOLD="" DIM="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" CYAN="" BOLD="" DIM="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${CYAN}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${DIM}[DEBUG]${RESET} $*"; fi; } # ── Helpers ─────────────────────────────────────────────────────────── section_header() { echo "" echo -e " ${BOLD}${CYAN}── $1 ──${RESET}" echo "" } field() { printf " ${BOLD}%-22s${RESET} %s\n" "$1" "$2" } field_color() { printf " ${BOLD}%-22s${RESET} %b\n" "$1" "$2" } # ══════════════════════════════════════════════════════════════════════ # CERTIFICATE CHECK # ══════════════════════════════════════════════════════════════════════ check_cert() { local host="$1" local port="$2" verbose "Connecting to ${host}:${port} (timeout ${CERT_CHECK_TIMEOUT}s)" local cert_output if ! cert_output=$(openssl s_client -servername "$host" -connect "${host}:${port}" 2>/dev/null <<< "" | openssl x509 -noout -subject -issuer -dates 2>/dev/null); then err "Failed to retrieve certificate from ${host}:${port}" COUNT_ERROR=$((COUNT_ERROR + 1)) if [[ "$JSON_OUTPUT" == "true" ]]; then JSON_RESULTS+=("{\"host\":\"${host}\",\"port\":${port},\"status\":\"ERROR\",\"error\":\"connection failed\"}") fi return fi local subject issuer not_after subject=$(echo "$cert_output" | grep "^subject=" | sed 's/^subject=//') issuer=$(echo "$cert_output" | grep "^issuer=" | sed 's/^issuer=//') not_after=$(echo "$cert_output" | grep "^notAfter=" | sed 's/^notAfter=//') if [[ -z "$not_after" ]]; then err "Could not parse certificate dates for ${host}:${port}" COUNT_ERROR=$((COUNT_ERROR + 1)) return fi local expiry_epoch now_epoch days_remaining expiry_epoch=$(date -d "$not_after" +%s 2>/dev/null) now_epoch=$(date +%s) days_remaining=$(( (expiry_epoch - now_epoch) / 86400 )) local status color if [[ "$days_remaining" -lt 0 ]]; then status="EXPIRED" color="$RED" COUNT_EXPIRED=$((COUNT_EXPIRED + 1)) elif [[ "$days_remaining" -le "$CRIT_DAYS" ]]; then status="CRITICAL" color="$RED" COUNT_CRIT=$((COUNT_CRIT + 1)) elif [[ "$days_remaining" -le "$WARN_DAYS" ]]; then status="WARNING" color="$YELLOW" COUNT_WARN=$((COUNT_WARN + 1)) else status="OK" color="$GREEN" COUNT_OK=$((COUNT_OK + 1)) fi if [[ "$JSON_OUTPUT" == "true" ]]; then # Escape double quotes in subject/issuer for JSON local json_subject json_issuer json_subject="${subject//\"/\\\"}" json_issuer="${issuer//\"/\\\"}" JSON_RESULTS+=("{\"host\":\"${host}\",\"port\":${port},\"subject\":\"${json_subject}\",\"issuer\":\"${json_issuer}\",\"expiry\":\"${not_after}\",\"days_remaining\":${days_remaining},\"status\":\"${status}\"}") else echo "" field "Host:" "${host}:${port}" field "Subject:" "$subject" field "Issuer:" "$issuer" field "Expiry:" "$not_after" field_color "Days remaining:" "${color}${days_remaining}${RESET}" field_color "Status:" "${color}${status}${RESET}" fi } # ══════════════════════════════════════════════════════════════════════ # INPUT PARSING # ══════════════════════════════════════════════════════════════════════ parse_host() { local entry="$1" local host port # Strip whitespace and skip empty/comment lines entry=$(echo "$entry" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') [[ -z "$entry" || "$entry" == \#* ]] && return if [[ "$entry" == *:* ]]; then host="${entry%%:*}" port="${entry##*:}" else host="$entry" port="443" fi HOSTS+=("${host}:${port}") } load_hosts_from_file() { local file="$1" if [[ ! -f "$file" ]]; then err "File not found: $file" exit 1 fi while IFS= read -r line; do parse_host "$line" done < "$file" } load_hosts_from_stdin() { while IFS= read -r line; do parse_host "$line" done } # ══════════════════════════════════════════════════════════════════════ # USAGE # ══════════════════════════════════════════════════════════════════════ usage() { cat <&2 exit 1 ;; *) parse_host "$1"; shift ;; esac done } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ main() { parse_args "$@" setup_colors # Require openssl if ! command -v openssl &>/dev/null; then err "openssl is required but not found" exit 1 fi # Load hosts from file if specified if [[ -n "$HOST_FILE" ]]; then load_hosts_from_file "$HOST_FILE" fi # Load from stdin if no hosts yet and stdin is not a terminal if [[ ${#HOSTS[@]} -eq 0 ]] && ! [[ -t 0 ]]; then load_hosts_from_stdin fi if [[ ${#HOSTS[@]} -eq 0 ]]; then err "No hosts specified" echo "Run ${SCRIPT_NAME} --help for usage" >&2 exit 1 fi COUNT_TOTAL=${#HOSTS[@]} if [[ "$JSON_OUTPUT" != "true" ]]; then echo "" echo -e "${BOLD}SSL Certificate Check${RESET}" echo -e "${DIM}$(date '+%Y-%m-%d %H:%M:%S %Z')${RESET}" echo -e "${DIM}Thresholds: warn=${WARN_DAYS}d, crit=${CRIT_DAYS}d, timeout=${CERT_CHECK_TIMEOUT}s${RESET}" section_header "Certificates" fi for entry in "${HOSTS[@]}"; do local host port host="${entry%%:*}" port="${entry##*:}" check_cert "$host" "$port" done if [[ "$JSON_OUTPUT" == "true" ]]; then echo "{" echo " \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"," echo " \"summary\": {" echo " \"total\": ${COUNT_TOTAL}," echo " \"ok\": ${COUNT_OK}," echo " \"warning\": ${COUNT_WARN}," echo " \"critical\": ${COUNT_CRIT}," echo " \"expired\": ${COUNT_EXPIRED}," echo " \"error\": ${COUNT_ERROR}" echo " }," echo " \"results\": [" local i=0 for result in "${JSON_RESULTS[@]}"; do i=$((i + 1)) if [[ $i -lt ${#JSON_RESULTS[@]} ]]; then echo " ${result}," else echo " ${result}" fi done echo " ]" echo "}" else section_header "Summary" field "Total checked:" "$COUNT_TOTAL" field_color "OK:" "${GREEN}${COUNT_OK}${RESET}" if [[ "$COUNT_WARN" -gt 0 ]]; then field_color "Warning:" "${YELLOW}${COUNT_WARN}${RESET}" else field "Warning:" "$COUNT_WARN" fi if [[ "$COUNT_CRIT" -gt 0 ]]; then field_color "Critical:" "${RED}${COUNT_CRIT}${RESET}" else field "Critical:" "$COUNT_CRIT" fi if [[ "$COUNT_EXPIRED" -gt 0 ]]; then field_color "Expired:" "${RED}${COUNT_EXPIRED}${RESET}" else field "Expired:" "$COUNT_EXPIRED" fi if [[ "$COUNT_ERROR" -gt 0 ]]; then field_color "Errors:" "${RED}${COUNT_ERROR}${RESET}" else field "Errors:" "$COUNT_ERROR" fi echo "" fi } main "$@"