commit 5cdb29c667a04549d48cac3fd04f6c952c415eee Author: gergo Date: Fri Jan 23 13:55:37 2026 +0100 Initial template: TLS cert expiry checker diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a9222c --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# user secrets / local overrides +config/cert-expiry.conf +config/domains.txt +config/msmtprc +.env +*.log + +# editor / os noise +.DS_Store +*.swp +*~ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3fc2b97 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# TLS Cert Expiry Check (Template) + +Minimal, POSIX-friendly TLS certificate expiry checker using `openssl s_client`. + +## What this is +- `git clone && run` template repo +- No real domains/emails/credentials committed +- Example configs included +- Easy to integrate into cron / CI / monitoring + +## Repo structure + +```text +cert-expiry-check-template/ +├── bin/ +│ └── cert-expiry-check.sh +├── config/ +│ ├── domains.example.txt +│ ├── cert-expiry.conf.example +│ └── msmtprc.example +├── .gitignore +└── README.md diff --git a/bin/cert-expiry-check.sh b/bin/cert-expiry-check.sh new file mode 100755 index 0000000..c053955 --- /dev/null +++ b/bin/cert-expiry-check.sh @@ -0,0 +1,214 @@ +#!/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 diff --git a/config/cert-expiry.conf.example b/config/cert-expiry.conf.example new file mode 100644 index 0000000..8de95f4 --- /dev/null +++ b/config/cert-expiry.conf.example @@ -0,0 +1,18 @@ +# Copy to config/cert-expiry.conf (gitignored) + +# Notification email settings (examples) +RECIPIENT="alert@example.domain" +SENDER="noreply@example.domain" + +# Check settings +WARN_DAYS=30 +TIMEOUT=12 +FORCE_SEND=0 + +# Files (defaults are repo-local, these are optional) +DOMAINS_FILE="./config/domains.txt" +LOG_FILE="./cert-expiry.log" + +# Email via msmtp (optional) +ENABLE_EMAIL=0 +MSMTP_CONFIG="./config/msmtprc" diff --git a/config/msmtprc.example b/config/msmtprc.example new file mode 100644 index 0000000..f4e8bfe --- /dev/null +++ b/config/msmtprc.example @@ -0,0 +1,13 @@ +# Copy to config/msmtprc (gitignored) and chmod 600 + +defaults +auth on +tls on +tls_trust_file /etc/ssl/certs/ca-certificates.crt + +account default +host smtp.example.domain +port 587 +from noreply@example.domain +user noreply@example.domain +password CHANGE_ME