| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160 |
- #!/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
|