Rust in Everything
基于 rustls 0.23.46

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 之后直接 drop TlsStream。数据一字节不少(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 就跳过了这一步。

📝NOTE
没有蠢问题

问: 是不是只有对端不是 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;靠关连接分界的协议不能忽略它。

💬 关联讨论

发起讨论
加载中...