从报错到修复:SSL证书 SAN 不匹配排查指南
1. 报错里的方括号往往比域名写错了更棘手凌晨两点被流水线告警叫醒打开日志第一眼就是这行红字javax.net.ssl.SSLHandshakeException: Certificate for api.example.com doesnt match any of the subject alternative names: [*.example.com]很多人第一反应是域名写错了改一下调用地址就完事。我踩过几次之后才明白subject alternative names简称 SAN这行方括号里的内容其实是服务端证书的身份证清单而报错方括号里的域名是客户端我要找的人。这行报错翻译成人话就是我拿着你要找的名字去对了一遍证书上的名单一个都对不上。这篇内容适合三类人看一是被这张证书报错卡住、想让服务先跑起来的一线开发二是负责证书签发、部署、续期的运维同学三是需要在代码里处理 HTTPS 调用、又不想靠跳过校验糊弄过去的技术负责人。我会把 SAN 的比对规则、五种最常见的触发场景、从openssl到curl再到 Java 客户端的完整排查链路以及修复方案的选型逻辑全部拆开讲最后给一份能直接抄作业的自检清单。先说一个容易被忽略的前提报错里的[xxx..com]这种中间多一个点的写法有可能是工具在拼接通配符或者截断显示时产生的也可能是证书里真的写进了一个格式不规范的条目。看到方括号内容长得怪别急着当成解读错误先去把证书原文打出来看后面第 4 章会讲具体命令。2. SAN 到底管什么为什么 CN 时代已经翻篇了2.1 从 CN 到 SAN是一次静悄悄的规则切换X.509 证书里有两个地方可以写域名。老一点的是 Subject 里的Common NameCN新一点的是扩展字段X.509v3 Subject Alternative Name。早期大家习惯只填 CN那时候浏览器也会去比 CN所以一张只写 CN 的证书也能用。后来事情变了。RFC 6125 明确了一条原则如果证书里存在 SAN 扩展客户端就必须忽略 CN只看 SAN。再往后主流浏览器从 2017 年前后开始彻底不再回退到 CN证书颁发机构也基本不再签发只有 CN 没有 SAN的公开证书。所以你手上如果有一张十年前签的老证书本地测试环境可能还认得一旦换到新版客户端就立刻报 SAN 不匹配。这里有个特别容易让人误判的现象同一张证书在开发机的旧 JDK 上跑得通部署到新环境就报doesnt match any of the subject alternative names。原因就是新旧客户端对没有 SAN 时要不要回退比 CN的处理策略不同。我遇到过最坑的一次是测试环境用 JDK 8 早期小版本生产用 8u181 之后的版本同一个接口一个通一个不通排查了大半天才定位到客户端行为差异而不是证书本身有问题。2.2 一条命令把服务端真实吐出的证书看清楚排查这类问题第一步永远不是改代码而是看服务端到底发的是哪张证书。下面这条命令我几乎是肌肉记忆# 显示握手过程并打印服务端发送的全部证书叶子证书在前中间证书在后 openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts /dev/null几个参数必须注意-servername是 SNI 字段很多网关和 CDN 靠它来决定返回哪张证书不写这个参数拿到的可能是默认证书结论完全跑偏-showcerts会打印服务端发送的完整证书链而不仅仅是第一张/dev/null是为了让命令在非交互环境下正常退出写脚本时漏掉它会卡住。拿到输出之后只关心 SAN 一行openssl s_client -connect api.example.com:443 -servername api.example.com /dev/null 2/dev/null \ | openssl x509 -noout -ext subjectAltName输出大概长这样X509v3 Subject Alternative Name: DNS:example.com, DNS:*.example.com, DNS:*.api.example.com到了这一步问题基本就定性了要么你要访问的名字不在这个列表里要么列表里的写法通配符层级、拼写、后缀跟你以为的不一样。真正难缠的不是看清楚而是看懂也就是下面这一章要说的比对规则。2.3 SAN 里能放什么不只是域名很多人以为 SAN 只能放域名其实它是多类型的常见的有几种SAN 类型用途常见踩坑点DNS普通域名与通配符域名通配符层级限制、大小写、末尾点IP Address直接用 IP 访问的场景必须是 IP 类型条目写成 DNS 不生效URI特定协议的服务标识极少用于 HTTPS 服务端校验email邮件签名与加密HTTPS 场景基本无关这里第一个大坑就是IP 访问。如果证书的 SAN 里写的是DNS:192.0.2.10那就等于没写因为客户端比对 IP 时会去找IP Address类型的条目。反过来如果 SAN 里只有IP Address:192.0.2.10你用域名去访问一样报错。第二种常见的坑是通配符被当成万能匹配。*.example.com只能覆盖一级子域名*.api.example.com只能覆盖xxx.api.example.com而a.b.api.example.com就匹配不上。更隐蔽的一点是*.example.com通常不覆盖裸域名example.com本身也就是说不覆盖零级。这个细节让无数人在访问主域名通了、访问 www 不通之间来回折腾。3. 五种高频触发场景以及各自的根因定位路径3.1 场景一证书只签了主域名访问的却是子域名这是最常见的一种。业务早期只有example.com证书申请时就填了这一个名字后来业务拆出api.example.com、admin.example.com运维没重新申请证书或者重新申请时忘了把新域名加进 SAN 列表于是子域名访问全部报错。定位动作很简单把服务端证书的 SAN 打印出来看目标域名是否在列表里同时确认通配符的层级是否真的能覆盖。注意不要只看列表里有个星号就下结论星号的位置很关键。*.example.com覆盖不覆盖api.example.com覆盖。覆盖不覆盖api.uat.example.com不覆盖。修复方式有两种一是重新签发把需要的域名都加进 SAN二是如果未来同一级子域名还会持续增加就申请一张*.example.com通配符证书。两种方式的取舍在第 5 章细说。3.2 场景二用内网 IP 直连却拿了一张域名证书unable to push signed certificate to host 192.168.2.222热词里出现的这类报错跟前面那条 SAN 不匹配本质上常常是同一个家族的问题——只不过报错的地方从客户端换到了服务端或部署工具。内网 IP 直连的场景下客户端连的是https://192.168.2.222:8443但服务端挂的是一张给*.example.com签发的证书SAN 里根本没有 IP 类型条目校验必然失败。还有一种变体域名证书其实是对的但负载均衡或某台节点上挂着旧证书部署工具推送新证书时因为文件权限、目标路径不对、服务没重载等原因没推成功于是部分节点还在用旧证书。这时候你会发现有的请求正常、有的报错非常迷惑。定位的关键是逐台确认而不是只测一次。# 针对具体 IP 和端口逐台确认证书主体 for ip in 192.168.2.221 192.168.2.222 192.168.2.223; do echo $ip openssl s_client -connect $ip:8443 -servername api.example.com /dev/null 2/dev/null \ | openssl x509 -noout -ext subjectAltName -subject -dates done这段循环跑完哪台节点掉了队一目了然。我个人的经验是凡是做了多节点的服务证书替换后一定要跑一遍这种逐台核对不要相信部署工具的成功回显。3.3 场景三网关或负载均衡上证书张冠李戴反向代理层是证书问题的高发区。一个入口承载多个域名时网关需要依赖 SNI 来挑选证书。如果配置里只挂了一张默认证书任何不在它 SAN 列表里的域名访问都会报 SAN 不匹配。Nginx 侧的典型问题是每个server块各自的ssl_certificate配置错误或者用了default_server却挂了一张只覆盖部分域名的证书。排查时先确认 SNI 是不是真的传到了后端# 分别用不同的 SNI 去连同一个入口观察返回的证书是否不同 openssl s_client -connect gw.example.com:443 -servername api.example.com /dev/null 2/dev/null | openssl x509 -noout -subject openssl s_client -connect gw.example.com:443 -servername web.example.com /dev/null 2/dev/null | openssl x509 -noout -subject如果两次输出的主体完全一样而你要访问的域名又不在它覆盖范围内那就说明网关没做基于 SNI 的分证书或者配置没生效。这种情况下换一张覆盖更广的证书和补上 SNI 分支是两条不同的路前者见效快、后者更规范具体选哪条要看域名规模和后续规划。3.4 场景四证书链缺失引发的连锁报错ssl certificate openssl verify result: unable to get local issuer certificate curl: (60) ssl certificate problem: certificate has expired这两条报错看起来很不一样其实都指向信任链这条主线。前一条是说客户端拿到了叶子证书往上找不到中间证书无法把链条接到本地信任库里。后一条是叶子证书本身过期。很多人把证书链问题误判成 SAN 问题因为客户端报错文案有时会混着展示。区分方法很直接# 单独做链验证cert.pem 是叶子chain.pem 是中间证书root.pem 是根证书 openssl verify -CAfile root.pem -untrusted chain.pem cert.pem如果这条命令报unable to get local issuer certificate说明链断了跟 SAN 无关。如果报certificate has expired就是有效期问题。这两种情况都不会因为你去改调用地址而消失。链缺失的根因通常是服务端配置只挂了叶子证书文件没把中间证书拼进去。Nginx 的ssl_certificate需要的是叶子 中间拼接后的完整链文件顺序是叶子在前、中间在后顺序反了同样会报错而ssl_certificate_key只放私钥。Apache 则是SSLCertificateFile放叶子、SSLCertificateChainFile放中间。这个配置差异,我见过太多人在迁移 Web 服务器时栽跟头。3.5 场景五客户端信任库版本太旧fatal alert: bad_certificate - a corrupt or unusable certificate was received这条报错的含义更接近证书本身或握手过程中的证书不可用常见于双向 TLSmTLS场景服务端要求客户端出示证书客户端给的证书没法被验证或者客户端根本没给。热词里那条no required ssl certificate was sent也是这一族——服务端配置了ssl_verify_client on但客户端没带证书就发起了请求。另一类属于信任库太旧服务端用的是新算法或者新根证书签发的证书而客户端的 JDKcacerts或者系统的 CA 包还停留在几年前认不出来。这种情况下的表现通常是浏览器访问正常、代码访问报错因为浏览器自带最新的根证书列表。# 检查 Java 信任库里的根证书数量与 JDK 版本 keytool -list -keystore $JAVA_HOME/lib/security/cacerts -storepass changeit | tail -5如果发现信任库确实陈旧更新 JDK 或者把缺失的根证书导入信任库都能解决但这属于补环境不是修证书定位时一定要分清。4. 一层层验下来从握手到应用层的完整排查链4.1 第一层确认握手阶段拿到的是哪张证书排查的起点永远是服务端到底发了什么。除了前面说的-showcerts我更推荐把证书导出来存文件方便反复比对openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts /dev/null 2/dev/null \ /tmp/handshake.txt # 提取第一张证书叶子并查看完整信息 awk /BEGIN CERTIFICATE/,/END CERTIFICATE/ /tmp/handshake.txt | head -40 /tmp/leaf.pem openssl x509 -in /tmp/leaf.pem -noout -subject -issuer -dates -ext subjectAltName把subject、issuer、dates、subjectAltName四个字段一起看能一次性排除掉域名不匹配签发者不是预期 CA证书已过期这三类问题。这一步花不了一分钟却能避免后面大量无效排查。注意如果你用的是 Nginx 或者某些网关一定要带上-servername。不带 SNI 时网关会返回默认证书你看到的 SAN 列表和你实际访问的域名可能压根没关系很容易得出错误结论。4.2 第二层用 openssl verify 判断信任链断点拿到证书文件之后做一次独立验证把域名匹配和信任链两件事彻底分开# 只用叶子证书验证观察报错 openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt /tmp/leaf.pem # 带上中间证书再验证 openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt -untrusted /tmp/chain.pem /tmp/leaf.pem两次结果的差异非常有信息量第一次失败、第二次成功说明服务端漏发了中间证书两次都失败且报unable to get local issuer certificate说明根证书不在本地信任库里报certificate has expired或certificate is not yet valid就是时间问题。我处理过一个案例服务端配置看起来完全正确但中间证书文件里混进了一张交叉签名的旧证书导致部分老客户端能验证、部分新客户端不行最后就是靠这两条命令比对出来的。时间问题还有个小陷阱容器或虚机时间漂移。如果宿主机时间跑偏客户端会认为证书尚未生效报错文案有时也会落到证书类错误上。排查时顺手看一眼date成本极低。4.3 第三层用 curl 复现顺便排除 DNS 和代理干扰应用层复现最方便的工具是 curl几个开关组合起来非常好用# 详细输出握手与证书信息 curl -v https://api.example.com/ 21 | head -40 # 绕过 DNS直接把域名指到指定 IP排查多节点时特别有用 curl -v --resolve api.example.com:443:192.168.2.222 https://api.example.com/ # 只看证书的 SAN不做完整请求 curl -sS -o /dev/null -v --resolve api.example.com:443:192.168.2.222 https://api.example.com/ 21 | grep -i subject\|alt--resolve是排查多节点证书不一致的杀手锏。它让你在不动 DNS 的前提下把请求精确打到某一台机器上逐台确认证书。热词里那种部署工具把证书推到了 192.168.2.222 这个节点的场景用这条命令很快就能验证推没推成功。至于curl: (60)这个错误码它代表证书校验失败涵盖的范围包括链不完整、证书过期、域名不匹配。想知道具体是哪一种加上-v看详细输出或者直接用--cert-status之类的方式进一步确认别看到 60 就一律当成忽略校验的理由。4.4 第四层Java 客户端侧的握手细节Java 生态里的报错最典型的就是文章开头那句doesnt match any of the subject alternative names。定位时打开 SSL 调试开关信息量会大很多java -Djavax.net.debugssl:handshake -jar app.jar输出里会打印客户端发送的 SNI、服务端返回的证书链、以及最终抛出异常的位置。如果客户端用的是自定义的HttpClient或者第三方 SDK还要注意它们是否覆盖了默认的HostnameVerifier。有些 SDK 在内部把域名校验逻辑改了导致报错信息看起来一样、根因却完全不同。Java 侧还有两个容易被忽略的点。一是连接池和长连接证书换掉之后旧连接可能还在复用导致部分请求报错、部分正常排查时记得重启客户端或者缩短连接存活时间。二是自定义SSLContext里加载的信任库路径很多时候应用打包时把信任库一起打进去了环境变量指向的却不是同一个文件改了系统信任库也不生效。我一般会先确认应用实际加载的信任库路径再决定往哪里导证书。# 查看应用进程实际打开的文件里有没有信任库 lsof -p pid | grep -i cacerts\|truststore\|\.jks5. 修复方案的取舍重签、扩展 SAN还是改入口5.1 重签一张 SAN 完整的证书是最直接的解法只要确认是目标域名不在 SAN 列表里最干脆的做法就是重新签发。流程上注意几点先确认验证方式。DNS 验证最省事适合有域名管理权限的团队文件验证需要能往站点根目录写文件在纯 API 服务上往往不方便企业级证书还会要求组织信息核验周期更长。其次CSR 生成时的私钥长度和算法要与现有环境兼容RSA 2048 位和 ECC P-256 是最通用的两个选择如果客户端里有老旧设备优先选 RSA。重签之后不要只改一处。至少要同步这几个地方网关或负载均衡的证书配置、后端各节点的证书文件、客户端应用里可能硬编码的证书路径、以及自动化部署脚本里的证书来源。我见过有人只更新了网关后端节点还挂着旧证书结果内部服务之间的调用开始报错排查了半天才发现是只改了一半。5.2 SAN 列表怎么规划才不会越签越乱SAN 列表的规划本质上是域名命名规范的问题。我的建议是分三层考虑场景推荐做法理由只有一两个确定域名全部写进 SAN不用通配符最小暴露面权限可控同一级子域名会持续增加用*.example.com一次签发改动成本低跨多级子域名每个层级各写一条通配符通配符不跨级必须分开写既有主域名又有子域名主域名和通配符都写通配符不覆盖裸域名还有一条经验SAN 条目不是越多越好。数量多了之后任何一条写错都可能导致整张证书在部分客户端上校验失败而且排查时要在几十条里找问题效率极低。按业务域拆分成多张证书反而比一张大而全的证书更好维护。提示公开证书对单个证书的 SAN 条目数量有上限约束规划时不要指望用一张证书覆盖全公司所有域名按业务线拆分更现实。5.3 内网场景私有 CA 加信任库导入比硬塞公网证书更合适纯内网环境用公网证书是一件别扭的事。内网 IP 无法通过公开验证流程域名也往往是内部自造的。这种场景下更合理的方案是自建私有 CA# 生成根证书私钥与自签根证书 openssl genrsa -out rootCA.key 4096 openssl req -x509 -new -nodes -key rootCA.key -sha256 -days 3650 \ -subj /CNInternal Root CA/OExample -out rootCA.crt # 生成服务端私钥与 CSR openssl genrsa -out server.key 2048 openssl req -new -key server.key -subj /CNapi.internal.example -out server.csr # 用 SAN 扩展文件签发服务端证书 cat san.cnf EOF subjectAltName DNS:api.internal.example, DNS:*.internal.example, IP:192.168.2.222 EOF openssl x509 -req -in server.csr -CA rootCA.crt -CAkey rootCA.key -CAcreateserial \ -days 825 -sha256 -extfile san.cnf -out server.crt签完之后把根证书rootCA.crt导入到所有需要访问该服务的客户端信任库里。Java 用keytool -importcert系统层面就放到系统的 CA 目录并执行更新命令。这样做的好处是SAN 完全可控IP 和内部域名都能覆盖有效期也能自己定。这里有个必须提醒的点私有 CA 的根证书私钥必须离线保管。一旦根私钥泄露等于所有内部服务的信任基础都塌了重新签发和替换的成本非常高。我在实际项目里见过把根证书私钥放在代码仓库里的做法那是绝对不能接受的。5.4 为什么不该用跳过校验来收场遇到证书报错最容易找到的解决方案是关掉校验代码里塞一个信任所有证书的TrustManager或者用curl -k、verifyFalse。测试环境临时验证一下服务是否可达这没问题但如果把它写进生产代码就等于亲手拆掉了 HTTPS 的防护墙。证书校验做的事其实有三件确认服务端身份域名匹配 SAN、确认对方持有对应私钥、确认证书在有效期内且被可信机构签发。关掉校验之后这三条全部失效中间人完全可以替换证书而不被发现。而且这个问题往往不会当场暴露等项目上线很久之后才由安全审计或者外部评估发现修复成本比一开始就正确处理高得多。如果确实面临客户端信任库无法更新的困境正当的出路是补信任链把缺失的中间证书或私有根证书导入客户端信任库而不是放弃校验。这两条路的长期成本差异巨大。6. 防复发把证书检查变成流程的一部分6.1 上线前必做的三项自检每次涉及证书的变更我都会跑这三步基本能拦住 90% 的低级问题# 1. 校验证书链完整性 openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt -untrusted chain.pem leaf.pem # 2. 校验私钥与证书是否配对两个输出必须一致 openssl x509 -noout -modulus -in leaf.pem | openssl md5 openssl rsa -noout -modulus -in server.key | openssl md5 # 3. 实际握手验证确认 SAN 覆盖目标域名 echo | openssl s_client -connect api.example.com:443 -servername api.example.com 2/dev/null \ | openssl x509 -noout -ext subjectAltName第二步特别值得强调。私钥和证书配不配是很多诡异问题的源头服务重启失败、部分节点起不来、或者启动起来了但握手直接失败。两条命令输出的 MD5 一致才说明配对。这个检查只要十几秒却能省掉几小时的排查。6.2 到期提醒与自动续期证书过期是最不值当的一类事故因为它完全可预防。几个实用做法在监控系统里对每个域名做证书有效期探测剩余天数低于 30 天开始告警低于 14 天升级告警级别。如果条件允许用自动化工具完成签发与续期并在续期成功后触发服务重载。重载这一步是关键很多续期工具签了新证书但没让服务重新读取等于白签。把证书到期时间写进值班交接文档。自动化再完善也建议保留一条人工兜底检查。我推荐给每个域名准备一个简单的探测脚本输出到期天数接入到已有的告警通道里成本极低但收益很高。# 读取远端证书剩余有效天数 end_date$(echo | openssl s_client -connect api.example.com:443 -servername api.example.com 2/dev/null \ | openssl x509 -noout -enddate | cut -d -f2) echo $end_date6.3 常见报错与对应动作对照排查到最后我一般会对照下面这张表快速定位避免在错误的方向上浪费时间报错关键信息最可能的根因优先动作doesnt match any of the subject alternative names目标域名不在 SAN 列表或通配符层级不覆盖打印 SAN确认后重签unable to get local issuer certificate服务端漏发中间证书或本地无根证书补齐证书链或导入根证书certificate has expired / not yet valid证书过期或机器时间漂移续期或校准时间no required ssl certificate was sent服务端要求客户端证书但未提供检查 mTLS 配置与客户端证书bad_certificate客户端证书不可用或被拒校验证书格式、算法与信任关系unable to push signed certificate to host部署推送失败节点仍是旧证书逐台核对检查路径与重载这张表最大的价值是分配优先级先判断是域名匹配问题、信任链问题还是时间问题这三类的排查路径完全不同。只要方向对了剩下的都是执行。最后分享一个我自己踩出来的习惯遇到证书类问题先别碰应用代码先用openssl s_client把服务端证书完整打出来存成文件再用openssl verify跑一遍链验证。这两步做完问题几乎必然落在域名不匹配链不完整时间不对三选一里后面就是按表执行的事。我早期最大的浪费就是在还不知道服务端发了哪张证书的情况下反复去改客户端调用地址和信任库配置方向从一开始就是错的。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →