peer closed connection without sending TLS close_notify
主线位置:记录主线的末尾。
read_tls读到 0 字节(TCP EOF)时,rustls 记下has_seen_eof;reader().read读完缓冲的明文之后,看对方之前有没有发过close_notify告警,决定返回Ok(0)还是UnexpectedEof。Stream上的复现和Stream曾经把它当正常结束的历史见 sans-IO。
flowchart TB
eof["read_tls 返回 0"] --> buf{"还有明文"}
buf -->|有| data["先交出明文"]
buf -->|没有| cn{"收到过 close_notify"}
data --> cn
cn -->|是| ok["Ok 0 正常结束"]
cn -->|否| ue["UnexpectedEof"]
ue --> framed{"协议自带长度"}
framed -->|消息已读完| eofok["可当 EOF"]
framed -->|没读完| trunc["截断 报错"]
实验 crate:exp-rustls/pita-lab,src/bin/p07_eof.rs(tokio-rustls 0.26.6,两端都在 127.0.0.1)、src/bin/p07_client.rs(对本机 openssl s_server)。M2 Max、macOS 27、rustc 1.99.0,release,ring provider,TLS 1.3。
现象
服务端用 tokio-rustls 发一条带 4 字节长度前缀的消息(hello, world,共 16 字节),然后结束。客户端 read_to_end。三种结束方式:
Drop: read_to_end -> Err(Custom { kind: UnexpectedEof, error: "peer closed connection without sending TLS close_notify: https://docs.rs/rustls/latest/rustls/manual/_03_howto/index.html#unexpected-eof" }), got 16 bytes
kind UnexpectedEof, Display: peer closed connection without sending TLS close_notify: https://docs.rs/rustls/latest/rustls/manual/_03_howto/index.html#unexpected-eof
Shutdown: read_to_end -> Ok(16), got 16 bytes
DropMidMessage: read_to_end -> Err(Custom { kind: UnexpectedEof, error: "peer closed connection without sending TLS close_notify: https://docs.rs/rustls/latest/rustls/manual/_03_howto/index.html#unexpected-eof" }), got 9 bytes
kind UnexpectedEof, Display: peer closed connection without sending TLS close_notify: https://docs.rs/rustls/latest/rustls/manual/_03_howto/index.html#unexpected-eof
- Drop:
write_all、flush之后直接 dropTlsStream。数据一字节不少(16 字节),但read_to_end返回错误。用?传播的代码会把一次成功的交换当成失败。 - Shutdown:drop 之前
tls.shutdown().await。read_to_end正常返回Ok(16)。 - DropMidMessage:只发了长度前缀和 5 字节正文就 drop。错误和 Drop 一模一样,这次数据确实不完整(9 字节)。
对 rustls 来说,Drop 和 DropMidMessage 无法区分:两者都是「收到一些数据之后,TCP 关了,没有 close_notify」。
原因
Reader::read 先交出缓冲里的明文,没有明文了才看连接是怎么结束的,check_no_bytes_state:
match (self.has_received_close_notify, self.has_seen_eof) {
// cleanly closed; don't care about TCP EOF: express this as Ok(0)
(true, _) => Ok(()),
// unclean closure
(false, true) => Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
UNEXPECTED_EOF_MESSAGE,
)),
// connection still going, but needs more data: signal `WouldBlock` so that
// the caller knows this
(false, false) => Err(io::ErrorKind::WouldBlock.into()),
}
TCP 的 FIN 没有完整性保护,路径上的任何一方都能伪造。close_notify 是一条加密、带认证的告警,只有持有密钥的对端能发。所以只有收到它,rustls 才能确认「对方说完了」;只见到 TCP EOF,就可能是有人在中间截断了连接(截断攻击,truncation attack)。rustls 选择把这种情况报给应用,由应用判断。
为什么 Drop 不发 close_notify:rustls 的连接对象不碰 IO,drop 时没有机会写任何东西。告警要靠 send_close_notify 排进队列、再由 write_tls 交出去。tokio-rustls 0.26 把这两步放在 poll_shutdown 里:shutdown() 调 send_close_notify,再把它冲到套接字。只 drop 就跳过了这一步。
问: 是不是只有对端不是 rustls 时才会遇到这个错误?
答: 不是,取决于对端程序关连接的方式,和用哪个 TLS 库无关。上面的 Drop 两端都是 rustls。反过来,本机 Homebrew 的 OpenSSL 4.1.0 起一个 openssl s_server -www,用 rustls 的 StreamOwned 发 GET / HTTP/1.0 再 read_to_end:
read_to_end -> Ok(4610), 4610 bytes, peer_has_closed via close_notify: true
s_server 回完页面先发了 close_notify 再关,rustls 这边是干净的 Ok。
修法
发送方:关之前发 close_notify。
- tokio-rustls:
stream.shutdown().await,上面的 Shutdown 就是这样改的。 - 同步代码:
conn.send_close_notify(),然后complete_io(或write_tls直到wants_write()为假),再关套接字;sans-IO 里的Stream实验加了这两行之后,客户端得到Ok(4)。
接收方:协议自带长度时,按长度读,把多余的 UnexpectedEof 当 EOF。 manual/howto.rs 的「Unexpected EOF」一节给了判断标准:空闲连接上的 UnexpectedEof 可以当正常关闭;应用层消息总是自带长度、从不靠关连接来分界时,可以无条件忽略它。实验里同样两种服务端,客户端改成先读 4 字节长度、再 read_exact 正文:
framed Drop: complete message "hello, world"
framed DropMidMessage: truncated: UnexpectedEof / peer closed connection without sending TLS close_notify: https://docs.rs/rustls/latest/rustls/manual/_03_howto/index.html#unexpected-eof
完整的消息照常拿到,被截断的消息仍然报错。区分两者的是应用层的长度,不是 TLS。HTTP/1.1 带 Content-Length 或分块编码、HTTP/2、各种带长度前缀的二进制协议都属于这一类。
不能忽略的情况:协议用「关连接」来标记消息结束(HTTP/1.0 没有 Content-Length 的响应、读到 EOF 为止的文件传输)。这时 UnexpectedEof 就是唯一能发现截断的信号,应当当作失败。
怎么检查
- 看错误的 kind。
e.kind() == io::ErrorKind::UnexpectedEof且Display以peer closed connection without sending TLS close_notify开头,就是本页的情况。kind 是UnexpectedEof但消息不同的(比如complete_io握手中途遇到 EOF 返回的那个不带消息的UnexpectedEof),是另一回事:握手没完成连接就断了。 - 数一数收到了多少。 出错时缓冲里的明文已经全部交给你了。和应用层期望的长度一比,就知道是「完整但没告别」还是「真的截断」。
- 检查自己这一端。 搜代码里
TlsStream/StreamOwned被 drop 的地方,看前面有没有shutdown()或send_close_notify()。自己的服务端不发,对端如果也是 rustls,就会在日志里看到这个错误。
小结
- 没收到
close_notify就见到 TCP EOF,rustls 在交出全部明文之后返回UnexpectedEof,消息是peer closed connection without sending TLS close_notify: ...。 - 原因是 TCP EOF 无法认证,可能是截断;rustls 区分不了「说完了没告别」和「被截断」。
- 发送方:tokio-rustls 调
shutdown(),同步代码send_close_notify()加一次write_tls;只 drop 不会发。 - 接收方:协议自带长度时按长度读,读完整条消息后的
UnexpectedEof可以当 EOF;靠关连接分界的协议不能忽略它。