前一阵帮同事排查一个内部服务的 HTTPS 连接问题,日志里反反复复出现同一行:verify certificate: x509: certificate relies on legacy Common Name field, use SANs instead。乍一看像证书过期,其实根本不是。这个报错的意思是,客户端在验证证书时发现,对方证书只设置了 Common Name(CN),没有可用的 Subject Alternative Name(SANs)扩展,于是拒绝继续通信。很多自签名证书、内部系统证书都会栽在这一条上,尤其是最近升级过 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: C=CN, ST=Beijing, L=Beijing, O=Example Inc, OU=IT, CN=myserver.com这里的CN=myserver.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 "/CN=myserver.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 "/CN=myserver.com" \ -addext "subjectAltName=DNS:myserver.com,DNS:www.myserver.com,IP:127.0.0.1"这条命令里,-subj仍然写上 CN,主要是为了让证书主体信息完整;真正起作用的是-addext "subjectAltName=DNS: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_req,OpenSSL 可能不会把 SAN 扩展写入最终的证书,这也是一个很隐蔽的坑。我在实际使用中还会在配置文件里加上basicConstraints=CA:FALSE和keyUsage=digitalSignature,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,即使指定了--cacert,curl 在某些较新版本里也一样会报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模拟握手。整套流程五分钟都不到,但能挡住九成以上的证书问题。希望这篇文章也能帮你把这条弯路绕过去。