登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go tls.ConnectionState 读取协商协议与证书信息

来源:17golang原创

时间:2026-10-04 01:04:23 327浏览 收藏

我在给 HTTPS 探测器补连接日志时,最开始只记录了状态码和耗时,遇到“为什么这台机器走 HTTP/2、另一台却退回 HTTP/1.1”时几乎没有可用线索。后来把 tls.ConnectionState 收进诊断模型,TLS 版本、密码套件、ALPN 协议、会话复用和证书信息就都能从同一个只读快照里提取。

读取时最重要的边界有两个:第一,状态只有在 TLS 握手完成后才有意义;第二,PeerCertificates 表示对端发送来的证书,VerifiedChains 才表示标准验证流程构建出的链,二者不能混为一谈。

官方文档:https://pkg.go.dev/crypto/tls

要点速览
  • HTTPS 客户端从 http.Response.TLS 读取,HTTPS 服务端从 http.Request.TLS 读取,裸 TLS 连接调用 tls.Conn.ConnectionState()。
  • tls.VersionName 和 tls.CipherSuiteName 可把数值 ID 转成可读名称。
  • NegotiatedProtocol 为空可能只是没有 ALPN 协商结果,并不等于 TLS 握手失败。
  • 返回的证书切片及证书对象应视为只读;业务层宜复制必要文本字段,而不是长期持有和修改底层对象。

先选对 ConnectionState 的读取入口

调用方不需要为了拿连接信息重新执行握手。使用 net/http 时,标准库已经把握手结果挂在请求或响应上:客户端收到的 http.Response.TLS 在非加密 HTTP 响应中为 nil;服务端处理 HTTPS 请求时,http.Request.TLS 会被填充。客户端响应中的指针可能在多个响应之间共享,不应修改。

直接使用 *tls.Conn 时,ConnectionState() 返回当前连接的状态值。若连接由 tls.Dial 或 tls.DialWithDialer 建立,返回前已经完成握手;若用 tls.Client 或 tls.Server 包装已有连接,应先调用 HandshakeContext,或者确认首次读写已经触发并完成握手。握手前字段尚未填充,不能据此下结论。

Go HTTP 客户端、HTTP 服务端和 tls.Conn 读取 ConnectionState 的静态结构图
图1:三种入口最终都提供 ConnectionState;图中是静态依赖关系,不表示实际执行步骤或运行结果。

客户端读取版本、密码套件和 ALPN

下面的示例从一次 HTTPS 响应中提取连接摘要。示例显式检查 resp.TLS,避免把普通 HTTP 响应当成 TLS 响应;版本和密码套件使用标准库名称函数输出,避免在业务代码里维护易过期的映射表。

package main

import (
	"crypto/tls"
	"fmt"
	"net/http"
	"time"
)

func main() {
	client := &http.Client{Timeout: 8 * time.Second}
	resp, err := client.Get("https://example.com/")
	if err != nil {
		panic(err) // 示例程序直接终止;服务代码应返回或分类记录错误。
	}
	defer resp.Body.Close() // 及时关闭响应体,允许连接被 Transport 复用。

	if resp.TLS == nil {
		panic("响应未携带 TLS 连接状态")
	}
	state := *resp.TLS // 复制状态值,只读取其中字段,不修改证书对象。

	protocol := state.NegotiatedProtocol
	if protocol == "" {
		protocol = "未协商 ALPN" // 空值不是握手失败,只表示没有协议结果。
	}

	fmt.Println("TLS 版本:", tls.VersionName(state.Version))
	fmt.Println("密码套件:", tls.CipherSuiteName(state.CipherSuite))
	fmt.Println("应用协议:", protocol)
	fmt.Println("会话复用:", state.DidResume)
}

NegotiatedProtocol 保存通过 ALPN 协商出的应用层协议,例如 h2。当双方没有进行 ALPN 协商,或配置中的 NextProtos 为空时,TLS 连接仍可能成功,而这个字段保持空字符串。因此业务判断应把“字段为空”和“握手错误”分开记录。

用 tls.Conn 时先把握手边界写清楚

底层连接场景常见于自定义代理、私有协议或连接探测器。我倾向于显式调用 HandshakeContext,因为超时和失败点都更清楚,后续读取的状态也有明确前提。

func inspectTLS(ctx context.Context, raw net.Conn, host string) (tls.ConnectionState, error) {
	conn := tls.Client(raw, &tls.Config{
		ServerName: host,                    // 用于 SNI 和服务端证书主机名验证。
		NextProtos: []string{"h2", "http/1.1"}, // 声明可接受的 ALPN 协议。
		MinVersion: tls.VersionTLS12,       // 示例策略:拒绝更低 TLS 版本。
	})

	if err := conn.HandshakeContext(ctx); err != nil {
		return tls.ConnectionState{}, fmt.Errorf("TLS 握手失败: %w", err)
	}
	return conn.ConnectionState(), nil // 此时 HandshakeComplete 应为 true。
}

这个函数只负责建立并检查 TLS 状态,连接所有权仍应在调用方设计中说清楚:谁关闭 raw 或 conn,谁设置读写截止时间,谁消费后续应用数据。把连接所有权和状态摘要混在一个隐式工具函数里,往往会导致重复关闭或连接泄漏。

PeerCertificates 适合看“收到什么”

PeerCertificates 按对端发送顺序保存解析后的证书,第一个元素是叶子证书。客户端连接中该切片不会为空;服务端连接中,如果没有要求或收到客户端证书,它可以为空。服务端读取到的也是客户端证书,不是服务端自己发送的证书。

type CertificateSummary struct {
	Subject   string
	Issuer    string
	DNSNames  []string
	NotBefore time.Time
	NotAfter  time.Time
}

func peerLeaf(state tls.ConnectionState) (CertificateSummary, error) {
	if len(state.PeerCertificates) == 0 {
		return CertificateSummary{}, errors.New("对端没有提供证书")
	}

	leaf := state.PeerCertificates[0] // 第一个证书是对端叶子证书。
	return CertificateSummary{
		Subject:   leaf.Subject.String(),
		Issuer:    leaf.Issuer.String(),
		DNSNames:  append([]string(nil), leaf.DNSNames...), // 复制业务需要的切片。
		NotBefore: leaf.NotBefore,
		NotAfter:  leaf.NotAfter,
	}, nil
}

日志通常只需要主题、颁发者、DNS 名称、序列号摘要和有效期。不要默认输出证书原始 DER、完整扩展或其他可能造成日志膨胀的数据。主机名判断也不应只看历史上的 Common Name 字段;标准库的正常验证会依据当前证书规则处理名称。

VerifiedChains 才表示验证流程构建的链

VerifiedChains 是一组通过验证构建出的证书链。客户端在 InsecureSkipVerify 为 false 的正常验证路径上会填充它;服务端只有在相应的客户端证书验证策略下才会填充。若禁用了默认验证,或完全由自定义逻辑处理证书,这个字段可能为 nil。

因此,下面三个判断表达的含义不同:

检查项能说明什么不能说明什么
len(PeerCertificates) > 0对端发送了至少一张证书不能单独证明证书已被信任
len(VerifiedChains) > 0标准验证路径构建出至少一条链不能代替应用自己的授权规则
HandshakeCompleteTLS 握手已经结束不能说明应用层协议或身份策略满足业务要求
Go ConnectionState 协商字段、对端证书和验证链的静态数据结构图
图2:PeerCertificates 描述对端提供的证书对象,VerifiedChains 描述验证后构建的链;这是字段关系说明图,不是证书验证结果截图。

如果应用使用 VerifyConnection 追加业务规则,应让回调返回错误来中止不符合策略的握手,而不是在连接建立后只写一条警告日志。自定义验证尤其要明确根证书池、DNS 名称、扩展密钥用途和恢复会话时的行为,不能简单地把 InsecureSkipVerify 打开后只比较某个字符串字段。

把 ConnectionState 封装成稳定的只读摘要

直接把整个 ConnectionState 交给日志层虽然方便,却会让调用方依赖越来越多底层字段。我更偏向定义一个小型摘要,只复制确实需要的标量和文本。这样能统一空值语义,也能避免后续代码修改标准库返回的证书对象。

type TLSSummary struct {
	Version        string
	CipherSuite    string
	ALPN           string
	ServerName     string
	DidResume      bool
	LeafSubject    string
	VerifiedChains int
}

func summarizeTLS(state tls.ConnectionState) TLSSummary {
	summary := TLSSummary{
		Version:        tls.VersionName(state.Version),
		CipherSuite:    tls.CipherSuiteName(state.CipherSuite),
		ALPN:           state.NegotiatedProtocol,
		ServerName:     state.ServerName,
		DidResume:      state.DidResume,
		VerifiedChains: len(state.VerifiedChains),
	}
	if len(state.PeerCertificates) > 0 {
		summary.LeafSubject = state.PeerCertificates[0].Subject.String() // 只复制可读主题。
	}
	return summary
}

是否允许 ALPN 为空、最低 TLS 版本是多少、是否必须出现验证链,都属于调用方策略,不应藏进通用摘要函数。摘要函数负责忠实表达事实,策略函数负责根据业务约束返回错误;两层拆开后,测试和日志都更容易理解。

客户端与服务端读取差异速查

场景入口证书代表谁容易误判的字段
HTTP 客户端resp.TLS服务端证书ALPN 为空不等于握手失败
HTTP 服务端r.TLS客户端证书,可能为空没有配置客户端认证时不能期待验证链
裸 TLS 连接conn.ConnectionState()连接对端证书握手完成前字段尚未填充

对我来说,ConnectionState 最有价值的地方不是“能打印多少字段”,而是把协商结果、对端身份材料和验证结果放进了同一个明确边界。只要保持只读、区分收到的证书与验证链,并在握手完成后读取,它就能成为 TLS 故障定位和策略审计里很稳定的一层。

常见问题

NegotiatedProtocol 为空是不是 HTTP/1.1?

不一定。空字符串只表示没有 ALPN 协商结果。具体应用协议要结合上层连接建立方式判断,不能仅凭该字段强行标记为 HTTP/1.1。

PeerCertificates[0] 一定可以直接读取吗?

客户端成功完成通常的服务器认证后有叶子证书,但通用代码仍应先检查长度;服务端没有请求客户端证书时,该切片可以为空。

VerifiedChains 为空是否代表证书无效?

不能直接这样判断。禁用默认验证、采用某些自定义验证路径,或服务端未配置客户端证书验证时,它都可能为空。应结合 tls.Config 和握手错误判断。

可以修改 ConnectionState 里的证书对象吗?

不应修改。标准库文档明确要求不要修改 PeerCertificates、VerifiedChains 及其内容;业务层只复制需要的字段。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>