Initial template: TLS cert expiry checker

This commit is contained in:
gergo
2026-01-23 13:55:37 +01:00
commit 5cdb29c667
5 changed files with 278 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@@ -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
*~

22
README.md Normal file
View File

@@ -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

214
bin/cert-expiry-check.sh Executable file
View File

@@ -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 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null \
| sed 's/^notAfter=//'
else
openssl s_client -servername "$host" -connect "$host:$port" </dev/null 2>/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

View File

@@ -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"

13
config/msmtprc.example Normal file
View File

@@ -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