check-cert-loaded.sh 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. #!/bin/sh
  2. # ============================================================
  3. # nginx 证书装载自愈 —— 47.94.88.244(payment-platform)
  4. #
  5. # 【为什么需要它】
  6. # NPM(jc21/nginx-proxy-manager)的定时续期路径
  7. # processExpiringHosts -> renew -> renewLetsEncryptSsl*
  8. # 只跑 certbot,从头到尾没有 reload nginx。
  9. # 证书文件和 NPM 数据库都更新了,nginx 内存里还是旧证书,
  10. # 直到下一次改配置(动 NPM 界面)才顺带重载。
  11. #
  12. # 2026-08-25 三张证书静默续期成功,nginx 一直发 06-25 那张,
  13. # 09-23 过期,三个域名 HTTPS 中断三天才被发现。
  14. # 下一次静默续期约 10/23,不处理则 11/22 重演。
  15. #
  16. # 【做什么】
  17. # 比对「nginx 实际发出的证书」与「磁盘当前证书」:
  18. # 不一致 -> nginx -t 通过后 reload,再复验一次
  19. # 一致 -> 只在日志里记一行,不动 nginx
  20. # 另有兜底:实际发出的证书剩余不足 WARN_DAYS 天,无论比对结果都告警。
  21. #
  22. # 【部署】
  23. # scp devops/check-cert-loaded.sh root@47.94.88.244:/root/payments/deploy/
  24. # chmod +x /root/payments/deploy/check-cert-loaded.sh
  25. # /root/payments/deploy/check-cert-loaded.sh # 手工跑一次,应三个域名全 OK
  26. # crontab -e 加一行(输出由脚本自己写日志,cron 行保持短,防粘贴折行):
  27. # 17 4 * * * /root/payments/deploy/check-cert-loaded.sh
  28. #
  29. # 钉钉 webhook 从 $ENV_FILE 的 DINGTALK_WEBHOOK= 读取,未配置则只写日志。
  30. # 注意:钉钉机器人配了 IP 白名单,先确认本机公网出口 IP 已加入,
  31. # 否则返回 {"errcode":310000,...}(见运维手册告警章节)。
  32. # ============================================================
  33. PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
  34. export PATH
  35. # cron 下输出落到日志;手工执行时(stdout 是终端)直接打屏,方便验证
  36. LOG_FILE=/var/log/cert-loaded.log
  37. if [ ! -t 1 ]; then
  38. exec >> "$LOG_FILE" 2>&1
  39. fi
  40. CONTAINER=nginx-proxy-manager
  41. DOMAINS="qcsj88888.com api.qcsj88888.com doc.qcsj88888.com"
  42. # 兜底阈值。NPM 在到期前 30 天续期,正常运行时实际发出的证书
  43. # 不会低于约 30 天;低于此值说明续期或重载至少有一环没走通。
  44. WARN_DAYS=15
  45. ENV_FILE=/root/payments/deploy/.env
  46. TAG='[cert-loaded]'
  47. NOW=$(date +%s)
  48. ALERT=''
  49. STALE=''
  50. log() { echo "$(date '+%F %T') $TAG $*"; }
  51. WEBHOOK=$(grep -E '^DINGTALK_WEBHOOK=' "$ENV_FILE" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"')
  52. notify() {
  53. if [ -z "$WEBHOOK" ]; then
  54. log "未配置 DINGTALK_WEBHOOK($ENV_FILE),仅写日志"
  55. return 0
  56. fi
  57. _p=$(mktemp)
  58. printf '{"msgtype":"markdown","markdown":{"title":"%s","text":"%s"},"at":{"isAtAll":true}}' "$1" "$2" > "$_p"
  59. _resp=$(curl -s -m 10 -w '\n%{http_code}' -X POST "$WEBHOOK" -H "Content-Type: application/json" -d @"$_p")
  60. rm -f "$_p"
  61. _code=$(echo "$_resp" | tail -1)
  62. _body=$(echo "$_resp" | sed '$d')
  63. # 钉钉业务错误同样返回 HTTP 200,必须查 body 里的 errcode
  64. if [ "$_code" = "200" ] && echo "$_body" | grep -q '"errcode":0'; then
  65. log '告警已发送'
  66. else
  67. log "告警发送失败 (HTTP $_code): $_body"
  68. fi
  69. }
  70. # 探服务实际发出的证书(127.0.0.1 + SNI,绕开 DNS 缓存)
  71. served_cert() {
  72. echo | openssl s_client -connect 127.0.0.1:443 -servername "$1" 2>/dev/null | openssl x509 -noout -fingerprint -sha256 -enddate 2>/dev/null
  73. }
  74. fp_of() {
  75. echo "$1" | grep -i 'Fingerprint=' | head -1 | cut -d= -f2 | tr -d ':' | tr 'A-F' 'a-f'
  76. }
  77. # ── 磁盘上「当前」证书的指纹集合(live/ 指向的就是当前的)──
  78. 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')
  79. if [ -z "$DISK_FPS" ]; then
  80. log "读不到容器内的磁盘证书指纹(容器没跑?openssl 缺失?)"
  81. notify '🔧 cert-loaded 脚本异常' "## 🔧 cert-loaded 脚本异常\n\n- **主机**:$(hostname)\n- 无法从容器 \`${CONTAINER}\` 读取磁盘证书指纹\n\n请人工检查。"
  82. exit 1
  83. fi
  84. # ── 逐个域名比对 ──
  85. for d in $DOMAINS; do
  86. s_raw=$(served_cert "$d")
  87. s_fp=$(fp_of "$s_raw")
  88. s_end=$(echo "$s_raw" | grep -i 'notAfter=' | head -1 | cut -d= -f2-)
  89. s_days=''
  90. if [ -n "$s_end" ]; then
  91. s_epoch=$(date -d "$s_end" +%s 2>/dev/null)
  92. if [ -n "$s_epoch" ]; then
  93. s_days=$(( (s_epoch - NOW) / 86400 ))
  94. fi
  95. fi
  96. if [ -z "$s_fp" ]; then
  97. log "探测失败 $d"
  98. ALERT="${ALERT}- $d:443 探测失败(服务在跑吗?)\n"
  99. continue
  100. fi
  101. if echo "$DISK_FPS" | grep -qx "$s_fp"; then
  102. log "OK $d 剩余 ${s_days:-?} 天"
  103. else
  104. log "不匹配 $d 发出的证书不是磁盘当前证书,剩余 ${s_days:-?} 天"
  105. STALE="$STALE$d "
  106. fi
  107. if [ -n "$s_days" ] && [ "$s_days" -lt "$WARN_DAYS" ]; then
  108. ALERT="${ALERT}- $d:实际发出的证书仅剩 ${s_days} 天(到期 $s_end)\n"
  109. fi
  110. done
  111. # ── 发现未装载 -> 验配置 -> reload -> 复验 ──
  112. if [ -n "$STALE" ]; then
  113. log "检测到未装载:$STALE"
  114. if docker exec "$CONTAINER" nginx -t >/dev/null 2>&1; then
  115. docker exec "$CONTAINER" nginx -s reload >/dev/null 2>&1
  116. sleep 2
  117. STILL=''
  118. for d in $STALE; do
  119. r_fp=$(served_cert "$d" | grep -i 'Fingerprint=' | head -1 | cut -d= -f2 | tr -d ':' | tr 'A-F' 'a-f')
  120. echo "$DISK_FPS" | grep -qx "$r_fp" || STILL="$STILL$d "
  121. done
  122. if [ -z "$STILL" ]; then
  123. log "已 reload,复验通过:$STALE"
  124. ALERT="${ALERT}已自动 reload,复验通过:$STALE\n"
  125. else
  126. log "已 reload,但复验仍不匹配:$STILL"
  127. ALERT="${ALERT}已 reload,但复验仍不匹配:$STILL\n"
  128. fi
  129. else
  130. log "nginx -t 失败,未执行 reload"
  131. ALERT="${ALERT}**nginx -t 失败,未执行 reload**:$STALE\n"
  132. fi
  133. fi
  134. # ── 无异常静默退出 ──
  135. if [ -z "$ALERT" ]; then
  136. log '全部正常'
  137. exit 0
  138. fi
  139. notify '🔧 nginx 证书未装载' "## 🔧 nginx 证书未装载\n\n- **主机**:$(hostname)\n${ALERT}\n**根因**:NPM 续期路径不 reload nginx(见运维手册 SSL 章节)\n\n请确认是否需要跟进。"
  140. exit 0