Skip to content

Commit 541b37a

Browse files
committed
fix: v1.9.5 — exit-node tolerates TLS close without close_notify (#585)
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.
1 parent 7268baf commit 541b37a

4 files changed

Lines changed: 157 additions & 8 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mhrv-rs"
3-
version = "1.9.4"
3+
version = "1.9.5"
44
edition = "2021"
55
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
66
license = "MIT"

docs/changelog/v1.9.5.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<!-- see docs/changelog/v1.1.0.md for the file format: Persian, then `---`, then English. -->
2+
• 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.
3+
---
4+
• 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.

src/domain_fronter.rs

Lines changed: 151 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2482,8 +2482,27 @@ where
24822482
while body.len() < cl {
24832483
let need = cl - body.len();
24842484
let want = need.min(tmp.len());
2485-
let n = timeout(Duration::from_secs(20), stream.read(&mut tmp[..want])).await
2486-
.map_err(|_| FronterError::Timeout)??;
2485+
// Handle ungraceful TLS close-without-close_notify (rustls
2486+
// surfaces this as `io::ErrorKind::UnexpectedEof`). Some
2487+
// origins — notably val.town's exit-node path through Apps
2488+
// Script (#585, v1.9.4) and certain Apps Script `Connection:
2489+
// close` responses — terminate the underlying TCP without
2490+
// sending the TLS close_notify alert first. Treat that the
2491+
// same as a clean `n == 0`: if we already have the full body
2492+
// declared by Content-Length, the response *is* complete.
2493+
// Only propagate the error if Content-Length couldn't be
2494+
// satisfied (real truncation, not a polite-protocol violation).
2495+
let read_res = timeout(
2496+
Duration::from_secs(20),
2497+
stream.read(&mut tmp[..want]),
2498+
)
2499+
.await
2500+
.map_err(|_| FronterError::Timeout)?;
2501+
let n = match read_res {
2502+
Ok(n) => n,
2503+
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => 0,
2504+
Err(e) => return Err(e.into()),
2505+
};
24872506
if n == 0 {
24882507
return Err(FronterError::BadResponse(
24892508
"connection closed before full response body".into(),
@@ -2492,11 +2511,17 @@ where
24922511
body.extend_from_slice(&tmp[..n]);
24932512
}
24942513
} else {
2495-
// No framing — read until short timeout.
2514+
// No framing — read until short timeout, EOF, or ungraceful
2515+
// TLS close (UnexpectedEof). Each is treated as "we got what
2516+
// the peer wanted to send"; the response we already have is
2517+
// returned to the caller. UnexpectedEof here is the most common
2518+
// case for `Connection: close` responses from servers that
2519+
// don't bother with TLS close_notify (#585).
24962520
loop {
24972521
match timeout(Duration::from_secs(2), stream.read(&mut tmp)).await {
24982522
Ok(Ok(0)) => break,
24992523
Ok(Ok(n)) => body.extend_from_slice(&tmp[..n]),
2524+
Ok(Err(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
25002525
Ok(Err(e)) => return Err(e.into()),
25012526
Err(_) => break,
25022527
}
@@ -2542,8 +2567,18 @@ where
25422567
}
25432568
}
25442569
while buf.len() < size + 2 {
2545-
let n = timeout(Duration::from_secs(20), stream.read(&mut tmp)).await
2546-
.map_err(|_| FronterError::Timeout)??;
2570+
// UnexpectedEof tolerance — see read_http_response for
2571+
// rationale. Treated as `n == 0`; if we haven't accumulated
2572+
// the full chunk yet, that's still a real truncation and
2573+
// we return BadResponse below.
2574+
let read_res = timeout(Duration::from_secs(20), stream.read(&mut tmp))
2575+
.await
2576+
.map_err(|_| FronterError::Timeout)?;
2577+
let n = match read_res {
2578+
Ok(n) => n,
2579+
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => 0,
2580+
Err(e) => return Err(e.into()),
2581+
};
25472582
if n == 0 {
25482583
return Err(FronterError::BadResponse(
25492584
"connection closed mid-chunked response".into(),
@@ -2899,7 +2934,117 @@ impl ServerCertVerifier for NoVerify {
28992934
#[cfg(test)]
29002935
mod tests {
29012936
use super::*;
2902-
use tokio::io::{duplex, AsyncWriteExt};
2937+
use std::pin::Pin;
2938+
use std::task::{Context, Poll};
2939+
use tokio::io::{duplex, AsyncRead, AsyncWriteExt, ReadBuf};
2940+
2941+
// Test fixture for ungraceful TLS close: emit a fixed prefix of bytes
2942+
// then return io::ErrorKind::UnexpectedEof on the next read. Mirrors
2943+
// what rustls surfaces when the peer closes TCP without sending a
2944+
// TLS close_notify alert (#585).
2945+
struct UnexpectedEofAfter {
2946+
bytes: Vec<u8>,
2947+
position: usize,
2948+
}
2949+
2950+
impl AsyncRead for UnexpectedEofAfter {
2951+
fn poll_read(
2952+
mut self: Pin<&mut Self>,
2953+
_cx: &mut Context<'_>,
2954+
buf: &mut ReadBuf<'_>,
2955+
) -> Poll<std::io::Result<()>> {
2956+
if self.position >= self.bytes.len() {
2957+
return Poll::Ready(Err(std::io::Error::new(
2958+
std::io::ErrorKind::UnexpectedEof,
2959+
"peer closed connection without sending TLS close_notify",
2960+
)));
2961+
}
2962+
let remaining = &self.bytes[self.position..];
2963+
let take = remaining.len().min(buf.remaining());
2964+
buf.put_slice(&remaining[..take]);
2965+
self.position += take;
2966+
Poll::Ready(Ok(()))
2967+
}
2968+
}
2969+
2970+
#[tokio::test]
2971+
async fn read_http_response_tolerates_unexpected_eof_with_content_length() {
2972+
// Issue #585 / v1.9.4 exit-node bug. Some peers (val.town in
2973+
// particular, certain Apps Script `Connection: close` paths) close
2974+
// the TCP without TLS close_notify. Body should still be returned
2975+
// when Content-Length is satisfied, even though the read after
2976+
// the body closes ungracefully.
2977+
let body = b"{\"ok\":true}";
2978+
let header = format!(
2979+
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2980+
body.len()
2981+
);
2982+
let mut full = header.into_bytes();
2983+
full.extend_from_slice(body);
2984+
let mut stream = UnexpectedEofAfter {
2985+
bytes: full,
2986+
position: 0,
2987+
};
2988+
2989+
let (status, _headers, got_body) =
2990+
read_http_response(&mut stream).await.expect("must succeed despite UnexpectedEof");
2991+
assert_eq!(status, 200);
2992+
assert_eq!(got_body, body);
2993+
}
2994+
2995+
#[tokio::test]
2996+
async fn read_http_response_tolerates_unexpected_eof_no_framing() {
2997+
// Same #585 fix, but for the no-framing branch (server didn't
2998+
// send Content-Length or Transfer-Encoding). Read until peer
2999+
// closes — UnexpectedEof should terminate the loop with the
3000+
// body we accumulated so far, not bubble up as an error.
3001+
let header = b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n";
3002+
let body = b"hello world";
3003+
let mut full = header.to_vec();
3004+
full.extend_from_slice(body);
3005+
let mut stream = UnexpectedEofAfter {
3006+
bytes: full,
3007+
position: 0,
3008+
};
3009+
3010+
let (status, _headers, got_body) =
3011+
read_http_response(&mut stream).await.expect("must succeed despite UnexpectedEof");
3012+
assert_eq!(status, 200);
3013+
assert_eq!(got_body, body);
3014+
}
3015+
3016+
#[tokio::test]
3017+
async fn parse_exit_node_response_unwraps_valtown_envelope() {
3018+
// The exit-node path through Apps Script returns val.town's JSON
3019+
// envelope as the response body. parse_exit_node_response must
3020+
// unwrap it back into a raw HTTP/1.1 response so the MITM TLS
3021+
// write-back path sees the same shape it gets from the regular
3022+
// Apps Script relay.
3023+
let envelope = br#"{"s":200,"h":{"content-type":"application/json","x-cf-cache":"DYNAMIC"},"b":"eyJtZXNzYWdlIjoiaGVsbG8ifQ=="}"#;
3024+
let raw = parse_exit_node_response(envelope).expect("envelope unwrap should succeed");
3025+
let raw_str = String::from_utf8_lossy(&raw);
3026+
assert!(raw_str.starts_with("HTTP/1.1 200 OK\r\n"));
3027+
assert!(raw_str.contains("content-type: application/json\r\n"));
3028+
assert!(raw_str.contains("x-cf-cache: DYNAMIC\r\n"));
3029+
assert!(raw_str.contains("Content-Length: 19\r\n"));
3030+
// Body is `{"message":"hello"}` (19 bytes; the base64-decoded
3031+
// contents of the b field).
3032+
assert!(raw.ends_with(b"{\"message\":\"hello\"}"));
3033+
}
3034+
3035+
#[tokio::test]
3036+
async fn parse_exit_node_response_surfaces_explicit_error() {
3037+
// When val.town returns `{e: "..."}` instead of the {s,h,b} shape,
3038+
// surface that error message specifically rather than letting
3039+
// it through as an unparseable 502 — the message string is what
3040+
// tells the user what went wrong (placeholder PSK, bad URL,
3041+
// unauthorized, etc.).
3042+
let envelope = br#"{"e":"unauthorized"}"#;
3043+
let err = parse_exit_node_response(envelope).expect_err("must surface error");
3044+
let msg = format!("{}", err);
3045+
assert!(msg.contains("unauthorized"), "got: {}", msg);
3046+
assert!(msg.contains("exit node"), "got: {}", msg);
3047+
}
29033048

29043049
#[test]
29053050
fn unix_to_ymd_utc_handles_known_epochs() {

0 commit comments

Comments
 (0)