From 785c13119b95038ea4b844ccb9339c42ba2e566a Mon Sep 17 00:00:00 2001 From: Walusimbi Silver Date: Fri, 24 Apr 2026 10:36:33 +0300 Subject: [PATCH] Add server ops certificate expiry check --- .gitignore | 9 +++ README.md | 61 +++++++++++++++++++++ scripts/check-cert-expiry.sh | 103 +++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 scripts/check-cert-expiry.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d6730c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +Thumbs.db +*.log +*.tmp +*.bak + +.env +.env.* +!.env.example diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f54139 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Silver Server Ops + +Server-wide maintenance scripts and operational notes for the Silver Cloud Hetzner host. + +Suggested repository name: + +```text +silver-server-ops +``` + +## SSL Certificate Expiry Alerts + +`scripts/check-cert-expiry.sh` checks every Let's Encrypt certificate under `/etc/letsencrypt/live` and sends an ntfy alert when any certificate expires in less than 14 days. + +Install on the server: + +```bash +sudo install -m 0755 scripts/check-cert-expiry.sh /opt/scripts/check-cert-expiry.sh +``` + +Recommended root cron: + +```cron +15 8 * * * /opt/scripts/check-cert-expiry.sh >/var/log/cert-expiry-check.log 2>&1 +``` + +Defaults: + +```bash +CERT_DIR=/etc/letsencrypt/live +EXPIRY_DAYS=14 +NTFY_URL=https://ntfy.silverwal.com/silver +``` + +Optional overrides: + +```bash +EXPIRY_DAYS=21 NTFY_URL=https://ntfy.silverwal.com/silver /opt/scripts/check-cert-expiry.sh +``` + +If your ntfy topic is protected with an access token: + +```bash +NTFY_TOKEN=your-token /opt/scripts/check-cert-expiry.sh +``` + +Manual test: + +```bash +sudo /opt/scripts/check-cert-expiry.sh +``` + +The script exits with `0` when all certificates are healthy and `1` when it sends an alert or cannot run the check correctly. + +## Notification Notes + +ntfy is a good default for this server because it is already self-hosted, simple to call from shell scripts, and supports useful alert metadata such as title, priority, and tags. + +For jobs where silence is also a failure, pair ntfy with a dead man's switch such as Healthchecks. ntfy tells you what the script found; Healthchecks tells you when the script did not run at all. + +If you later want one notification config that can fan out to ntfy, email, Slack, Discord, Telegram, and similar services, use Apprise as the wrapper and keep ntfy as one destination. diff --git a/scripts/check-cert-expiry.sh b/scripts/check-cert-expiry.sh new file mode 100644 index 0000000..edeca72 --- /dev/null +++ b/scripts/check-cert-expiry.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash + +set -uo pipefail + +CERT_DIR="${CERT_DIR:-/etc/letsencrypt/live}" +EXPIRY_DAYS="${EXPIRY_DAYS:-14}" +NTFY_URL="${NTFY_URL:-https://ntfy.silverwal.com/silver}" +NTFY_TITLE="${NTFY_TITLE:-SSL certificate warning}" +NTFY_PRIORITY="${NTFY_PRIORITY:-high}" +NTFY_TAGS="${NTFY_TAGS:-warning,lock}" +NTFY_TOKEN="${NTFY_TOKEN:-}" +ALERT_ON_NO_CERTS="${ALERT_ON_NO_CERTS:-true}" + +host="$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo "unknown-host")" +now_epoch="$(date +%s)" +threshold_seconds=$((EXPIRY_DAYS * 86400)) + +send_alert() { + local message="$1" + local curl_args=( + -fsS + -H "Title: ${NTFY_TITLE}" + -H "Priority: ${NTFY_PRIORITY}" + -H "Tags: ${NTFY_TAGS}" + ) + + if [[ -n "${NTFY_TOKEN}" ]]; then + curl_args+=(-H "Authorization: Bearer ${NTFY_TOKEN}") + fi + + curl "${curl_args[@]}" --data-binary "${message}" "${NTFY_URL}" >/dev/null +} + +fail() { + local message="$1" + send_alert "${message}" || echo "Failed to send ntfy alert to ${NTFY_URL}" >&2 + echo "${message}" >&2 + exit 1 +} + +if ! command -v openssl >/dev/null 2>&1; then + fail "SSL certificate check failed on ${host}: openssl is not installed." +fi + +if ! command -v curl >/dev/null 2>&1; then + echo "SSL certificate check failed on ${host}: curl is not installed." >&2 + exit 1 +fi + +if [[ ! -d "${CERT_DIR}" ]]; then + fail "SSL certificate check failed on ${host}: certificate directory ${CERT_DIR} does not exist." +fi + +warnings=() +cert_count=0 + +while IFS= read -r -d '' cert_path; do + cert_count=$((cert_count + 1)) + cert_name="$(basename "$(dirname "${cert_path}")")" + + enddate_line="$(openssl x509 -in "${cert_path}" -noout -enddate 2>/dev/null)" + if [[ -z "${enddate_line}" ]]; then + warnings+=("${cert_name}: could not read expiry date from ${cert_path}") + continue + fi + + not_after="${enddate_line#notAfter=}" + expiry_epoch="$(date -d "${not_after}" +%s 2>/dev/null)" + if [[ -z "${expiry_epoch}" ]]; then + warnings+=("${cert_name}: could not parse expiry date '${not_after}'") + continue + fi + + seconds_left=$((expiry_epoch - now_epoch)) + days_left=$((seconds_left / 86400)) + + if (( seconds_left < 0 )); then + warnings+=("${cert_name}: EXPIRED on ${not_after}") + elif ! openssl x509 -checkend "${threshold_seconds}" -noout -in "${cert_path}" >/dev/null 2>&1; then + warnings+=("${cert_name}: expires in ${days_left} day(s), on ${not_after}") + fi +done < <(find "${CERT_DIR}" -mindepth 2 -maxdepth 2 \( -type f -o -type l \) -name cert.pem -print0) + +if (( cert_count == 0 )); then + if [[ "${ALERT_ON_NO_CERTS}" == "true" ]]; then + fail "SSL certificate check found no cert.pem files under ${CERT_DIR} on ${host}." + fi + + echo "No certificates found under ${CERT_DIR}; no alert sent." + exit 0 +fi + +if (( ${#warnings[@]} > 0 )); then + message="SSL certificate expiry warning on ${host}. Threshold: ${EXPIRY_DAYS} day(s). + +$(printf '%s\n' "${warnings[@]}")" + + send_alert "${message}" || echo "Failed to send ntfy alert to ${NTFY_URL}" >&2 + echo "${message}" >&2 + exit 1 +fi + +echo "All ${cert_count} certificate(s) are valid for more than ${EXPIRY_DAYS} day(s)."