← back to Kamatera Ops
cert-fleet-fix.sh
113 lines
#!/usr/bin/env bash
# cert-fleet-fix.sh — TK-12164. Finds Let's Encrypt certs on Kamatera that are failing to renew
# and moves each onto a validation method that actually reaches it, then renews it.
#
# Why failures happen here: public :80 traffic only lands on IP-bound vhosts
# (listen 45.61.58.125:80), so certbot's "nginx" authenticator (which edits the site's own vhost)
# and webroot paths other than /var/www/certbot (which 00-acme-default-80.conf serves) 404.
#
# Per lineage, each method is proven with a --dry-run against LE staging first (no rate-limit cost):
# 1. zone on Cloudflare DNS -> dns-cloudflare (DNS-01; bypasses nginx entirely), trying each
# /root/.secrets/cloudflare*.ini until one can edit the zone
# 2. otherwise -> webroot /var/www/certbot, only if a probe file is reachable
# Only a method whose dry-run passes is used for the real renewal. Nothing passes -> reported, untouched.
#
# Usage: cert-fleet-fix.sh [--days N] candidates = certs expiring within N days (default 30)
# cert-fleet-fix.sh --report dry-runs only: list candidates + chosen method, renew nothing
# cert-fleet-fix.sh --cron as default, but only proceeds at 01:xx America/Los_Angeles
# (server is UTC; crontab fires at 08 and 09 UTC to cover DST)
# Undo: renewal confs are backed up to /root/cert-backups/<ts>/ before any change:
# cp /root/cert-backups/<ts>/*.conf /etc/letsencrypt/renewal/
set -uo pipefail
DAYS=30; REPORT=0; CRON=0
while [ $# -gt 0 ]; do case "$1" in
--days) DAYS="$2"; shift 2;; --report) REPORT=1; shift;; --cron) CRON=1; shift;;
*) echo "unknown arg $1"; exit 2;; esac; done
if [ "$CRON" = 1 ] && [ "$(TZ=America/Los_Angeles date +%H)" != 01 ]; then exit 0; fi
TS=$(date -u +%Y%m%dT%H%M%SZ)
LOG=/var/log/cert-fleet-fix.log
BK=/root/cert-backups/$TS
WEBROOT=/var/www/certbot
CF_INIS=(/root/.secrets/cloudflare-master.ini /root/.secrets/cloudflare-dw.ini /root/.secrets/cloudflare.ini)
say() { echo "$(date -u +%FT%TZ) $*" | tee -a "$LOG"; }
# Registrable zone for a name (last two labels; fine for this fleet's .com names).
zone_of() { echo "$1" | awk -F. '{print $(NF-1)"."$NF}'; }
on_cloudflare() { dig +short NS "$(zone_of "$1")" @1.1.1.1 | grep -q 'ns.cloudflare.com'; }
# Domains a lineage covers, from its live cert (authoritative, unlike parsing the conf).
domains_of() { openssl x509 -noout -ext subjectAltName -in "/etc/letsencrypt/live/$1/cert.pem" 2>/dev/null \
| grep -oE 'DNS:[^,]+' | sed 's/DNS://' | tr '\n' ' '; }
days_left() { echo $(( ( $(date -d "$(openssl x509 -enddate -noout -in "/etc/letsencrypt/live/$1/cert.pem" | cut -d= -f2)" +%s) - $(date +%s) ) / 86400 )); }
dargs() { local a=""; for d in $1; do a="$a -d $d"; done; echo "$a"; }
# certbot allows one instance at a time; certbot.timer fires twice a day and a collision makes every
# attempt fail instantly with "Another instance of Certbot is already running" — which must never be
# misread as a token/probe failure. Wait (up to 60 min) for any other certbot process to exit.
wait_lock() {
local waited=0
while pgrep -f '(^|/)certbot( |$)' >/dev/null; do
[ "$waited" -ge 3600 ] && { say "certbot lock still held after 60m"; return 1; }
sleep 15; waited=$((waited+15))
done
}
cb() { wait_lock || return 1; certbot "$@"; }
try_dns() { # lineage, domains -> prints the working ini
local lin=$1 doms=$2 ini
for ini in "${CF_INIS[@]}"; do
[ -f "$ini" ] || continue
if cb certonly --dry-run --non-interactive --dns-cloudflare --dns-cloudflare-credentials "$ini" \
--dns-cloudflare-propagation-seconds 30 --cert-name "$lin" $(dargs "$doms") >>"$LOG" 2>&1; then
echo "$ini"; return 0; fi
done; return 1
}
try_webroot() { # lineage, domains
local lin=$1 doms=$2 d p=probe-tk12164-$$
mkdir -p "$WEBROOT/.well-known/acme-challenge"; echo ok > "$WEBROOT/.well-known/acme-challenge/$p"
for d in $doms; do
if [ "$(curl -s -m 8 -L "http://$d/.well-known/acme-challenge/$p")" != ok ]; then
rm -f "$WEBROOT/.well-known/acme-challenge/$p"; echo "probe unreachable at http://$d"; return 1; fi
done
rm -f "$WEBROOT/.well-known/acme-challenge/$p"
# Distinct from a probe failure: the route works but Let's Encrypt staging still refused.
cb certonly --dry-run --non-interactive --webroot -w "$WEBROOT" --cert-name "$lin" $(dargs "$doms") >>"$LOG" 2>&1 \
|| { echo "staging dry-run refused (route OK) — see $LOG"; return 1; }
}
# Candidates: certs expiring within DAYS. certbot.timer renews at 30 days left, so a cert under
# 30 days is one the timer has failed to renew.
# Most urgent first (expired, then fewest days left), so a long run fixes what's on fire before the rest.
mapfile -t LINS < <(for c in /etc/letsencrypt/live/*/cert.pem; do
l=$(basename "$(dirname "$c")"); d=$(days_left "$l"); [ "$d" -lt "$DAYS" ] && echo "$d $l"; done \
| sort -n | cut -d' ' -f2)
say "=== run $TS candidates=${#LINS[@]} (expiring <${DAYS}d) report_only=$REPORT"
if [ "$REPORT" = 0 ] && [ "${#LINS[@]}" -gt 0 ]; then
mkdir -p "$BK"; cp /etc/letsencrypt/renewal/*.conf "$BK/"; say "backup: $BK"; fi
ok=0; fail=0; FAILED=()
for lin in "${LINS[@]}"; do
doms=$(domains_of "$lin"); left=$(days_left "$lin"); first=${doms%% *}
if on_cloudflare "$first"; then
if ini=$(try_dns "$lin" "$doms"); then method="dns-cloudflare ($ini)"
[ "$REPORT" = 1 ] || cb certonly --non-interactive --force-renewal --dns-cloudflare \
--dns-cloudflare-credentials "$ini" --dns-cloudflare-propagation-seconds 30 \
--cert-name "$lin" $(dargs "$doms") >>"$LOG" 2>&1 || method="FAILED-real dns-cloudflare"
else method="FAILED no Cloudflare token can edit $(zone_of "$first")"; fi else
if why=$(try_webroot "$lin" "$doms"); then method="webroot $WEBROOT"
[ "$REPORT" = 1 ] || cb certonly --non-interactive --force-renewal --webroot -w "$WEBROOT" \
--cert-name "$lin" $(dargs "$doms") >>"$LOG" 2>&1 || method="FAILED-real webroot"
else method="FAILED webroot: $why"; fi
fi
case "$method" in FAILED*) fail=$((fail+1)); FAILED+=("$lin");; *) ok=$((ok+1));; esac
say "$lin | ${left}d | $doms| $method"
done
if [ "$REPORT" = 0 ] && [ "$ok" -gt 0 ]; then nginx -t >>"$LOG" 2>&1 && systemctl reload nginx && say "nginx reloaded"; fi
say "=== done ok=$ok failed=$fail ${FAILED[*]:-}"
[ "$fail" -eq 0 ] # non-zero exit when anything is still failing, so cron/canaries see it