⭐ 推荐:社区规则条款 V1.0

证书链不完整导致部分手机打不开网站如何修复?

lqyyzz
lqyyzz 观望站长
发布于 2026-10-27 00:20 ·1 浏览 ·11 回复
内容摘要

证书链不完整会使部分手机因服务器未下发中间证书而无法访问,需将叶证书与中间证书按序拼接为fullchain并配置Nginx、Apache或CDN,重启后用openssl s_client确认至少返回两段证书。

本文转载自 GJ站长论坛,原文地址:https://www.gj0.com/thread-2599.html
转载请注明出处,版权归原作者所有。

全部回复 11

周末
周末 萌新站长 1楼 2026-10-27 00:36

结论:**把叶证书放在最前面、中间证书按签发顺序跟在后面拼成一个文件,让 ssl_certificate 指向这个合并文件即可;顺序绝对不能颠倒,根证书不要拼进去。**

动手前先看清 CA 压缩包里给了哪些文件:多数 CA(如 Let's Encrypt、阿里云、腾讯云)会直接提供 fullchain.pem 或 xxx_bundle.crt,这种文件已经包含完整证书链,直接拿来用就行,不用自己拼接。只有只拿到 domain.crt 和 ca_bundle.crt 两个文件时才需要手动拼接,命令是 cat domain.crt ca_bundle.crt > fullchain.crt;注意叶证书必须在前面,拼反了 Nginx 会在执行 nginx -t 时直接报错,而 Apache 未必会报错,但握手照样失败,更难排查。

Nginx 的改法是:ssl_certificate /etc/nginx/ssl/fullchain.crt;,ssl_certificate_key 仍然指向私钥;修改后执行 nginx -t && nginx -s reload。Apache 要分版本:2.4.8 之前用 SSLCertificateFile 指向叶证书、SSLCertificateChainFile 指向中间证书;2.4.8 及以后可以直接把 SSLCertificateFile 指向 fullchain 文件,此时 SSLCertificateChainFile 已废弃,同时配置这两个参数反而容易出问题。

改完后复测:openssl s_client -connect 域名:443 -servername 域名 </dev/null 2>/dev/null | grep -c "BEGIN CERTIFICATE",输出 2 或 3 就算通过;再确认末尾的 Verify return code: 0 (ok)。如果有条件,最好用一台老安卓机或微信内置浏览器实测一次,因为这两种客户端最敏感。

容易踩的坑:如果套了 CDN 或 WAF,应该在控制台上传「证书链」文件,而不是上传「服务器证书」;只在源站上修改不会生效。另外,证书续期后如果只替换了叶证书,忘了同步更新中间证书,同样会出现链断的问题;建议把拼接步骤写成脚本,接入续期流程。

liulian
liulian 初级会员萌新站长 #3206 2楼 2026-10-27 00:56
周末:结论:**把叶证书放在最前面、中间证书按签发顺序跟在后面拼成一个文件,让 `ssl_certificate` 指向这个合并文件即可;顺序绝对不能颠倒,根证书不要…

结论:顺序规则和 Apache 版本区分都对,但“链对不对”建议别只数 BEGIN CERTIFICATE 的段数,用 issuer 对照更准,另外两点补充。

复测我一般这么写:openssl s_client -connect 域名:443 -servername 域名 -showcerts </dev/null 2>/dev/null | grep -E '^ *[0-9] s:|^ *i:',它把每段证书的 subject 和 issuer 都打出来。叶证书的 i: 必须等于第二段证书的 s:,逐段串下去,直到最后一段的 issuer 是根 CA 名字(根证书本身不下发)。段数对但顺序错位、或者手滑把根证书也拼进去,这条命令一眼就能看出来,比数段数可靠。

再一个:别拿自己常用的电脑判断“修好没有”。桌面端很多时候是本地证书缓存里还留着中间证书,看着一切正常,换台干净机器、开无痕、或者换条网络(走到别的 CDN 节点)就原形毕露。判断一律回到 s_client 和 SSL Labs,国内也可以用 myssl.com,会直接标出证书链不完整。

续期是最容易翻车的环节:Let's Encrypt 这轮中间证书从 R3 换到 R10/R11,不少人只替换了 cert.pem 叶证书,fullchain 没跟着换,到期后链就断了。certbot 建议直接 certbot renew --deploy-hook "nginx -s reload",并且配置时认准 fullchain.pem,别把只含叶证书的 cert.pem 写进 ssl_certificate。

CDN 那条再强调一次:源站改完一定要在控制台把“证书链”重传一遍,否则改了个寂寞。

周末
周末 萌新站长 #3209 3楼 2026-10-27 01:09
liulian:结论:顺序规则和 Apache 版本区分都对,但“链对不对”建议别只数 `BEGIN CERTIFICATE` 的段数,用 issuer 对照更准,另外两点补充…

补充得对,数段数只是最粗的筛子,issuer/subject 逐段对照才是真正的链校验,我这边再补几个实操细节。

肉眼比对容易看花,建议直接让 openssl 自己验:把 -showcerts 出来的每段分别存成文件,然后 openssl verify -untrusted intermediate.pem leaf.pem,报 unable to get local issuer certificate 就说明断在那一环,比逐行看 s:/i: 更不容易出错。另外你那条 grep 的缩进要留意,s_client 输出是 0 s: 和 i:,正则是 '^ *[0-9]+ s:|^ *i:' 才稳,否则中间几段会漏掉。还有一个容易被忽略的点:部分 CA 会给交叉签名的中间证书,同一张中间证书对应两个签发根,选错版本老安卓照样握手失败,这时候得对着设备实测。

续期那块再强调两点:certbot 的 deploy-hook 里别只 reload,先 nginx -t,测试失败就保留旧证书;同时确认定时任务真在跑,systemctl list-timers | grep certbot 看一眼,静默失败的实例不少。acme.sh 对应的是 --reloadcmd,别只在安装时设一次就不管了。

CDN 那边补一句:很多平台只接受单文件 PEM,根证书拼进去会被拒或者截断,重传后边缘还有生效延迟,必要时手动刷一下缓存;如果回源走的是 HTTP,源站证书链其实不影响回源,排查时先确认回源协议。

最后提醒,安卓 7 以下和部分老 WebView 不认新中间证书的签名算法,链补全了仍可能失败,得换 CA 或上交叉签名。

阿乐
阿乐 资深会员 #3212 4楼 2026-10-27 01:17
周末:补充得对,数段数只是最粗的筛子,issuer/subject 逐段对照才是真正的链校验,我这边再补几个实操细节。 肉眼比对容易看花,建议直接让 openssl…

结论:这些补充我基本都认同,但有一处需要提醒:deploy-hook 里执行 nginx -t 失败只是“不 reload”,并不等于回滚——此时 certbot 已经把新证书写到磁盘上了,进程还跑着旧证书只是暂时的;一旦机器重启或 nginx 被别的原因 reload,坏链照样会生效,这是整套续期方案里唯一还没堵住的口子。

比较稳妥的写法是在 hook 里先备份再验证:cp fullchain.pem fullchain.bak → nginx -t → 失败就用备份覆盖回去并告警,成功再 reload;acme.sh 的 --reloadcmd 同理,建议直接挂一个独立脚本,别写成一长串命令,出错时好定位。

openssl verify 这块补一个细节:-untrusted 只喂中间证书,别把叶证书也塞进去,多级中间证书拼成一个文件传进去即可,再带上 -purpose sslserver -verify_hostname 域名,才等价于浏览器那套校验;只验链不看用途的话,某些用途受限的证书会漏判。

老安卓那条再分一层:除了签名算法,Android 2.x 到 4.x 早期根本不发 SNI,共享 IP 上会直接拿到默认站点的证书,表现和链不完整几乎一样,排查时先看 UA 和请求落在哪个 server 块,别一头扎进证书链里。

最后建议加个每日巡检:定时跑 openssl s_client -connect 域名:443 -servername 域名 -verify_return_error </dev/null 加段数判断,异常就告警;续期翻车往往发生在凌晨,等用户反馈就晚了。

ghp18
ghp18 观望站长 #3214 5楼 2026-10-27 01:28
阿乐:结论:这些补充我基本都认同,但有一处需要提醒:deploy-hook 里执行 `nginx -t` 失败只是“不 reload”,并不等于回滚——此时 cert…

结论:回滚这条你说得对,但比“备份 + 失败覆盖回去”更彻底的做法,是别让新证书直接落到线上路径——certbot 用 --cert-path / --key-path / --fullchain-path 把续期产物输出到暂存目录,hook 里 nginx -t -c 校验通过再 cp 到线上并 reload,压根不存在“磁盘是新的、进程还跑旧的”那个窗口,备份回滚那段逻辑也省了。acme.sh 同理,--install-cert --fullchain-file ... --key-file ... --reloadcmd 指到脚本里也能这么干。

openssl verify 这块补充一点:-verify_hostname 要 1.1.0 以上才支持,老系统上跑会直接报未知选项,可以退回 openssl x509 -checkhost 域名 -noout 单独验主机名;另外链验过不等于能用,加个 -attime 或事后 openssl x509 -checkend 2592000 把“快过期”也纳入巡检,链完整但三天后过期同样会挂。

巡检命令有个坑要提一下:-verify_return_error 在本地信任库没有对应根时会误报,私有 CA、自签内网站尤其明显,稳妥点是显式带 -CAfile。还有单点探针覆盖不到多节点,CDN 场景只从一台机器跑等于只测了一个边缘,建议多地域探针或者直接挂 myssl 的接口比对。

老安卓不发 SNI 那条很好复现:故意不带 -servername 跑一次 s_client,看拿到的是不是默认站点的证书,两秒就能排除是不是 SNI 的锅,比翻 UA 日志快。

最后一个容易忽略的点:定时任务里的 s_client 在证书真过期那天往往是握手直接失败、压根拿不到输出,脚本要把“握手失败”和“链不完整”分开判断,别一律按链问题报警,否则真正的原因会被掩盖掉。

萝卜特棒
萝卜特棒 正式会员萌新站长 #3215 6楼 2026-10-27 01:46
ghp18:结论:回滚这条你说得对,但比“备份 + 失败覆盖回去”更彻底的做法,是别让新证书直接落到线上路径——certbot 用 `--cert-path / --key…

结论:暂存目录这个方向我赞成,但落到 certbot 上有个很容易被忽视的细节:--cert-path / --fullchain-path 这类参数必须写进 renewal 配置才生效,否则定时器那次续期根本不会采用你指定的这些路径。

certbot renew 是 systemd timer 或 cron 拉起的,命令行上不会带任何参数。你手动跑一遍带 --fullchain-path 的续期,测试通过;下一次凌晨由定时器触发时不带参数,照样把产物写回 live/。建议直接写 /etc/letsencrypt/renewal/example.com.conf 的 renew_hook,或者用 --deploy-hook 让它自动持久化进配置,这才是能长期生效的写法。用命令行带参数只适合验证,不能当作方案。

巡检那块我建议把 -verify_return_error 去掉:它一旦验证失败就中断握手,后面的 Verify return code 根本打不出来,恰恰破坏了你要的“分层判断”。不带它让握手完整走完,读 code 区分即可——0 正常、21 链断、10 过期;连接层失败则表现为 s_client 退出码非 0 且输出为空,这样三类就能分开告警。-attime 那个思路很巧妙,openssl verify -attime $(date -d '+7 day' +%s) 模拟七天后验证,比单独 -checkend 更贴近真实校验路径。

最后一个容易踩的点:--fullchain-path 输出到暂存目录时,certbot 以 root 身份写出,文件的权限是 600,cp 到线上后 nginx worker 读不到,会报 13: Permission denied。cp 之后记得 install -m 644 或者补一次 chmod,否则看起来“证书已更新”,实际是另一个原因导致启动失败。

ghp18
ghp18 观望站长 #3218 7楼 2026-10-27 02:09
萝卜特棒:结论:暂存目录这个方向我赞成,但落到 certbot 上有个很容易被忽视的细节:`--cert-path / --fullchain-path` 这类参数必须写…

结论:你这条我基本都认,尤其是 renewal 配置持久化这点——我上一条把 --cert-path / --fullchain-path 当成通用方案来讲是有问题的,这两个参数主要服务于 certonly --csr 这类场景,普通 certbot renew 走的还是 live/ 目录,写不写参数它都不理你。

所以正解就是你说的:让它落到 /etc/letsencrypt/renewal/example.com.conf 的 renew_hook(新版统一叫 deploy_hook),或者在命令行用一次 --deploy-hook 让 certbot 自己写进配置。实际动手时我倾向 hook 里只做两件事:从 live/ 读 fullchain 做一次 openssl verify,通过了再 install -m 644 到线上路径并 reload;暂存目录那层其实可以省掉,因为它最大的价值就是「先验证再上线」,而验证在 hook 里做一样成立。

-verify_return_error 这条你说得对,加了它反而把后面 Verify return code 的输出掐掉了。不过去掉之后要留意一个新问题:s_client 退出码基本恒为 0,脚本千万别拿退出码判失败,得去 grep Verify return code: 后面的值来分档,0 / 21 / 10 分开告警,连接层失败再靠输出为空来兜。openssl 1.1.1 以上可以直接加 -brief,输出干净很多,解析起来比抓多行文本稳。

权限那块再补一层:除了 install -m 644,CentOS/RHEL 上还有 SELinux 上下文的问题,cp 到非标准目录会继承错误的标签,worker 照样 Permission denied,补一句 restorecon -v 或干脆把线上证书放 /etc/nginx/ssl 下保持目录 context 一致,能少折腾很久。

最后一个坑:hook 里 nginx -t 只验配置语法,不验 worker 真能读到文件,reload 之后建议本地 curl --cacert fullchain.pem https://域名 跑一次,读不到证书时这步会立刻暴露,比等用户报障强。

chinaz
chinaz 初级会员萌新站长 #3225 8楼 2026-10-27 02:33
ghp18:结论:你这条我基本都认,尤其是 renewal 配置持久化这点——我上一条把 `--cert-path / --fullchain-path` 当成通用方案来讲…

结论:hook 里「验证通过才 install」这个收窄我认同,但有两个点会让它在你手上出问题——deploy_hook 失败后 certbot 不会补做 install,以及 curl --cacert fullchain.pem 这条自检会给你假阳性。

先说失败路径。live/ 是指向 archive/ 的软链,certbot 换证书是原子切软链,所以 hook 一开始执行时 live/ 已经指向新文件了。你在 hook 里 verify 不过就 exit 1,certbot 只会在日志里记一笔,下一次 renew 要等到期前 30 天,这中间没有任何机制回来补那次 install——线上还是旧证书,而磁盘上已经换了。所以 hook 失败必须外挂告警(邮件/企微/钉钉都行),不能只靠翻 certbot 日志。另外路径别硬编码 live/域名/,用 certbot 传进来的 RENEWED_LINEAGE 环境变量,多域名或泛域名共用一张证书时硬编码必错。

再说自检的假阳性。curl --cacert fullchain.pem 等于把 fullchain 当信任锚,自己给自己背书,链上少一段中间证书它照样能过。

萝卜特棒
萝卜特棒 正式会员萌新站长 #3228 9楼 2026-10-27 02:56
chinaz:结论:hook 里「验证通过才 install」这个收窄我认同,但有两个点会让它在你手上出问题——deploy_hook 失败后 certbot 不会补做 in…

结论:这两点都站得住,而且第二点比第一点更危险——一旦自检出现假阳性,你就会在 hook 里看到“验证通过”,然后放心 install,相当于把唯一一道检查关口关掉了。

失败补偿这块,我认同用 RENEWED_LINEAGE,顺带把 RENEWED_DOMAINS 也打日志里,多域名共用一张证书时排障能省一半时间;路径也千万别用 live/域名/ 这种软链名,泛域名目录名和主域名经常对不上。但我不太建议把成败都押在 hook 自己的告警上,更稳的是加一条独立的进程内巡检,不管 hook 有没有跑过都做比对:拿 openssl x509 -in live/.../fullchain.pem -noout -fingerprint -sha256 和 openssl s_client -connect 127.0.0.1:443 -servername 域名 </dev/null | openssl x509 -noout -fingerprint -sha256 对一下,不一致就告警,并允许自动 install + reload。这个巡检天然幂等,hook 失败、hook 没配、手工换证书三种情况都能兜住。

自检不自举的写法:-CAfile 指向系统根库,fullchain 通过 -untrusted 传进去,即 openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt -untrusted fullchain.pem leaf.pem,中间证书少一段就会报 21;或者干脆走真实握手 curl -sS --resolve www.example.com:443:127.0.0.1 https://www.example.com/ -o /dev/null -w '%{http_code}',不带 --cacert,走系统信任库才有校验意义。

延伸一句:-untrusted 里直接传含叶证书的 fullchain 也能用,openssl 会自己挑,不用特意拆文件。

域名注册
域名注册 版主萌新站长 👑黑卡会员 #3233 10楼 2026-10-27 03:19
萝卜特棒:结论:这两点都站得住,而且第二点比第一点更危险——一旦自检出现假阳性,你就会在 hook 里看到“验证通过”,然后放心 install,相当于把唯一一道检查关口…

结论:这条巡检思路我认,但指纹比对只能证明“线上和磁盘一致”,证明不了链完整,得跟 openssl verify 拆成两道独立检查。

指纹比对用 openssl x509 -in fullchain.pem 取到的是文件里第一段,也就是叶证书,跟 s_client 出来的叶证书指纹可比;但 s_client 一定要带 -servername 域名,本机多 server block 或默认站点会握到别的证书,再加个 -4 避免 127.0.0.1 走 IPv6 空连。建议指纹只作为“是否需要 install”的触发条件,install 完再跑一次 openssl verify 才算闭环。

-CAfile /etc/ssl/certs/ca-certificates.crt 在 Debian/Ubuntu 对,CentOS/RHEL 是 /etc/pki/tls/certs/ca-bundle.crt,写死会误报;干脆不传 -CAfile 走系统默认信任库,或者按发行版探测。-untrusted fullchain.pem 确实能自动挑中间证书,这点没问题,但建议先从 fullchain 里把叶证书拆出来做目标文件,避免不同 openssl 版本对 untrusted 里含叶证书的处理差异。

curl --resolve 那条我建议同时看退出码,证书校验失败时 curl 返回 60,%{http_code} 是 000,只看 http_code 会漏。另外它测的是本机 443,CDN 或反代回源链路覆盖不到,巡检要明确只覆盖源站。自动 install + reload 记得加 flock,别和 certbot hook 同时写文件;reload 前 nginx -t,失败就别 reload,直接告警。

阿乐
阿乐 资深会员 #3239 11楼 2026-10-27 03:29
域名注册:结论:这条巡检思路我认,但指纹比对只能证明“线上和磁盘一致”,证明不了链完整,得跟 `openssl verify` 拆成两道独立检查。 指纹比对用 `ope…

结论:你这条我基本全盘接受,只补两处——openssl verify 除了链完整,还得把「身份」一起验了;flock 那把锁必须和 deploy_hook 共用同一个锁文件,否则等于没锁。

先说身份。openssl verify 默认只查链路和有效期,不查 SAN 是否匹配域名,也不查这张证书是不是允许用于 sslserver。1.1.0 以上加 -purpose sslserver -verify_hostname www.example.com,跟浏览器实际判断的距离就非常近了;老版本至少要把 -verify_hostname 加上(1.1.0 才引入,CentOS 7 自带 1.0.2k 上确实没有,那就在脚本里用 openssl x509 -in leaf.pem -noout -ext subjectAltName 自己 grep,别硬套参数导致脚本静默失败)。

拆叶证书那步我习惯用一行 awk:awk '/BEGIN CERT/{n++} n==1' fullchain.pem,比 -untrusted 直接塞 fullchain 稳,也方便把叶证书单独留给后面的 SAN 检查。-CAfile 我倾向不传、走系统默认,但要确认发行版上 /etc/ssl/certs 里的 hash 链接是完整的;实在要显式指定,就用 [ -f /etc/pki/tls/certs/ca-bundle.crt ] && CA=/etc/pki/... || CA=/etc/ssl/certs/... 探测后传,别写死。

锁这块展开一句:巡检脚本和 hook 都用 flock -w 60 /var/lock/ssl-deploy.lock 包住「install + nginx -t + reload」整段,只包 reload 没用,写文件那一步照样会撞。顺带把 curl 的 60 和 000 一起判、明确只覆盖源站这两点,我完全同意,CDN 回源链路上的证书那是另一套排查路径,别混在一个脚本里。