高防CDN回源502、源站自测却200:过期跨签名中间证书引发的TLS握手暗雷
零、故障一页纸
时间:2024-11-14 02:00 ~ 2024-11-14 04:30
现象:API 服务通过高防CDN对外,凌晨开始回源 502 抖动,CDN 控制台报“源站证书验证失败”。
源站:轻云互联 Hong Kong BGP 高防云服务器,Nginx 1.24 + certbot 自动续期。
反直觉点:源站本机 curl -kI 返回 200,浏览器直接打开源站 IP(改hosts) 也是 200,
但高防CDN回源就是 502。
一、先别急着改CDN配置,我踩了两个坑
接到告警后我第一反应是“回源IP白名单被DDoS清洗策略挡了”。远程上轻云互联源站,先用顺手的方式测了一下:
curl -kI https://api.example.com/health
HTTP/2 200
当时差点就让CDN厂商去查链路了。但多留了个心眼,去掉 -k 再试:
curl -I https://api.example.com/health
curl: (60) SSL certificate problem: certificate has expired
到这里才意识到:锅在源站证书链,不在CDN。
二、把证书链拆开,一行命令现原形
源站上执行 openssl 握手,并把服务端下发的证书链逐张拆成文件:
cd /tmp/ssl_debug
echo | openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts 2>/dev/null \
| awk '/BEGIN CERT/{n++; out="cert_" n ".pem"; print > out} /END CERT/{close(out)}'
ls -l cert_*.pem
然后逐张看有效期和Subject/Issuer:
for f in cert_*.pem; do
echo "=== $f ==="
openssl x509 -in "$f" -noout -subject -issuer -dates
done
输出如下(关键内容已打码):
=== cert_1.pem === # 站点证书
subject= CN = api.example.com
issuer= CN = R3
notBefore=Nov 14 01:50:00 2024 GMT
notAfter=Feb 12 01:49:59 2024 GMT # 注意,站点证书本身有效
=== cert_2.pem === # R3,由 ISRG Root X1 签发,正常
subject= CN = R3
issuer= CN = ISRG Root X1
notBefore=Sep 4 00:00:00 2020 GMT
notAfter=Sep 15 16:00:00 2025 GMT
=== cert_3.pem === # 问题在这里
subject= CN = R3
issuer= CN = DST Root CA X3
notBefore=Sep 4 00:00:00 2020 GMT
notAfter=Sep 30 14:01:15 2021 GMT # 过期已超过两年
cert_2.pem 是 Let’s Encrypt 默认的 R3 中间证书(ISRG Root X1 签发),cert_3.pem 是历史遗留的 “R3 cross-sign” 证书,由老的 DST Root CA X3 交叉签发。certbot 在 2024 年续期时没有把这份过期残留从 fullchain 里清掉,Nginx reload 后继续把它随站点证书一起下发。
三、为什么浏览器/curl -k正常,高防CDN却直接502?
curl -k 能通:它完全不校验证书链,自然不报错。
浏览器能通:浏览器信任 ISRG Root X1,收到链后可以“忽略”那份已过期的 DST Root CA X3 交叉签发证书,直接走 cert_2 → ISRG Root X1 构建出有效信任路径。
高防CDN 回源失败:边缘节点回源时对上游证书做严格链路校验(等同于 openssl verify 逐级校验证书有效期),链上任何一张证书过期都会直接掐断 TLS 握手,于是表现为 502/521。
# 用 openssl verify 同样能复现CDN的报错
openssl verify -CApath /etc/ssl/certs -untrusted cert_2.pem -untrusted cert_3.pem cert_1.pem
cert_1.pem: C = US, O = Let's Encrypt, CN = R3
error 10 at depth 1: certificate has expired
四、修复:重建 fullchain,而不是手动删证书
网上很多教程让你直接把 cert_3.pem 删掉,Nginx 只加载 cert_1 + cert_2。这个应急可以,但 fullchain 是 certbot 自动管理的,下次 renew 会被重新覆盖回旧状态。正确做法是用 ACME 客户端重新拉取一份全新的证书链:
# 使用 acme.sh 强制续期并重新安装 fullchain
acme.sh --renew -d api.example.com --force \
--fullchain-file /etc/nginx/ssl/fullchain.pem \
--key-file /etc/nginx/ssl/key.pem \
--reloadcmd "nginx -s reload"
如果不想换证书,也可以用 openssl 手动从当前站点证书重新拼链:
# 获取当前 R3 中间证书(ISRG Root X1 签发的,未过期)
echo | openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts 2>/dev/null \
| awk '/BEGIN CERT/{n++; out="chain_" n ".pem"; print > out} /END CERT/{close(out)}'
# 只保留站点证书 + cert_2.pem,丢弃过期的 cross-sign 证书
cat chain_1.pem chain_2.pem > /etc/nginx/ssl/fullchain.pem
nginx -t && nginx -s reload
修复后,先用本地 curl 不带 -k 验证源站,再切CDN回源,502 立即消失。
五、顺手补了一个“证书链体检”脚本
别只盯着站点证书的到期时间。中间证书/cross-sign 证书过期才是这类“源站200、CDN 502”的隐形杀手。下面这个脚本可以直接扔进 crontab,每天巡检一次:
#!/bin/bash
# ssl_chain_health.sh
HOST="api.example.com"
PORT="443"
echo | openssl s_client -connect ${HOST}:${PORT} \
-servername ${HOST} -showcerts 2>/dev/null \
| awk '/BEGIN CERT/{n++; out="/tmp/cert_" n ".pem"; print > out} /END CERT/{close(out)}'
for f in /tmp/cert_*.pem; do
openssl x509 -in "$f" -noout -dates -issuer | tr '\n' ' '
echo ""
done
# 清理
rm -f /tmp/cert_*.pem
输出里一旦出现 notAfter=...GMT 早于当前时间,立刻处理。尤其注意含 DST Root CA X3 或 cross-sign 字样的证书。这次的教训让我养成了一个习惯:任何证书变更后,都不再只跑 curl -k,必须用 openssl s_client -verify_return_error 做一次完整验证。
六、总结一句话
高防CDN 回源 502,源站自测却 200——先拆证书链,看中间证书和 cross-sign 证书的有效期,别被 curl -k 的“假 200”带偏。这也是我第一次在轻云互联节点上遇到这种跨签名残留问题,处理完顺手给他们的控制台提了个建议:证书到期监控最好能覆盖整条链,而不是只盯主证书。