解决x509证书报错:SANs缺失导致HTTPS连接失败及修复方法 前一阵帮同事排查一个内部服务的 HTTPS 连接问题日志里反反复复出现同一行verify certificate: x509: certificate relies on legacy Common Name field, use SANs instead。乍一看像证书过期其实根本不是。这个报错的意思是客户端在验证证书时发现对方证书只设置了 Common NameCN没有可用的 Subject Alternative NameSANs扩展于是拒绝继续通信。很多自签名证书、内部系统证书都会栽在这一条上尤其是最近升级过 Go 版本或者换了新客户端工具的环境一升级就爆出来。如果你也遇到类似问题先不用慌。这条报错不代表加密机制坏了而是你的证书“写法”过时了。这篇文章我会从 X.509 证书的基本概念讲起说清楚 CN 和 SAN 到底有什么不同为什么新版本客户端不再认 CN然后给出生成带 SAN 证书的完整方案以及 curl、Go、Nginx 等场景下的排查和修复方法。无论你是运维、后端开发还是自己搭服务玩照着做基本都能解决。1. 先搞懂这条报错在说什么1.1 从一行报错拆开看x509、Common Name、SANs 是什么X.509 是数字证书的标准格式HTTPS 里用的就是它。一个 X.509 证书里包含很多字段其中两个和主机名校验关系最大一个是证书主体的 Common Name通常简写为 CN另一个是扩展字段 Subject Alternative Name一般缩写为 SANs。CN 在证书里长这样Subject: CCN, STBeijing, LBeijing, OExample Inc, OUIT, CNmyserver.com这里的CNmyserver.com表示证书所有者是 myserver.com。老一代的客户端在校验时会把访问的域名或者 IP 和证书里的 CN 比对对得上就放行。SANs 则是证书扩展部分可以列出多个域名、IP 地址甚至邮箱地址。用 openssl 查看时通常长这样X509v3 Subject Alternative Name: DNS:myserver.com, DNS:www.myserver.com, IP Address:127.0.0.1这里的DNS:和IP:就是服务端证书允许的访问入口。问题就出在过去 CN 是主机名校验的主要依据但现在的规范建议客户端只看 SANs不再看 CN。当证书里只有 CN、没有 SANs 时客户端就没法确认这个证书到底是为了哪个域名签发的于是直接拒绝。刚才这条报错就是在 Go 的crypto/x509包或者其他依赖 OpenSSL 的程序里校验逻辑发现你给的是“旧式证书”于是明确告诉你不要再依赖 CN 了请提供 SANs。1.2 为什么新版本会拒绝 CN策略变化与 RFC 6125这不是某个软件拍脑袋想出来的改动而是整个行业在向标准靠拢。早在 2011 年RFC 6125 就规定了服务器身份校验应该以 SAN 里的 dNSName 为主CN 不再作为可靠依据。浏览器厂商执行得比较早Chrome 从 58 版本开始就完全忽略 CN 字段只认 SANs。所以你会发现有些老证书在旧浏览器里能开在新浏览器里直接提示不安全。编程语言和基础库这些年也陆续跟进。Go 语言在 1.15 版本发布时有一个重要变更crypto/x509在证书主机名校验时不再回退到 Common Name。也就是说从 Go 1.15 开始如果证书只有 CN 没有 SANs用 Go 写的客户端访问这个 HTTPS 服务时就会直接报我们开头看到的那条错。OpenSSL 也类似1.1.1 之后的版本在校验主机名时优先使用 SANs虽然有些场景下还支持-verify_hostname的兼容处理但如果证书本身没有 SANs依然会失败。这样一来老式“一条 openssl 命令生成自签名证书”的方式就彻底过时了。所以这个问题的本质不是“证书不能用”而是“证书格式不符合现代客户端的校验要求”。理解了这一点你就知道应该往哪个方向改了把证书补上 SANs而不是在客户端里强行关掉校验。2. 为什么你的证书只有 CN生成自签名证书时埋下的雷2.1 一条经典的错误命令很多教程、博客在演示自签名证书时都会让你执行这么一条命令openssl req -new -x509 -keyout server.key -out server.crt -days 365 -subj /CNmyserver.com这条命令确实能生成一张证书而且老版本的 OpenSSL、老版本的 Go甚至某些旧浏览器都能正常使用。但问题在于这样生成的证书扩展部分几乎是空的没有subjectAltName将来任何按新规范校验的客户端都会不认。为什么会有这个历史遗留因为-subj只把 Distinguished Name 里的 CN 写进去了没有生成扩展项。早期实现里 CN 是主机名匹配的兜底方案大家习惯了写 CN很多教程也就这么教了下来。结果就是网上大量自签名证书生成脚本都是“只含 CN不含 SANs”的写法。现实中的受害场景主要有几种内部系统用了自签名证书个人开发环境里用 Docker 起了个 Nginx测试环境用openssl s_client模拟握手或者内网应用通过 Go 程序调用某个 HTTPS 接口。这些场景只要客户端版本一升级报错就雪片般飞来。2.2 用 -addext 一行命令生成带 SAN 的证书如果你用的是 OpenSSL 1.1.1 及以上版本最简单的方式是给openssl req加一个-addext参数直接写入 SAN 扩展。一条命令就能生成带 SANs 的自签名证书openssl req -x509 -newkey rsa:2048 -sha256 -nodes \ -keyout server.key -out server.crt \ -days 365 \ -subj /CNmyserver.com \ -addext subjectAltNameDNS:myserver.com,DNS:www.myserver.com,IP:127.0.0.1这条命令里-subj仍然写上 CN主要是为了让证书主体信息完整真正起作用的是-addext subjectAltNameDNS:myserver.com,DNS:www.myserver.com,IP:127.0.0.1。我建议把将来可能访问这个服务的所有域名和 IP 都列进去比如内网域名myserver.local、公网域名myserver.com、测试 IP127.0.0.1一次写全省得以后重复签发。注意-nodes表示私钥不加密适合测试环境生产环境建议去掉-nodes生成加密私钥并妥善保管。如果只需要生成 CSR证书签名请求可以把-x509去掉换成一个 CSR 文件待 CA 签名时再带上这份 SAN 扩展。-addext这个参数非常方便但前提是你的 OpenSSL 版本足够新。检查版本用openssl version如果是 1.1.1 之前的老版本就不支持-addext那就得用配置文件的方式。2.3 用 openssl.cnf 生成带 SAN 的证书规范做法对于生产环境我倾向于用配置文件来管理证书生成参数可维护性更好。创建一个openssl.cnf文件内容大致如下[req] distinguished_name req_distinguished_name req_extensions v3_req prompt no [req_distinguished_name] CN myserver.com [v3_req] subjectAltName alt_names [alt_names] DNS.1 myserver.com DNS.2 www.myserver.com IP.1 127.0.0.1然后执行openssl req -x509 -newkey rsa:2048 -sha256 -nodes \ -keyout server.key -out server.crt \ -config openssl.cnf \ -days 365生成之后建议再用一条命令确认扩展真的写进去了openssl x509 -in server.crt -noout -text | grep -A1 Subject Alternative Name如果输出里能看到Subject Alternative Name那就成功了。如果用配置文件生成但没有加req_extensions v3_reqOpenSSL 可能不会把 SAN 扩展写入最终的证书这也是一个很隐蔽的坑。我在实际使用中还会在配置文件里加上basicConstraintsCA:FALSE和keyUsagedigitalSignature,keyEncipherment更符合 Web 服务证书的规范尤其是需要被 CA 签名的场景。3. 拿到证书后怎么验证别再凭感觉了3.1 用 openssl 查看证书里的 SAN生成证书之后第一件事就是验证证书内容是否符合预期而不是直接丢到服务器上。查看证书的基本信息可以用openssl x509 -in server.crt -noout -subject -issuer -dates -ext subjectAltName-subject显示证书主体-issuer显示签发者-dates显示有效期-ext subjectAltName专门查看 SAN 扩展。这种组合方式比-text全量输出更聚焦。如果证书里没有 SAN输出中会看不到X509v3 Subject Alternative Name这一段那就要返回去检查生成配置。有些证书是别人给你的你没法重新生成那就只能要求对方换证或者在客户端侧配置证书信任链但这属于妥协方案。有时候你会看到输出里 SAN 是DNS:myserver.com, IP Address:127.0.0.1但访问请求里用的是https://localhost那也会校验失败因为localhost不等于127.0.0.1。所以检查时不要只看有没有 SAN还要看 SAN 里的值是否覆盖了你实际要访问的域名和 IP。3.2 模拟客户端握手并校验主机名光看证书内容还不够最好模拟一遍真实的 TLS 握手确认服务端返回的证书能被客户端接受。用openssl s_client是一个很直接的办法openssl s_client -connect myserver.com:443 -servername myserver.com -CAfile ca.pem这里的-servername用于 SNI告诉服务端我要访问的是哪个域名-CAfile ca.pem指定根证书文件。如果服务端证书是自签名证书本身那-CAfile就填服务端证书如果服务端证书是由自己的内网 CA 签发的那-CAfile填内网 CA 证书。更严格的做法是加上-verify_hostname参数openssl s_client -connect myserver.com:443 -servername myserver.com \ -CAfile ca.pem -verify_hostname myserver.com如果校验通过输出里会有类似Verification: OK的信息。如果证书不匹配会显示校验失败并且往往能直接看到subjectAltName不匹配的提示。我用这个方式排查过很多次比直接抓包高效得多。4. 不同客户端场景下的修复与应对4.1 Go 程序报 x509 错误的处理Go 1.15 之后crypto/x509对 CN 字段的依赖被彻底移除了。所以如果你在用 Go 写客户端请求一个只含 CN 的 HTTPS 接口大概率会看到这样的报错x509: certificate relies on legacy Common Name field, use SANs instead最彻底的修复方式是让服务端把证书换成带 SANs 的。这个是根因必须解决。服务端证书可以自己生成也可以让 CA 重新签发只要 SANs 中包含访问域名即可。在客户端侧如果是内网自建 CA 签发的证书你需要让 Go 程序信任这个 CA。常见的做法是把 CA 证书写入系统证书库或者在代码里显式加载pool, _ : x509.SystemCertPool() if pool nil { pool x509.NewCertPool() } caCert, err : os.ReadFile(ca.pem) if err ! nil { log.Fatal(err) } pool.AppendCertsFromPEM(caCert) client : http.Client{ Transport: http.Transport{ TLSClientConfig: tls.Config{ RootCAs: pool, }, }, } resp, err : client.Get(https://myserver.com/)这样程序就会用你指定的 CA 去验证服务端证书而不是用系统内置的一堆公共 CA。有一点要记住如果服务端证书没有 SANs即使你把 RootCAs 配置正确Go 依然会报“legacy Common Name field”的错。所以根因还是证书本身。不推荐的做法是设置InsecureSkipVerify: true这等于关闭了所有证书校验不仅绕过了主机名校验也绕过了证书链校验很容易被中间人攻击。本地调试可以偶尔用一下但千万别把这个参数带到生产环境。4.2 curl 访问 https 接口的报错处理用 curl 访问一个只含 CN 的自签名服务常见报错有两种。一种是curl: (60) SSL certificate problem: self-signed certificate这种通常是证书不被信任需要用 CA 文件curl --cacert ca.pem https://myserver.com/另一种是curl: (60) SSL certificate problem: unable to get local issuer certificate这种也是信任链问题要么指定--cacert要么把根证书装进系统信任库。但如果服务端证书本身没有 SANs即使指定了--cacertcurl 在某些较新版本里也一样会报x509: certificate relies on legacy Common Name field。这时候就不要纠结命令参数了还是要回到服务端重新签一张带 SANs 的证书。临时测试的时候可以用curl -k跳过证书校验但只建议在调试时用。更好的方式是用--resolve做域名解析映射同时带上--cacert这样既能测试预发布环境又不影响真实请求curl --resolve myserver.com:443:127.0.0.1 \ --cacert ca.pem \ https://myserver.com/api/ping这种方式对排查多域名证书特别有用因为你可以把某个域名临时指向内网 IP同时校验证书里是否包含这个域名。4.3 Nginx 上配置证书避免证书链和 SAN 缺失问题Nginx 配置 HTTPS 本身不难但证书相关的坑不少。如果你已经拿到了带 SANs 的证书配置文件类似这样server { listen 443 ssl; server_name myserver.com www.myserver.com; ssl_certificate /etc/nginx/certs/server.crt; ssl_certificate_key /etc/nginx/certs/server.key; }这里的server_name必须包含在证书 SANs 里。证书里的 SAN 有myserver.com和www.myserver.com那server_name就写这两个域名。如果证书 SAN 里只有 IP127.0.0.1那你用域名访问就会失败反之亦然。如果是 CA 签发的证书ssl_certificate文件通常要把站点证书和中间 CA 合并在一起顺序是先站点证书再中间证书。漏了中间证书会导致部分客户端报unable to get local issuer certificate。合并方法很简单cat server.crt intermediate.crt fullchain.crt配置完成后一定要用nginx -t检查语法然后nginx -s reload平滑重载。重载前最好再用openssl s_client从外部访问验证一遍确认公网看到的证书是你的完整链。5. 常见问题速查表与避坑指南5.1 报错信息与排查对照表我在排查 TLS 证书问题时遇到频率最高的几条报错整理成了一张速查表报错信息可能原因排查方向x509: certificate relies on legacy Common Name field, use SANs instead证书只有 CN没有 SANs重新生成带 SANs 的证书unable to get local issuer certificate客户端不信任签发证书的 CA使用--cacert或把 CA 加入系统信任库no required ssl certificate was sent服务端配置了 mTLS要求客户端证书但客户端没发检查客户端证书配置确认私钥和证书匹配fatal alert: bad_certificate服务端或客户端收到无法解析的证书可能是格式损坏或证书过期用openssl x509 -text检查证书格式确认证书链完整server certificate is not valid, please check if the host has the correct t主机名不匹配、证书过期或系统时间不对核对 SANs 域名、证书有效期和服务器时间SSL certificate verify result: unable to get local issuer certificate证书链不完整或根证书缺失合并证书链指定完整 CA 链这张表我长期贴在“踩坑记录”笔记本里每次排查先对号入座能省很多时间。5.2 自签名证书的几条实战心得第一自签名证书一定要加 SANs哪怕只是本机测试。否则今天你用127.0.0.1访问没问题明天换个域名就报错来回折腾很浪费。把可能用到的域名和 IP 都列进去一劳永逸。第二生成证书后立刻验证。我以前犯过很多次错觉得命令没问题就直接部署结果上线后才发现 SAN 没写进去。现在每次生成完都跑一遍openssl x509 -in server.crt -noout -text | grep -A1 Subject Alternative Name看到输出有结果才算过关。第三如果系统时间和证书时间对不上也会出现“证书无效”的错误。以前排查一个诡异问题服务端、客户端都在内网证书也没过期但就是验证失败最后发现是新装系统时间慢了三天。对齐时间之后一切正常。第四不要把测试用的自签名证书和正式 CA 证书混用。有些人图方便把同一个.pem文件又当根证书又当服务端证书短期内能通但一旦涉及多个服务信任链就乱了。建议区分 CA 证书、服务端证书和客户端证书各自独立生成。5.3 自动化检查证书的脚本思路既然 SAN 缺失这种问题很隐蔽我建议写一个简单的检查脚本在证书更新或发布前自动验证。用 openssl 就能做不依赖额外工具#!/bin/bash CERT_FILE${1:-server.crt} DOMAIN${2:-myserver.com} if openssl x509 -in $CERT_FILE -noout -ext subjectAltName | grep -q $DOMAIN; then echo SAN check passed: $DOMAIN else echo SAN check failed: $DOMAIN not found in subjectAltName exit 1 fi if openssl x509 -in $CERT_FILE -noout -checkend 86400; then echo Expiry check passed else echo Expiry check failed exit 1 fi这个脚本检查两件事证书里是否包含指定的域名以及证书在未来 24 小时内会不会过期。你可以把它接到 CI 流程里或者放到发布脚本里每次部署前自动跑一遍避免带着有问题的证书发到生产环境。6. 写在最后的一点经验做了这些年技术支持和排查我最大的感受是很多 TLS 证书问题看起来五花八门真正原因就那么几个SAN 缺失是其中最常见的。遇到这类报错与其在客户端那边绕来绕去不如一劳永逸地把服务端证书更新到标准格式。证书生成时多写几行 SAN后面能省下大把排查时间。我自己有一个固定的流程每签一张证书都会走一遍确认域名和 IP 列表生成配置文件指定 SAN 扩展签名后用openssl验证 SAN再放到服务器上用s_client模拟握手。整套流程五分钟都不到但能挡住九成以上的证书问题。希望这篇文章也能帮你把这条弯路绕过去。