#!/bin/sh # ============================================================ # nginx 证书装载自愈 —— 47.94.88.244(payment-platform) # # 【为什么需要它】 # NPM(jc21/nginx-proxy-manager)的定时续期路径 # processExpiringHosts -> renew -> renewLetsEncryptSsl* # 只跑 certbot,从头到尾没有 reload nginx。 # 证书文件和 NPM 数据库都更新了,nginx 内存里还是旧证书, # 直到下一次改配置(动 NPM 界面)才顺带重载。 # # 2026-08-25 三张证书静默续期成功,nginx 一直发 06-25 那张, # 09-23 过期,三个域名 HTTPS 中断三天才被发现。 # 下一次静默续期约 10/23,不处理则 11/22 重演。 # # 【做什么】 # 比对「nginx 实际发出的证书」与「磁盘当前证书」: # 不一致 -> nginx -t 通过后 reload,再复验一次 # 一致 -> 只在日志里记一行,不动 nginx # 另有兜底:实际发出的证书剩余不足 WARN_DAYS 天,无论比对结果都告警。 # # 【部署】 # scp devops/check-cert-loaded.sh root@47.94.88.244:/root/payments/deploy/ # chmod +x /root/payments/deploy/check-cert-loaded.sh # /root/payments/deploy/check-cert-loaded.sh # 手工跑一次,应三个域名全 OK # crontab -e 加一行(输出由脚本自己写日志,cron 行保持短,防粘贴折行): # 17 4 * * * /root/payments/deploy/check-cert-loaded.sh # # 钉钉 webhook 从 $ENV_FILE 的 DINGTALK_WEBHOOK= 读取,未配置则只写日志。 # 注意:钉钉机器人配了 IP 白名单,先确认本机公网出口 IP 已加入, # 否则返回 {"errcode":310000,...}(见运维手册告警章节)。 # ============================================================ PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin export PATH # cron 下输出落到日志;手工执行时(stdout 是终端)直接打屏,方便验证 LOG_FILE=/var/log/cert-loaded.log if [ ! -t 1 ]; then exec >> "$LOG_FILE" 2>&1 fi CONTAINER=nginx-proxy-manager DOMAINS="qcsj88888.com api.qcsj88888.com doc.qcsj88888.com" # 兜底阈值。NPM 在到期前 30 天续期,正常运行时实际发出的证书 # 不会低于约 30 天;低于此值说明续期或重载至少有一环没走通。 WARN_DAYS=15 ENV_FILE=/root/payments/deploy/.env TAG='[cert-loaded]' NOW=$(date +%s) ALERT='' STALE='' log() { echo "$(date '+%F %T') $TAG $*"; } WEBHOOK=$(grep -E '^DINGTALK_WEBHOOK=' "$ENV_FILE" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"') notify() { if [ -z "$WEBHOOK" ]; then log "未配置 DINGTALK_WEBHOOK($ENV_FILE),仅写日志" return 0 fi _p=$(mktemp) printf '{"msgtype":"markdown","markdown":{"title":"%s","text":"%s"},"at":{"isAtAll":true}}' "$1" "$2" > "$_p" _resp=$(curl -s -m 10 -w '\n%{http_code}' -X POST "$WEBHOOK" -H "Content-Type: application/json" -d @"$_p") rm -f "$_p" _code=$(echo "$_resp" | tail -1) _body=$(echo "$_resp" | sed '$d') # 钉钉业务错误同样返回 HTTP 200,必须查 body 里的 errcode if [ "$_code" = "200" ] && echo "$_body" | grep -q '"errcode":0'; then log '告警已发送' else log "告警发送失败 (HTTP $_code): $_body" fi } # 探服务实际发出的证书(127.0.0.1 + SNI,绕开 DNS 缓存) served_cert() { echo | openssl s_client -connect 127.0.0.1:443 -servername "$1" 2>/dev/null | openssl x509 -noout -fingerprint -sha256 -enddate 2>/dev/null } fp_of() { echo "$1" | grep -i 'Fingerprint=' | head -1 | cut -d= -f2 | tr -d ':' | tr 'A-F' 'a-f' } # ── 磁盘上「当前」证书的指纹集合(live/ 指向的就是当前的)── DISK_FPS=$(docker exec "$CONTAINER" sh -c 'for f in /etc/letsencrypt/live/npm-*/fullchain.pem; do openssl x509 -in "$f" -noout -fingerprint -sha256 2>/dev/null; done' 2>/dev/null | cut -d= -f2 | tr -d ':' | tr 'A-F' 'a-f') if [ -z "$DISK_FPS" ]; then log "读不到容器内的磁盘证书指纹(容器没跑?openssl 缺失?)" notify '🔧 cert-loaded 脚本异常' "## 🔧 cert-loaded 脚本异常\n\n- **主机**:$(hostname)\n- 无法从容器 \`${CONTAINER}\` 读取磁盘证书指纹\n\n请人工检查。" exit 1 fi # ── 逐个域名比对 ── for d in $DOMAINS; do s_raw=$(served_cert "$d") s_fp=$(fp_of "$s_raw") s_end=$(echo "$s_raw" | grep -i 'notAfter=' | head -1 | cut -d= -f2-) s_days='' if [ -n "$s_end" ]; then s_epoch=$(date -d "$s_end" +%s 2>/dev/null) if [ -n "$s_epoch" ]; then s_days=$(( (s_epoch - NOW) / 86400 )) fi fi if [ -z "$s_fp" ]; then log "探测失败 $d" ALERT="${ALERT}- $d:443 探测失败(服务在跑吗?)\n" continue fi if echo "$DISK_FPS" | grep -qx "$s_fp"; then log "OK $d 剩余 ${s_days:-?} 天" else log "不匹配 $d 发出的证书不是磁盘当前证书,剩余 ${s_days:-?} 天" STALE="$STALE$d " fi if [ -n "$s_days" ] && [ "$s_days" -lt "$WARN_DAYS" ]; then ALERT="${ALERT}- $d:实际发出的证书仅剩 ${s_days} 天(到期 $s_end)\n" fi done # ── 发现未装载 -> 验配置 -> reload -> 复验 ── if [ -n "$STALE" ]; then log "检测到未装载:$STALE" if docker exec "$CONTAINER" nginx -t >/dev/null 2>&1; then docker exec "$CONTAINER" nginx -s reload >/dev/null 2>&1 sleep 2 STILL='' for d in $STALE; do r_fp=$(served_cert "$d" | grep -i 'Fingerprint=' | head -1 | cut -d= -f2 | tr -d ':' | tr 'A-F' 'a-f') echo "$DISK_FPS" | grep -qx "$r_fp" || STILL="$STILL$d " done if [ -z "$STILL" ]; then log "已 reload,复验通过:$STALE" ALERT="${ALERT}已自动 reload,复验通过:$STALE\n" else log "已 reload,但复验仍不匹配:$STILL" ALERT="${ALERT}已 reload,但复验仍不匹配:$STILL\n" fi else log "nginx -t 失败,未执行 reload" ALERT="${ALERT}**nginx -t 失败,未执行 reload**:$STALE\n" fi fi # ── 无异常静默退出 ── if [ -z "$ALERT" ]; then log '全部正常' exit 0 fi notify '🔧 nginx 证书未装载' "## 🔧 nginx 证书未装载\n\n- **主机**:$(hostname)\n${ALERT}\n**根因**:NPM 续期路径不 reload nginx(见运维手册 SSL 章节)\n\n请确认是否需要跟进。" exit 0