摘要

  • 在 TLS 1.3 中,close_notify 是带认证的单向声明:发送方此后不会在该连接上发送更多 TLS 消息。它不关闭对端的发送方向,也不确认请求、响应、流或持久化副作用。
  • 底层传输在没有该告警时出现 EOF,会保留截断歧义。只有上层协议拥有独立而明确的消息完整性规则,且应用确实执行了检查,兼容策略才可能把这种 EOF 作为可接受终止。
  • 重试与归责需要四道分开保存的边界:应用消息终点、本地与对端 TLS 关闭、传输终止、业务持久结果。一个 closed=true 字段没有这些权限。

“关闭成功”回答错了问题

事故日志最后一行是 shutdown=success。运维人员起初把它理解为响应已送达、交易已完成。这个字段其实来自 TLS 封装层,只说明本地关闭流程进入了成功分支。

服务器先构造成功响应,再尝试提交状态。响应记录被写入 TLS,随后发出 close_notify。数据库在最后阶段遇到唯一性冲突,拒绝提交。客户端拿到的是一个真实且有序的密码学终点,却没有拿到提交编号。

TLS 能证明服务器选择发送的记录位于告警之前。它看不到数据库事务,更不知道远端应用是否消费了这些字节。把最后一个网络事件升级为业务权威,才是这起故障的核心。

一个连接有两个结束方向

TLS 1.3 对两个方向分别关闭。发送 close_notify 的一方承诺不再发送 TLS 消息;告警之后到达的数据必须被忽略。但它仍可继续读取,因为对端可能还有最后的数据要发,然后才关闭自己的方向。

除非已经发送错误告警,每一方都应在关闭写侧前发送 close_notify。TLS 1.3 不再要求收到告警后立即回告并丢弃待发送数据,因为旧行为可能截断接收方仍未发完的输出。

TCP 也有半关闭,但证据性质不同。FIN 只界定一个有序字节流的结束。它不是带认证的 TLS 告警,不能指出最后一个完整 TLS 记录,也不理解应用消息。

EOF 留下的不是结论,而是歧义

底层传输在 close_notify 之前关闭时,接收方无法判断发送方原本打算发送的数据是否全部到达。原因可能是进程崩溃、代理超时、旧实现不兼容,也可能是主动截断。缺少告警不等于攻击成立,但意味着更强的密码学边界不存在。

致命告警、RST、超时、意外 EOF、收到关闭告警和本地尝试发送告警,也必须是不同状态。若全部显示为“断开”,调查者将失去判断消息能否使用、操作能否重试所需的顺序和方向。

IANA 把告警值 0 分配给 close_notify。这能证明代码点和规范引用,不能证明某条生产连接确实发出或收到它,更不能证明告警之前的应用消息完整。

TLS 不知道一条消息在哪里结束

受到保护的记录先于 close_notify 到达,只能说明它们与告警之间的顺序。对 TLS 而言,内容是不透明字节;它不知道里面是完整文档、最终响应、提交凭证,还是半条结构化消息。

应用协议必须拥有自己的终点,例如声明长度、终止分块、流 FIN、最终状态码、确认消息、请求编号或持久化记录。哪一种有效,由上层协议决定。完整响应仍可能报告失败;成功操作也可能在响应返回前就已提交。

缓冲区还会增加中间状态。一次写调用被接受,可能只表示字节进入 TLS 或 BIO 缓冲。非阻塞程序也可能先记录“已尝试发送告警”,随后才真正把字节交给传输层。这些事件都不构成数据库提交证据。

OpenSSL 的返回值是一条状态序列

SSL_shutdown() 首次返回 0,通常表示本地 close_notify 已发出,而对端告警仍未收到。这不是错误,也不是双向完成。返回 1 才表示双方告警均已发送和接收。

第一步关闭 TLS 写侧,读侧仍可工作,底层 TCP 也仍然打开。OpenSSL 建议继续读取,以处理最后的应用数据和握手后消息。若待处理应用数据尚未排空就再次调用关闭,操作可能失败。

内部标志同样要按原义保存:sent 记录发送尝试,received 记录对端告警。quiet shutdown 只是把本地状态标成关闭,不发送告警,因此不符合协议。OpenSSL 3.0 起,意外 EOF 有明确错误含义;只有应用协议能无歧义发现截断并真正执行检查时,SSL_OP_IGNORE_UNEXPECTED_EOF 才有成立条件。

GnuTLS 用 GNUTLS_SHUT_WR 和 GNUTLS_SHUT_RDWR 区分只关写侧与等待对端。BoringSSL 的运行状态机也分别跟踪读写关闭。库提供了可区分的事实,扁平监控却可能再次把它们抹掉。

HTTP 展示了上层边界

HTTP/1.1 有自己的完整性规则。Content-Length 要求收到声明数量的字节;chunked 编码要求终止的零长度分块。任一条件不满足,连接关闭都不能把响应变成完整消息。

只靠关闭界定长度的响应风险更高。它的消息终点依赖连接正确结束,所以不完整的 TLS 关闭会使 HTTP 响应也不完整。RFC 9112 建议使用明确长度或编码,正是因为网络故障可能伪装成正常结束。

若声明长度或终止分块已经核验,即使随后缺少对端告警,该消息边界仍是有效证据。这也是忽略某些 EOF 的精确前提:应用必须逐条证明它准备采用的消息有独立终点,而不是只打开一个兼容选项。

多路复用还需要请求范围

HTTP/2 在同一 TLS 连接上承载多条流。close_notify 无法指出服务器已经开始处理哪些请求。GOAWAY 提供最后流编号的边界:更高编号可以重试,边界内的请求仍可能已经执行。

若连接在没有 GOAWAY 时结束,一条在途的非幂等 POST 仍处于不确定状态。HTTP/3 在 QUIC 之上采用同样思路,以自己的 GOAWAY 限定已接受请求范围。连接关闭不会凭空生成请求级确认。

把所有“正常关闭”前的请求都重试,可能造成双重扣款;一律不重试,也可能丢失工作。重试权限来自方法语义、幂等键、结果查询和对账,而不是“优雅关闭”这个形容词。

QUIC 使用另一套终止机制

QUIC 承载 TLS 握手字节,却不用 TLS 记录保护应用数据。TLS 告警被转换为 QUIC 连接错误;QUIC 自己拥有 CONNECTION_CLOSE、流 FIN、reset、closing 和 draining 状态。TLS close_notify 的 warning 语义不能搬到 QUIC。

HTTP/3 仍需要 GOAWAY,因为 QUIC 连接终止也不能单独说明哪些请求被接受。指标必须写明实际载体:TLS 告警、TCP FIN 或 RST、QUIC 流 FIN、CONNECTION_CLOSE、空闲超时、HTTP GOAWAY 或应用确认。

让结束能够被审计

每条连接应记录端点角色、方向、TLS 版本、关联标识,以及最后一条完整应用消息或流。保存消息边界规则、预期与实收字节数、请求编号、幂等键和重试类别。

TLS 侧要区分本地告警入队、实际发出、对端告警收到,保存库返回序列、读写关闭标志、待处理明文或密文和准确错误分类。quiet 模式、EOF 策略、库版本、kTLS 与代理边界也应进入证据。

传输侧分别记录 FIN、RST、EOF、超时和半关闭;多路协议保存 GOAWAY 范围及逐流终点;业务侧保存确认、提交编号、持久时间戳和签发这些事实的权威系统。

负向测试要覆盖:告警前切断 TCP、删掉 HTTP 终止分块、完整帧后但提交前关闭、首次返回 0、对端数据未读、quiet shutdown、切换 EOF 兼容策略,以及 HTTP/2 或 HTTP/3 中非幂等请求在途时断开。

来源