在 Go 里排查 HTTPS 证书时,最容易混淆的是三个问题:证书声明了哪些名字、当前主机名是否命中这些名字、证书链是否被当前信任根接受。x509.Certificate.DNSNames 只回答第一个问题的一部分,它保存的是 Subject Alternative Name(SAN)里的 DNS 名称集合。
因此,读取 DNSNames 适合做诊断、展示和配置对照;要判断一个主机名是否真的匹配证书,优先调用 Certificate.VerifyHostname。如果还要确认签发链和信任根,再使用 Certificate.Verify,通过 VerifyOptions.DNSName 把名称校验接入完整验证。
DNSNames 到底表示什么
ParseCertificate 把 DER 编码解析成 *x509.Certificate 后,SAN 中的 DNS 名称会出现在 DNSNames []string,IP 地址则出现在 IPAddresses []net.IP。二者不是同一个集合:用 IP 访问服务时,不能把 IP 当成字符串塞进 DNSNames 再自己比较。
常见的 CommonName 也不能当作当前主机名匹配入口。Go 文档明确说明,VerifyHostname 会检查 DNSNames 或 IPAddresses,并忽略传统 Common Name。这样做可以避免把一个历史字段误当成证书当前声明的 SAN。
先解析 PEM,再把 DNSNames 用于诊断
实际证书通常以 PEM 保存。解析时先确认 PEM 块存在、类型正确,再把块内 DER 交给 x509.ParseCertificate。下面的函数只负责读取证书和列出声明的 DNS 名称,不把“字段存在”误报为“证书可信”。
package main
import (
"bytes"
"crypto/x509"
"encoding/pem"
"fmt"
)
// parseCertificate 从 PEM 中提取单张证书,拒绝空块和多余内容。
func parseCertificate(pemBytes []byte) (*x509.Certificate, error) {
block, rest := pem.Decode(pemBytes)
if block == nil || block.Type != "CERTIFICATE" {
return nil, fmt.Errorf("没有找到 CERTIFICATE PEM 块")
}
// 只接受一张证书,避免把后续证书误当成当前对象的一部分。
if len(bytes.TrimSpace(rest)) != 0 {
return nil, fmt.Errorf("PEM 中包含未处理的额外内容")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("解析 DER 证书失败: %w", err)
}
return cert, nil
}
// printDNSNames 只用于诊断证书声明,不代表已经完成信任校验。
func printDNSNames(cert *x509.Certificate) {
fmt.Println("DNSNames:")
for _, name := range cert.DNSNames {
fmt.Println(" -", name)
}
fmt.Println("IPAddresses:", cert.IPAddresses)
}
这里打印出来的每一项是证书中声明的名称。它可以帮助你回答“证书里有没有这个域名”,也可以用于排查签发配置是否漏了某个 SAN。但不要据此自行实现一套通配符、大小写、IP 和异常证书处理逻辑。
不要手写字符串比较,使用 VerifyHostname
如果目标是判断“证书能不能用于这个主机名”,直接调用 VerifyHostname 更稳妥。官方实现会区分 DNS 名称和 IP 地址;DNS 名称比较不区分大小写,通配符只能作为完整的最左侧标签,例如 *.example.com 可以覆盖一个子域标签,但不能把任意多级域名都当成命中。
package main
import (
"crypto/x509"
"errors"
"fmt"
)
// matchHost 只判断证书声明的名称是否匹配目标主机,不建立信任链。
func matchHost(cert *x509.Certificate, host string) error {
if err := cert.VerifyHostname(host); err != nil {
var hostnameErr x509.HostnameError
// HostnameError 能保留目标主机和证书对象,便于记录排查上下文。
if errors.As(err, &hostnameErr) {
return fmt.Errorf("主机名 %q 未命中证书: %w", hostnameErr.Host, err)
}
return fmt.Errorf("主机名匹配失败: %w", err)
}
return nil
}
这个函数返回 nil,只能说明名称匹配成功;它不等价于“证书来自受信任 CA”,也不等价于“证书仍在有效期内”。把名称匹配和信任判断拆开,日志才能准确表达故障所在。
| 输入或字段 | 匹配位置 | 应该怎么理解 |
|---|---|---|
api.example.com | DNSNames | 按 DNS 名称规则匹配 |
192.0.2.10 | IPAddresses | 按 IP 地址匹配,不是 DNSNames |
*.example.com | DNS 通配符 | 只覆盖完整的最左侧标签 |
Subject.CommonName | 不作为 VerifyHostname 入口 | 不能替代 SAN 校验 |
把名字匹配接入真正的证书校验
客户端需要确认 HTTPS 对端身份时,通常还要检查证书链。此时使用 VerifyOptions.DNSName 指定目标主机名,使用 Roots 指定信任根;中间证书可以放进 Intermediates。Certificate.Verify 返回成功,才表示名称匹配和链路验证在这组选项下都通过。
package main
import (
"crypto/x509"
"fmt"
)
// verifyIdentity 同时要求目标主机名匹配,并且证书链能连接到信任根。
func verifyIdentity(cert *x509.Certificate, host string, roots, intermediates *x509.CertPool) error {
opts := x509.VerifyOptions{
DNSName: host,
Roots: roots,
Intermediates: intermediates,
}
// Verify 会把 DNSName 纳入证书验证,不要只检查 DNSNames 切片。
if _, err := cert.Verify(opts); err != nil {
return fmt.Errorf("证书身份校验失败: %w", err)
}
return nil
}
在生产代码里,roots 应来自明确的信任策略,不能为了让请求通过而随意关闭验证或使用空的信任集合。若只是在证书管理页面展示域名,读取 DNSNames 就够了;若要决定是否信任网络对端,则应走完整的 Verify 路径。
排查 DNSNames 时最容易踩的坑
- 把 CommonName 当成备用域名。 当前 Go 的主机名校验忽略传统 Common Name,应该回到 SAN 的 DNSNames 或 IPAddresses。
- 用字符串前缀模拟通配符。
strings.HasSuffix不能表达完整左侧标签边界,也不能处理 IP 地址。 - 把名称命中当成链路可信。
VerifyHostname只处理名称匹配;信任根、有效期和用途等还要由Verify统一判断。 - 忽略解析错误。 PEM 类型错误、DER 解析失败和证书字段异常都应保留原始错误上下文,便于区分输入损坏和名称不匹配。
结论
x509.Certificate.DNSNames 是证书 SAN 中的 DNS 名称集合,适合观察证书声明了哪些域名。要判断主机名是否覆盖,调用 VerifyHostname;要判断证书是否可信,再把 DNSName、Roots 和中间证书交给 Certificate.Verify。这三层职责分开后,证书排查日志会更准确,代码也不会因为自己实现通配符或 IP 比较而引入边界错误。
相关问题
DNSNames 为空是不是证书一定无效?
不应只凭这个切片下结论。它只表示解析对象当前没有可展示的 DNS 名称;还要结合证书用途、IPAddresses、解析错误和完整 Verify 结果判断。
访问 IP 时应该读取哪个字段?
读取 Certificate.IPAddresses,或直接把 IP 传给 VerifyHostname。不要把 IP 文本与 DNSNames 逐项比较。
为什么 DNSNames 命中了,Verify 仍然失败?
名称匹配只是一个条件。信任根缺失、中间证书不完整、有效期、用途或签名链不满足时,完整验证仍会返回错误。