HTTPS 打不开时,从头把证书链查一遍
第一次给站点挂证书的时候,我遇到的现象很怪:浏览器说证书无效,但 curl 没报错,
用手机打开又正常。折腾了一下午才明白——不是证书签错了,是服务器只发了叶子证书,没发中间证书。
有些客户端会自己去补,有些不会。
这篇按「链 → 名字 → 时间 → 协议」四层来写,基本上 HTTPS 的报错都能落到其中一层。
一、证书链到底有几张证书
你从 CA 拿到的东西通常是三样(有的打包成一个 fullchain.pem):
- 叶子证书(leaf):签给你域名的这张,Subject 是
CN = 你的域名。 - 中间证书(intermediate):签发叶子证书的那张,由根证书签发。
- 根证书(root):自签名的最高一层,不随服务器下发,预装在操作系统和浏览器的信任库里。
握手时服务器必须把叶子 + 中间一起发过去,客户端再用本地信任库把链补全到根。
只发叶子,客户端手上又没有那个中间证书,校验就断在半路——这就是最常见的
NET::ERR_CERT_AUTHORITY_INVALID。
判断方法:
openssl s_client -connect 域名:443 -servername 域名 -showcerts的输出里,如果只有一段BEGIN CERTIFICATE,而证书的 Issuer 又不是某个根 CA 的名字,基本可以确定缺中间证书。
二、报错对号入座
| 现象 | 通常是 |
|---|---|
| AUTHORITY_INVALID / unable to get local issuer | 缺中间证书,或客户端信任库里没有对应根 |
| COMMON_NAME_INVALID / hostname mismatch | 证书的 SAN 里没有你访问的那个域名 |
| DATE_INVALID / certificate has expired | 证书过期,或本机系统时间不对 |
| 握手直接断开、没有证书报错 | 协议版本或加密套件不匹配,不是证书问题 |
三、名字这一关:CN 已经不作数了
老证书看 CN,现在的浏览器只认 subjectAltName(SAN)扩展里的域名列表。
也就是说,一张只写了 CN = example.com 而没有 SAN 的证书,
在现代浏览器里是无效的——这是很多人用旧脚本自签证书时踩的坑。
# 看 SAN 列表
openssl x509 -in cert.pem -noout -ext subjectAltName
# 输出形如:
# X509v3 Subject Alternative Name:
# DNS:example.com, DNS:www.example.com
另外注意通配符只覆盖一级:*.example.com 匹配 a.example.com,
但不匹配 a.b.example.com,也不匹配裸域 example.com。
所以正规做法是签一张同时包含裸域和通配符的证书。
四、时间这一关
# 有效期、签发者、使用者
openssl x509 -in cert.pem -noout -dates -subject -issuer
# 直接看线上服务实际在用的那张
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -dates -subject -issuer
如果证书没过期但浏览器说过期,先看客户端时间。
服务器上跑着定时任务的机器时钟漂移是很常见的事,尤其虚拟机休眠恢复之后。
校时用 timedatectl(systemd 系统)看 System clock synchronized 是否为 yes。
五、把验证过程一次跑完
排查时我习惯直接用 openssl s_client 看完整输出,比浏览器的一句话报错信息量大得多:
openssl s_client -connect example.com:443 \
-servername example.com \
-CAfile /etc/ssl/certs/ca-certificates.crt \
-verify_return_error < /dev/null
-servername必须带。一个 IP 上挂多个站点时,不带 SNI 会拿到默认站点的证书,看到的就不是你要查的那张。- 末尾
< /dev/null让命令读完就退出,不然它会停在交互式输入等你说 QUIT。 - 输出里
Verify return code: 0 (ok)才算链完整;21 (unable to verify the first certificate)就是缺中间证书。
六、协议版本:1.3 比 1.2 少一个来回
TLS 1.2 的完整握手要 2 个 RTT 才能开始传数据,TLS 1.3 压到 1 个 RTT (会话复用时甚至可以 0-RTT)。对高延迟线路来说这一步的差别比想象中大: 跨境访问时一个 RTT 常常就是 150 毫秒起。
# 看协商结果和实际用的协议版本
openssl s_client -connect example.com:443 -servername example.com < /dev/null \
| grep -E "Protocol|Cipher"
nginx 里只开 1.2 和 1.3 就够了,1.0/1.1 早已被主流浏览器弃用:
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off; # 1.3 时代交给客户端选更合适
七、续期与两个容易忘的开关
Let's Encrypt 的证书有效期是 90 天,官方建议在剩余 30 天内续。 certbot 装好后系统会带一个定时器自动跑,但自动续期失败往往是静默的,所以要定期看一眼:
# 干跑一次,验证续期流程能不能走通(不会真的签发)
certbot renew --dry-run
# 看系统的续期定时器
systemctl list-timers | grep -i certbot
两个和证书配套、但经常被漏掉的配置:
-
OCSP stapling:由服务器代为获取证书吊销状态并随握手一起发给客户端,
省掉客户端自己去查的那一次请求。nginx 里是
ssl_stapling on;, 同时要配ssl_trusted_certificate指向完整链,否则不生效。 -
HSTS:
Strict-Transport-Security: max-age=31536000告诉浏览器以后只用 HTTPS 访问。 上线前把max-age设小一点试,确认全站(含子域)都能走 HTTPS 再加长—— 这个头一旦下发,浏览器会记住,回退很麻烦。
一句话总结:先分清是「链不完整」「名字不对」还是「时间不对」,三种原因三条查法,别一上来就重签。