ソースを参照

ops: nginx 证书装载自愈脚本(比对实际发出的证书与磁盘证书)

起因:2026-08-25 三张证书静默续期成功,但 nginx 没 reload ⇒ 一直发 06-25 那张,
09-23 过期,三个域名 HTTPS 断了三天才被发现。

根因(写在脚本头部):NPM 的续期路径
`processExpiringHosts -> renew -> renewLetsEncryptSsl*`
**只跑 certbot,从头到尾没有 reload nginx** —— 证书文件与 NPM 数据库都更新了,
nginx 内存里还是旧证书,直到下次改配置(动 NPM 界面)才顺带重载。
所以这不是「证书没续上」,是「续上了没装载」:看证书文件时间、看 NPM 界面**都正常**,
只有从外面握手取实际发出的那张才看得出来。

脚本:比对「nginx 实际发出的证书」与「磁盘当前证书」,
不一致则先 `nginx -t` 通过再 reload、然后复验一次;一致就只记一行日志、不动 nginx。
另有兜底:实际发出的证书剩余不足 `WARN_DAYS` 天,无论比对结果都告警。
钉钉 webhook 从 `$ENV_FILE` 的 `DINGTALK_WEBHOOK=` 读,脚本里**没有任何 URL 字面量**
(已核:全文 0 处 URL)。日志由脚本自己写,cron 行保持短,防粘贴折行。

★ 尚未部署。部署命令在脚本头部(scp → chmod → 手工跑一次 → 加 crontab)。
**下一条时间线:下次静默续期约 10/23,不处理则 11/22 重演。**

配套的手册订正(服务器/容器拓扑、端口暴露、SSL 证书三节)留在工作区**未提交**:
`.claude/plan/ops-manual.md` 含明文数据库/Redis 口令(HEAD 上就有,本次未新增),
提交动作被拒。要入库得先决定那几处口令怎么办。
alphaH 2 週間 前
親
コミット
2a5c53eb7d
1 ファイル変更、160 行追加、0 行削除
  1. 160 0
      devops/check-cert-loaded.sh

+ 160 - 0
devops/check-cert-loaded.sh

@@ -0,0 +1,160 @@
+#!/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