上周有个客户急吼吼地找我,说他们的官网在安卓手机上打不开,弹出来一个红色的安全警告,提示「您的连接不是私密连接」或者「证书无效」,点「继续访问」能勉强打开,但转跳到带小锁的地址就彻底卡住。更离谱的是,客户自己在电脑上用浏览器访问同一个域名,一切正常,页面渲染完整,HTTPS 小锁也是绿的。他第一反应是「手机中毒了」「是不是安卓系统版本太老」,甚至怀疑我上次改的 Nginx 配置把站点搞坏了。
但我这边用 curl 一测,输出干干净净:SSL certificate verify ok,握手过程没有任何 warning。我又让他拿一台安卓手机开抓包,结果一包就露馅了手机端在握手阶段就收到了服务器的证书,然后立刻判定为无效,直接断开,根本没走到业务请求那一步。也就是说,证书本身没过期、没被吊销、私钥也是对的,问题出在服务器发出去的那一串证书不完整。
想一边排查一边看具体的握手报文,可以用这台服务器上的 httpcanary 抓包工具(安卓抓包调试):它能在手机上把 TLS 握手过程完整抓下来,服务端返回了几张证书、少了哪一段,一眼就能看明白,比在终端里反复敲 openssl 命令直观得多。

一、先搞清楚:证书链到底是什么
证书链这个概念新手最容易搞混。简单说,一张被 CA 签发出来的公网证书(我们叫叶子证书或者 server cert)本身并不完整。它里面只写了「我是谁」和「谁签发了我」,但验证方要往上一级确认「签发我的这个 CA 是不是可信的」,需要顺着链条一层一层往上走:
叶子证书(你的域名) → 中间证书(Intermediate CA,通常由 CA 签发给你) → 根证书(Root CA,预装在操作系统和浏览器里)。
浏览器验证的时候,是从叶子证书开始往上顺着链子走,只要中间有一环断了,整条链就断。根证书不用你发,因为每台设备的系统里都预装了一份信任列表。但中间证书必须由服务器在握手时主动发出去,这是最容易漏的一步。
而这里有个更要命的地方:中间证书往往不由你直接签发,而是打包成一个 .bundle 或者 fullchain.pem 文件给你,名字里带不带 chain 字样全看签发机构(CA)的脾气。有些 CA 给的 bundle 是完整的链,有些只给叶子证书,让你自己去下载中间证书拼。这两种我都遇到过。

二、为什么 curl 正常,手机不正常
这是排查时最容易被带偏的一点。同样是缺中间证书,为什么桌面浏览器和 curl 常常能正常访问?
原因是缓存和补链机制。主流桌面浏览器和 curl 库(比如 OpenSSL 内置的验证逻辑)都带有「AIA 补链」能力:证书里的 Authority Information Access 扩展字段里写着 CA 的下载地址,客户端发现链断了,会主动去那个地址把缺失的中间证书下载下来,临时拼进链条里,握手就成功了。
但这个能力不是所有客户端都有:
- 部分安卓 WebView、微信内置浏览器、老版本 Android 系统的 TLS 栈不实现 AIA 补链,链一断就直接失败;
- 证书里的 AIA 字段如果被某些工具在转换格式时弄丢了(比如从 PKCS#12 转成 PEM 时没保留扩展),补链这条路就断了;
- 企业内网环境如果禁了外网出站,补链请求发不出去,照样失败;
- 有些客户端虽然能补链,但缓存策略有延迟你第一次访问失败,第二次突然好了,这就是它把中间证书缓存下来了。
所以「curl 能通」根本不能证明你的证书配置没问题。它只证明你的客户端恰好会自己补。

三、三个命令把问题定位死
不用猜,按顺序敲这三个命令,基本两三分钟就能出结论。
第一步,看服务端到底发了什么。
openssl s_client -connect yourdomain.com:443 -servername yourdomain.com -showcerts
输出里 Certificate chain 下面会有 0、1、2 三段。正常情况(叶子 + 一张中间)应该有 2 段,如果你只看到 1 段,就是只发了叶子证书。段数少于 2 基本可以定案了。
第二步,看浏览器/客户端视角拿到的链。
openssl s_client -connect yourdomain.com:443 -servername yourdomain.com -verify_return_error
如果这台机器恰好带了 AIA 补链,你会看到 Verify return code: 0 (ok);把 -verify_return_error 去掉,再加 -verify 0 配合 ssl_chain_verify 的思路去看握手时实际用了几张证书。总之,只要第一段命令只吐一张证书,问题就已经确认了,别在客户端上继续折腾。
第三步,对比 CA 官方给的文件。
登录你的 CA 后台(Let's Encrypt、阿里云、腾讯云、DigiCert 都是),找到当初签发的那张证书,看它提供了几个下载文件:
- 只给
.crt一个文件的,大概率要自己拼; - 给
fullchain.pem/.bundle的,直接换过去; - 给
chain.pem单独一张的,用cat把叶子证书和它拼起来。
拼接顺序有讲究:叶子证书在最上面,中间证书在下面,顺序反了照样报错。
四、Nginx 修复:fullchain 而不是单张
确认是少发中间证书之后,修起来其实很快。Nginx 里证书路径改成拼接后的完整链文件就行。
cat leaf.crt chain.pem > fullchain.pem
然后在 server 块里:
ssl_certificate /etc/nginx/ssl/fullchain.pem;ssl_certificate_key /etc/nginx/ssl/leaf.key;
注意 key 必须还是叶子证书对应的私钥,中间证书没有私钥,别搞混。改完 nginx -t 检查语法,nginx -s reload 热加载,不用重启,连接不会断。
如果用的是 Let's Encrypt 的 certbot,其实默认行为就已经是对的certbot 会把 live/ 目录下的 fullchain.pem 给你,直接指向它即可。但很多人在手工续期或者从别的 CA 迁过来的时候,把 cert.pem(只有叶子)填进去了,这才是事故源头。另外 手动续期脚本也要一起改,不然三个月后自动续期又会把好的覆盖回错的。

五、顺手要做的几件收尾事
修完证书链本身,还有几个地方建议一起检查,不然下一个坑还在路上:
1. 检查私钥和证书是否配对。 用 openssl x509 -noout -modulus -in fullchain.pem | openssl md5 和 openssl rsa -noout -modulus -in leaf.key | openssl md5 对一下两个 md5,一致才是配对的。
2. 全站扫一遍域名。 你大概率不止一个站点用同一批证书。主站、后台、接口域名、静态资源域名,任何一个用了单张叶子证书都可能出问题。用 openssl s_client -connect ip:443 -showcerts 挨个试一遍,别只修报错的那一个。
3. 检查 OCSP stapling 有没有真的生效。 openssl s_client -status 看返回里有没有 OCSP Response Data。顺便说,很多人把 stapling 配了却因为没把中间证书传对,导致 stapling 静默失效,这两件事经常一起坏。
4. 把检查做成定时任务。 我现在的做法是每天跑一次脚本,批量对所有域名做 s_client -showcerts,只要输出的证书段数少于 2 就报警。这个检查成本极低,但能在客户投诉之前发现问题,比事后排查省事太多。
5. 顺手验证一下 TLS 版本。 老设备能连不代表配置合理,现在应该只留 TLS 1.2 和 1.3,TLS 1.0/1.1 就关掉。不过这一步和本次事故不是一回事,别混在一起改,否则出问题分不清是哪个引起的。
六、写在最后
这次事故的教训挺朴素:不要用「我这边能访问」来判断证书配置是对的。你的 curl、你的桌面浏览器、甚至你同事的 iPhone,都是带着「自动补链」能力的高级客户端,它们会替你把中间证书补上,把配置缺陷藏起来。而真正会因为这个问题挂掉的,恰恰是那些不补链的客户端安卓 App 内置浏览器、微信里的链接、老旧设备、还有各种 API 客户端。
所以正确的验收标准是:openssl s_client -showcerts 的输出里必须有 2 段以上,且是 fullchain 而不是单张叶子。做到这一点,剩下的交给浏览器和设备去谈吧。
顺带提一句,这次能这么快定位,是因为链路上有人专门看了一眼「服务器到底发了什么」这个最朴素的问题,而不是在客户端上反复重装浏览器。很多故障排查的弯路,都是因为一开始就去查最复杂的那一层了。


喜欢
顶
难过
囧
围观
无聊



