在HTTPS通信中,证书是否覆盖某个域名,判断依据早就不是Common Name了,而是SAN(Subject Alternative Name,主题备用名)扩展。一张证书的SAN里可能同时包含几十个域名和若干IP地址,用Ruby自带的OpenSSL标准库可以把这些信息完整解析出来。这篇文章围绕OpenSSL::X509::Certificate,详细介绍如何提取并拆解SAN扩展中的DNS条目和IP地址。

为什么证书校验要看SAN而不是CN
早期证书只靠subject里的Common Name字段标识域名,浏览器拿访问的域名和CN比对即可。但随着一张证书需要承载多个域名、通配符域名甚至IP地址,CN的表达能力明显不够了。RFC 2818之后,主流客户端(包括Chrome、Firefox和各类HTTP库)都把SAN作为唯一的域名匹配依据,如果证书没有SAN扩展,即使CN完全一致,校验也会失败。
用Ruby可以直接观察这个差异。CN在证书的subject字段中:
require 'openssl'
raw = File.read('server.crt')
cert = OpenSSL::X509::Certificate.new(raw)
# 取出CN
cn = cert.subject.to_a.find { |name, _, _| name == 'CN' }
puts cn[1] # 例如输出 ippipp.com
而SAN则存放在扩展区。通过cert.extensions可以拿到所有扩展,每个扩展都是一个OpenSSL::X509::Extension对象,包含扩展名、值和critical标记。SAN对应的扩展名是subjectAltName,它的value是一段DER编码后再转为文本的内容,形如DNS:ippipp.com, DNS:*.ippipp.com, IP Address:192.168.1.1。解析工作的核心就是把这段字符串结构化。
提取并拆解SAN扩展的完整代码
最直接的做法是遍历extensions,找到目标扩展后按逗号切分value,再根据前缀分类。每一条SAN记录由类型和值组成,常见类型有DNS、IP Address、email、URI等。下面这段代码把DNS和IP分别收集到数组里:
require 'openssl'
def parse_san(cert)
san_ext = cert.extensions.find { |ext| ext.oid == 'subjectAltName' }
return { dns: [], ips: [] } unless san_ext
dns = []
ips = []
san_ext.value.split(', ').each do |entry|
type, value = entry.split(':', 2)
case type.strip
when 'DNS' then dns << value.strip
when 'IP Address' then ips << value.strip
end
end
{ dns: dns, ips: ips }
end
cert = OpenSSL::X509::Certificate.new(File.read('server.crt'))
result = parse_san(cert)
puts "DNS域名: #{result[:dns].join(', ')}"
puts "IP地址: #{result[:ips].join(', ')}"
这里有几个细节值得注意。split(',', 2)这种写法是错的用法要避免,正确的做法是对每个条目用split(':', 2)限制只切一次,因为IP地址里可能残留冒号形式的IPv6文本。另外分隔符建议用', '(带空格),OpenSSL生成的value条目之间就是逗号加空格。
IPv6是常见的坑。在SAN中,IPv6地址的十六进制字节会以冒号连接显示,例如IP Address:FD00:0:0:0:0:0:0:1,直接按冒号切割会把它截断。所以解析时必须用限制分割次数的方式,或者干脆改用更严谨的ASN.1方式(下文会讲)。对于只需DNS和IPv4的场景,上面的简化版已经够用。
用ASN.1解码获取结构化数据
文本切分终究是权宜之计,更可靠的方式是对扩展值做ASN.1解码。SAN在DER结构里是一个GeneralNames序列,每个GeneralName带上下文标签:tag 2是dNSName,tag 7是iPAddress。借助OpenSSL::ASN1.decode可以把扩展的原始字节还原成结构树:
require 'openssl'
def parse_san_asn1(cert)
ext = cert.extensions.find { |e| e.oid == 'subjectAltName' }
return { dns: [], ips: [] } unless ext
seq = OpenSSL::ASN1.decode(ext.to_der)
dns, ips = [], []
seq.value.first.value.each do |name|
case name.tag
when 2 then dns << name.value.force_encoding('UTF-8')
when 7
bytes = name.value.unpack('C*')
ip = if bytes.size == 4
bytes.join('.')
else
# IPv6:每两个字节转十六进制,冒号连接
bytes.each_slice(2).map { |p| p.sum { |b| b }.to_s(16) }.join(':')
end
ips << ip
end
end
{ dns: dns, ips: ips }
end
ext.to_der返回扩展的DER编码字节,解码后得到的是Extension的外层结构,取第一层value才是GeneralNames序列。tag 7的iPAddress是原始字节串,IPv4占4字节,IPv6占16字节,需要手动还原成点分十进制或冒号十六进制。上面的IPv6还原逻辑是简化写法,严格实现应把每两个字节按大端拼成一个16位段再转十六进制。
ASN.1方式的好处是完全不依赖value的文本格式,无论OpenSSL版本如何变化、条目里是否包含逗号或特殊字符,都能稳定解析。缺点是代码更繁琐,需要理解标签结构。如果只是做证书信息展示,文本切分够用;如果是写自动化校验工具,建议直接上ASN.1方案。
实际应用中的注意事项
解析证书时别忘了处理异常。证书文件损坏、编码不对或者不是X509格式,OpenSSL::X509::Certificate.new会抛出OpenSSL::X509::CertificateError,批量处理时最好用rescue包住单个文件的处理逻辑,避免一张坏证书中断整个任务:
Dir.glob('certs/*.crt').each do |path|
begin
cert = OpenSSL::X509::Certificate.new(File.read(path))
san = parse_san(cert)
puts "#{path}: #{san[:dns].size}个DNS, #{san[:ips].size}个IP"
rescue OpenSSL::X509::CertificateError => e
warn "跳过无效证书 #{path}: #{e.message}"
end
end
另一个实用场景是从线上服务器直接抓证书。配合TCPSocket和OpenSSL::SSL::SSLSocket可以在握手阶段拿到对端证书,不需要本地文件。做证书到期监控或者域名覆盖检查时,这个组合非常顺手:解析出SAN后,和你维护的域名清单求差集,就能发现哪些域名即将失去证书保护。
还要提醒一点,通配符域名(如*.ippipp.com)会原样出现在DNS条目里,如果你的校验逻辑要匹配通配符,需要自己实现*.前缀的匹配规则,OpenSSL本身不会替你做这层转换。把解析、匹配、告警拆成独立方法,代码会清晰很多。
Ruby OpenSSLX509证书SAN扩展修改时间:2026-09-12 02:56:34