From 541b37ad7d53b6926c290b85715f6d748b73abd5 Mon Sep 17 00:00:00 2001 From: therealaleph Date: Fri, 1 May 2026 15:38:45 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20v1.9.5=20=E2=80=94=20exit-node=20tolerat?= =?UTF-8?q?es=20TLS=20close=20without=20close=5Fnotify=20(#585)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #585 from @gregtheph: v1.9.4's exit-node feature failed for every ChatGPT/Claude/Grok request with `io: peer closed connection without sending TLS close_notify` and fell back to direct Apps Script (which can't reach those sites either, producing the no-json error chain). Root cause: rustls is strict about TLS shutdown — when the peer (val.town's host) closes the underlying TCP without first sending a TLS close_notify alert, rustls surfaces this as `io::ErrorKind::UnexpectedEof`. Our read_http_response propagated this as a hard error, even when the body was already complete per Content-Length. Fix: treat UnexpectedEof the same as `n == 0` (graceful EOF). If Content-Length is satisfied, return the response; if mid-body truncation, still error as BadResponse. Same handling added to the chunked reader and the no-framing reader. 4 new regression tests: - read_http_response_tolerates_unexpected_eof_with_content_length - read_http_response_tolerates_unexpected_eof_no_framing - parse_exit_node_response_unwraps_valtown_envelope - parse_exit_node_response_surfaces_explicit_error 173 lib tests + 33 tunnel-node tests + both release builds passing. --- Cargo.lock | 2 +- Cargo.toml | 2 +- docs/changelog/v1.9.5.md | 4 + src/domain_fronter.rs | 157 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 docs/changelog/v1.9.5.md diff --git a/Cargo.lock b/Cargo.lock index 5d3797a..3b8667f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2222,7 +2222,7 @@ dependencies = [ [[package]] name = "mhrv-rs" -version = "1.9.4" +version = "1.9.5" dependencies = [ "base64 0.22.1", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 5c51d93..c3cc2d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mhrv-rs" -version = "1.9.4" +version = "1.9.5" edition = "2021" description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting" license = "MIT" diff --git a/docs/changelog/v1.9.5.md b/docs/changelog/v1.9.5.md new file mode 100644 index 0000000..966237d --- /dev/null +++ b/docs/changelog/v1.9.5.md @@ -0,0 +1,4 @@ + +• fix exit-node v1.9.4: مدارا با TLS ungraceful close (peer closed without close_notify) که val.town از Apps Script عبور می‌دهد ([#585](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/585) از @gregtheph): در v1.9.4، کاربری که val.town رو با درست‌ترین config setup کرد، در log می‌دید `WARN exit node failed for https://chatgpt.com/: io: peer closed connection without sending TLS close_notify — falling back to direct Apps Script` + سپس fallback به Apps Script که خود نمی‌تونه ChatGPT رو reach کنه، در نتیجه decoy/no-json error. علت: rustls سختگیر است درباره‌ی TLS shutdown — وقتی peer (val.town) underlying TCP رو می‌بنده بدون اول send کردن TLS close_notify alert، rustls `io::ErrorKind::UnexpectedEof` می‌فرسته. کد ما در `read_http_response` این error رو propagate می‌کرد به‌عنوان hard error. حالا UnexpectedEof به‌صورت graceful EOF (مشابه `n == 0`) درمان می‌شه — اگر body completed شده با Content-Length، response درست برمی‌گرده. اگر mid-body close بود، error real (truncation) همچنان propagate می‌شه. ۴ regression test جدید (شامل UnexpectedEof tolerance + envelope unwrap valtown). 173 lib tests + 33 tunnel-node tests pass. +--- +• Fix v1.9.4 exit-node: tolerate ungraceful TLS close (peer closed without close_notify) on the val.town path ([#585](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/585) by @gregtheph): in v1.9.4, users with a correctly-configured val.town deployment saw `WARN exit node failed for https://chatgpt.com/: io: peer closed connection without sending TLS close_notify — falling back to direct Apps Script` in the log, followed by a fallback to direct Apps Script which can't reach ChatGPT either, resulting in the decoy/no-json error. Root cause: rustls is strict about TLS shutdown — when the peer (val.town's host) closes the underlying TCP without first sending a TLS close_notify alert, rustls surfaces this as `io::ErrorKind::UnexpectedEof`. Our code in `read_http_response` was propagating this as a hard error rather than treating it as graceful EOF. Now `UnexpectedEof` is handled like `n == 0`: if the body has been fully received per Content-Length, the response returns successfully; if it's a real mid-body truncation, the error still propagates as `BadResponse`. Same handling added to the chunked reader and the no-framing reader. Four regression tests cover the new behavior (UnexpectedEof tolerance for Content-Length and no-framing branches + val.town envelope unwrap success and error paths). 173 lib tests + 33 tunnel-node tests passing. diff --git a/src/domain_fronter.rs b/src/domain_fronter.rs index 06f2b7f..9dd67f0 100644 --- a/src/domain_fronter.rs +++ b/src/domain_fronter.rs @@ -2482,8 +2482,27 @@ where while body.len() < cl { let need = cl - body.len(); let want = need.min(tmp.len()); - let n = timeout(Duration::from_secs(20), stream.read(&mut tmp[..want])).await - .map_err(|_| FronterError::Timeout)??; + // Handle ungraceful TLS close-without-close_notify (rustls + // surfaces this as `io::ErrorKind::UnexpectedEof`). Some + // origins — notably val.town's exit-node path through Apps + // Script (#585, v1.9.4) and certain Apps Script `Connection: + // close` responses — terminate the underlying TCP without + // sending the TLS close_notify alert first. Treat that the + // same as a clean `n == 0`: if we already have the full body + // declared by Content-Length, the response *is* complete. + // Only propagate the error if Content-Length couldn't be + // satisfied (real truncation, not a polite-protocol violation). + let read_res = timeout( + Duration::from_secs(20), + stream.read(&mut tmp[..want]), + ) + .await + .map_err(|_| FronterError::Timeout)?; + let n = match read_res { + Ok(n) => n, + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => 0, + Err(e) => return Err(e.into()), + }; if n == 0 { return Err(FronterError::BadResponse( "connection closed before full response body".into(), @@ -2492,11 +2511,17 @@ where body.extend_from_slice(&tmp[..n]); } } else { - // No framing — read until short timeout. + // No framing — read until short timeout, EOF, or ungraceful + // TLS close (UnexpectedEof). Each is treated as "we got what + // the peer wanted to send"; the response we already have is + // returned to the caller. UnexpectedEof here is the most common + // case for `Connection: close` responses from servers that + // don't bother with TLS close_notify (#585). loop { match timeout(Duration::from_secs(2), stream.read(&mut tmp)).await { Ok(Ok(0)) => break, Ok(Ok(n)) => body.extend_from_slice(&tmp[..n]), + Ok(Err(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, Ok(Err(e)) => return Err(e.into()), Err(_) => break, } @@ -2542,8 +2567,18 @@ where } } while buf.len() < size + 2 { - let n = timeout(Duration::from_secs(20), stream.read(&mut tmp)).await - .map_err(|_| FronterError::Timeout)??; + // UnexpectedEof tolerance — see read_http_response for + // rationale. Treated as `n == 0`; if we haven't accumulated + // the full chunk yet, that's still a real truncation and + // we return BadResponse below. + let read_res = timeout(Duration::from_secs(20), stream.read(&mut tmp)) + .await + .map_err(|_| FronterError::Timeout)?; + let n = match read_res { + Ok(n) => n, + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => 0, + Err(e) => return Err(e.into()), + }; if n == 0 { return Err(FronterError::BadResponse( "connection closed mid-chunked response".into(), @@ -2899,7 +2934,117 @@ impl ServerCertVerifier for NoVerify { #[cfg(test)] mod tests { use super::*; - use tokio::io::{duplex, AsyncWriteExt}; + use std::pin::Pin; + use std::task::{Context, Poll}; + use tokio::io::{duplex, AsyncRead, AsyncWriteExt, ReadBuf}; + + // Test fixture for ungraceful TLS close: emit a fixed prefix of bytes + // then return io::ErrorKind::UnexpectedEof on the next read. Mirrors + // what rustls surfaces when the peer closes TCP without sending a + // TLS close_notify alert (#585). + struct UnexpectedEofAfter { + bytes: Vec, + position: usize, + } + + impl AsyncRead for UnexpectedEofAfter { + fn poll_read( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if self.position >= self.bytes.len() { + return Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "peer closed connection without sending TLS close_notify", + ))); + } + let remaining = &self.bytes[self.position..]; + let take = remaining.len().min(buf.remaining()); + buf.put_slice(&remaining[..take]); + self.position += take; + Poll::Ready(Ok(())) + } + } + + #[tokio::test] + async fn read_http_response_tolerates_unexpected_eof_with_content_length() { + // Issue #585 / v1.9.4 exit-node bug. Some peers (val.town in + // particular, certain Apps Script `Connection: close` paths) close + // the TCP without TLS close_notify. Body should still be returned + // when Content-Length is satisfied, even though the read after + // the body closes ungracefully. + let body = b"{\"ok\":true}"; + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let mut full = header.into_bytes(); + full.extend_from_slice(body); + let mut stream = UnexpectedEofAfter { + bytes: full, + position: 0, + }; + + let (status, _headers, got_body) = + read_http_response(&mut stream).await.expect("must succeed despite UnexpectedEof"); + assert_eq!(status, 200); + assert_eq!(got_body, body); + } + + #[tokio::test] + async fn read_http_response_tolerates_unexpected_eof_no_framing() { + // Same #585 fix, but for the no-framing branch (server didn't + // send Content-Length or Transfer-Encoding). Read until peer + // closes — UnexpectedEof should terminate the loop with the + // body we accumulated so far, not bubble up as an error. + let header = b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n"; + let body = b"hello world"; + let mut full = header.to_vec(); + full.extend_from_slice(body); + let mut stream = UnexpectedEofAfter { + bytes: full, + position: 0, + }; + + let (status, _headers, got_body) = + read_http_response(&mut stream).await.expect("must succeed despite UnexpectedEof"); + assert_eq!(status, 200); + assert_eq!(got_body, body); + } + + #[tokio::test] + async fn parse_exit_node_response_unwraps_valtown_envelope() { + // The exit-node path through Apps Script returns val.town's JSON + // envelope as the response body. parse_exit_node_response must + // unwrap it back into a raw HTTP/1.1 response so the MITM TLS + // write-back path sees the same shape it gets from the regular + // Apps Script relay. + let envelope = br#"{"s":200,"h":{"content-type":"application/json","x-cf-cache":"DYNAMIC"},"b":"eyJtZXNzYWdlIjoiaGVsbG8ifQ=="}"#; + let raw = parse_exit_node_response(envelope).expect("envelope unwrap should succeed"); + let raw_str = String::from_utf8_lossy(&raw); + assert!(raw_str.starts_with("HTTP/1.1 200 OK\r\n")); + assert!(raw_str.contains("content-type: application/json\r\n")); + assert!(raw_str.contains("x-cf-cache: DYNAMIC\r\n")); + assert!(raw_str.contains("Content-Length: 19\r\n")); + // Body is `{"message":"hello"}` (19 bytes; the base64-decoded + // contents of the b field). + assert!(raw.ends_with(b"{\"message\":\"hello\"}")); + } + + #[tokio::test] + async fn parse_exit_node_response_surfaces_explicit_error() { + // When val.town returns `{e: "..."}` instead of the {s,h,b} shape, + // surface that error message specifically rather than letting + // it through as an unparseable 502 — the message string is what + // tells the user what went wrong (placeholder PSK, bad URL, + // unauthorized, etc.). + let envelope = br#"{"e":"unauthorized"}"#; + let err = parse_exit_node_response(envelope).expect_err("must surface error"); + let msg = format!("{}", err); + assert!(msg.contains("unauthorized"), "got: {}", msg); + assert!(msg.contains("exit node"), "got: {}", msg); + } #[test] fn unix_to_ymd_utc_handles_known_epochs() {