VPS 上装 SSL:本地 openssl 全绿、安卓报 ERR_CERT_AUTHORITY_INVALID,三个静默坑逐个拆

先对齐一下现象

VPS 上折腾 SSL 证书,真正卡住人的极少是“怎么申请”,几乎都是申请完之后——Chrome、Safari、`curl` 看着都正常,偏偏用户群里有人反馈手机端打不开;或者监控三天两头告警说证书快过期,但你登上去看,明明刚续期过。

下面这三处是我在客户机器上反复遇到、而且每次都得花半小时才能定位的静默坑,按踩坑频率排。

坑一:`ssl_certificate` 指向 cert.pem,Chrome 能用纯属巧合

certbot 落在 `/etc/letsencrypt/live/example.com/` 下的四个文件,全是软链:

ls -l /etc/letsencrypt/live/example.com/
# cert.pem        -> ../../archive/example.com/cert1.pem       # 只有你域名的叶子证书
# chain.pem       -> ../../archive/example.com/chain1.pem      # 只有中间证书
# fullchain.pem   -> ../../archive/example.com/fullchain1.pem  # 叶子 + 中间
# privkey.pem     -> ../../archive/example.com/privkey1.pem

不少面板和教程里写的是 `cert.pem`,理由是“Chrome 打开绿锁没问题”。这个判断在桌面端勉强能糊弄过去,原因是桌面 Chrome 内置了一个 AIA fetching 机制:当服务端没发中间证书时,它会根据叶子证书 Authority Information Access 扩展里的 URL,主动去 CA 服务器把中间证书拉下来补齐。

但这条路只在桌面浏览器上行得通。Android 的证书验证栈(WebView、`java.net.Socket`、OkHttp 默认 TrustManager)不做 AIA 补全,拿不到中间证书就直接抛 `SSLHandshakeException: Trust anchor for certification path not found`,用户的感受就是“这网站证书不受信任”。Python 的 `requests` 具体行为则取决于 `certifi` 版本里有没有那张中间证书,时好时坏,更难查。

正确写法只有一种:

# /etc/nginx/conf.d/example.conf
server {
    listen 443 ssl;
    server_name example.com;

    # 这里必须是 fullchain,不是 cert
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
}

还有一个更隐蔽的:别在配置里写 archive 目录里的具体文件名(像 `cert1.pem`)。certbot 每次续期会生成 `cert2.pem`、`cert3.pem`,同时更新 `live/` 下的软链,但 archive 下的具体文件名不会变回来。如果你直接绑 archive 里的文件,续期后 Nginx 仍然在读最早那张,日志干净、`certbot renew` 退出码 0,就是证书没换。这个坑排查起来最耗时间,因为文件 mtime 也在变,从表面上完全看不出问题。

坑二:`openssl s_client` 验证失败照样连上,这是最大的一块假绿灯

部署完测一下,大部分人这么干:

openssl s_client -connect example.com:443 -servername example.com

输出里有 `CONNECTED(00000003)`、有证书详情、最后一行 `Verify return code: 21 (unable to verify the first certificate)`。很多人看到 CONNECTED 和证书信息就认为没事了——`s_client` 默认根本不会因为验证失败而断连,它只是把结果打印出来,连接照常建立。要模拟严格客户端,必须加 `-verify_return_error`:

openssl s_client -connect example.com:443 \
  -servername example.com \
  -verify_return_error </dev/null

加上之后验证不通过会直接报错退出,这才对得上浏览器和 Java 客户端的行为。

更稳妥的办法是拿一个只含根证书的 CA bundle 去握手,模拟那些“手里只有根证书、也不会去网上补中间证书”的客户端。curl 官网那份 `cacert.pem` 正好合适(从 Mozilla certdata 提取,只有根证书):

curl -s https://curl.se/ca/cacert.pem -o /tmp/roots-only.pem

curl -sv --cacert /tmp/roots-only.pem https://example.com/ -o /dev/null 2>&1 | \
  grep -E "SSL certificate verify|unable to get local issuer"

# 只发了叶子证书时会看到:
# curl: (60) SSL certificate problem: unable to get local issuer certificate

# 发了完整 fullchain 时才会是:
# SSL certificate verify ok.

这个测试比 `openssl s_client` 更贴近真实移动端行为,加进部署 checklist 里,能挡掉大量“用户说打不开、我这里却好好的”的扯皮。

顺带一句,如果你的 VPS 是 NAT 共享 IP,或者上行对 80/443 做了特殊处理,certbot 的 HTTP-01 验证可能直接过不去,只能退到 DNS-01 手动配 API token。轻云互联的实例默认给独立公网 IPv4、不在上游额外拦 80/443,HTTP-01 可以直接过,省去了配 DNS 服务商 token 这一圈事,调试周期能缩短不少。

坑三:certbot 说续期成功,Nginx 发的还是旧证书

这个坑分三层,一层比一层深。

第一层:根本没有 reload

certbot 的 systemd timer 只负责续期,默认不 reload 任何服务。正确姿势是在 `/etc/letsencrypt/renewal/example.com.conf` 里写 hook:

[renewalparams]
account = xxxxxxxx
authenticator = webroot
webroot_path = /var/www/html,
server = https://acme-v02.api.letsencrypt.org/directory
key_type = ecdsa
renew_hook = systemctl reload nginx

或者命令行一次性指定:

certbot renew --deploy-hook "systemctl reload nginx"

`renew_hook` 和 `deploy_hook` 别同时配,会触发两次 reload,虽不致命但没必要。

第二层:reload 成功了,但旧 worker 还在处理连接

Nginx 收到 `HUP` 后会读新配置、起新 worker、给旧 worker 发 graceful shutdown。旧 worker 在处理完手上的连接之前不会退出,最长能拖到 `worker_shutdown_timeout`。这段时间里连进来的老客户端拿到的还是旧证书。

确认真的换了,别只看 `systemctl status nginx`:

# worker 的启动时间应该晚于证书文件的 mtime
ps -eo pid,lstart,cmd | grep "nginx: worker"
ps -eo pid,lstart,cmd | grep "nginx: master"

# 对照看一眼
stat -c '%y %n' /etc/letsencrypt/live/example.com/fullchain.pem

如果 worker 启动时间早于 fullchain 的 mtime,说明 reload 没生效。补一句:`nginx -t` 只验配置语法,不验证书内容。如果新证书路径写错、或者私钥权限不对读不到,`nginx -s reload` 会静默失败——master 保持旧配置继续跑,只有 error.log 里留一行 `SSL_CTX_use_PrivateKey_file() failed`。所以 hook 里更稳的写法:

certbot renew --deploy-hook "nginx -t && systemctl reload nginx"

让校验失败至少留下非零退出码,能被 systemd timer 或 cron 通知捕捉到。

第三层:`ssl_session_tickets` 让客户端“看起来”还在用旧证书

Nginx 默认开 `ssl_session_tickets on`,TLS 1.2 下 client 可以缓存 session ticket 很久。证书换了,但客户端手里还有一张有效的 ticket,恢复会话时不走完整握手、也就不会重新验证证书——浏览器显示的仍是上一次握手时的证书信息,不强制刷新看不到新的。

这个在运营商或企业内网环境尤其明显,因为中间可能有 TLS 中间盒缓存 session。验证方式:

# 每次都强制完整握手,不复用 session
openssl s_client -connect example.com:443 -servername example.com \
  -no_ticket -no_reuse </dev/null 2>&1 | \
  openssl x509 -noout -dates -subject

看到 `notAfter` 是新的日期才算真的换了。根上的做法是换证书后轮转 ticket key——Nginx 1.23.2 起 `ssl_session_ticket_key` 支持配多个文件平滑轮转,但老实说配起来麻烦。绝大多数站点直接用 `ssl_session_tickets off` + `ssl_session_cache shared:SSL:10m` 就够,会话复用靠 cache 走,代价是多占几 MB 内存,换证书时行为完全可预期。

补充:两个新手一踩就翻不了身的坑

HSTS 头加早了

`add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;` 这一行在测试环境加上去之后,只要测试域名和生产主域共用一个父域,`includeSubDomains` 会把你所有子域一起锁在 HTTPS 里。等你想回滚、或者证书出故障,浏览器直接硬报错,连“继续访问(不安全)”的按钮都不给你。

稳妥的流程是先 `max-age=300` 跑一周,确认没漏的子域,再扩到 86400,最后才上 31536000。`preload` 这个参数尽量别加——一旦进了浏览器的 preload list,要摘出来得等好几个月。

`--force-renewal` 打爆 Let's Encrypt 速率限制

调证书没生效的时候,下意识就是 `certbot renew --force-renewal`,跑一次签一张新的,跑五次就被 Let's Encrypt 按在地上。官方限额:每个注册域名每周 50 张、同一域名集合每周 5 张重复证书。超了报 `too many certificates already issued for exact set of domains`,冷却一周。

更致命的是把这个写进 cron。见过不少人抄了一份“每天强制续期”的脚本,几天后就撞墙了。正常的 `certbot renew` 会在剩余有效期 30 天以内才真正签发,不需要手动 force。

一个可以挂到监控上的巡检脚本

上面这些手工查一轮半小时,写成脚本挂 cron 每天跑一遍,出问题先发邮件再说:

#!/bin/bash
# /usr/local/bin/ssl-check.sh
set -uo pipefail

DOMAIN="${1:?usage: ssl-check.sh example.com [port]}"
PORT="${2:-443}"
CERT_FILE="/etc/letsencrypt/live/${DOMAIN}/fullchain.pem"
DAYS_WARN=20

# 1. 证书剩余天数
if [ -f "$CERT_FILE" ]; then
    END=$(openssl x509 -in "$CERT_FILE" -noout -enddate | cut -d= -f2)
    LEFT=$(( ($(date -d "$END" +%s) - $(date +%s)) / 86400 ))
    echo "[cert] ${DOMAIN} expires in ${LEFT} days"
    if [ "$LEFT" -lt "$DAYS_WARN" ]; then
        echo "[WARN] cert renewal is stale"
    fi
fi

# 2. 服务端实际返回了几张证书
COUNT=$(openssl s_client -connect "${DOMAIN}:${PORT}" -servername "$DOMAIN" \
        -showcerts </dev/null 2>/dev/null | grep -c 'BEGIN CERTIFICATE' || true)
echo "[chain] server sent ${COUNT} certificate(s)"
if [ "$COUNT" -lt 2 ]; then
    echo "[WARN] intermediate cert missing"
fi

# 3. 模拟只信任根证书的客户端
curl -s --cacert /tmp/roots-only.pem "https://${DOMAIN}/" -o /dev/null \
  || echo "[ERROR] strict verification failed"

# 4. 时间一致性:notBefore 不能在将来
START=$(openssl x509 -in "$CERT_FILE" -noout -startdate | cut -d= -f2)
echo "[time] cert valid from ${START}, now $(date -u +%Y-%m-%dT%H:%M:%SZ)"

第 4 项专门提一下:VPS 从快照恢复或者被宿主热迁移之后系统时钟会漂移,`notBefore` 落在未来的证书能让所有客户端拒连,而 `date` 命令看着又正常(差个几十秒肉眼根本看不出)。直接比 `notBefore` 和 `date -u` 才靠得住。国内 VPS 平时用 `chronyc tracking` 盯一眼 offset,长期漂移到几百毫秒就该查宿主层了。轻云互联的实例在宿主层有 chrony 兜底,正常不会撞这个,但从别家快照迁过来的镜像还是建议自己再核一遍系统时钟源。

最后

SSL 证书这块的技术密度其实不高,麻烦之处全在于“失败是静默的”——没有报错、服务照跑、日志里最多一行 error、大部分客户端还因为各种缓存和补全机制看起来一切正常。真正把问题暴露出来的,永远是群里那个用着三年前安卓机、什么都没额外装的用户。

所以最有效的调试思路不是猜,是层层剥:先用 `openssl s_client -showcerts` 看服务端到底发了几张证书,再用只含根证书的 bundle 模拟最严格的客户端,最后确认 Nginx 进程真的 reload 过。三步下来,九成的“证书问题”都不用翻文档。