mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-17 21:24:48 +03:00
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.
This commit is contained in:
Generated
+1
-1
@@ -2222,7 +2222,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mhrv-rs"
|
name = "mhrv-rs"
|
||||||
version = "1.9.4"
|
version = "1.9.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mhrv-rs"
|
name = "mhrv-rs"
|
||||||
version = "1.9.4"
|
version = "1.9.5"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
|
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<!-- see docs/changelog/v1.1.0.md for the file format: Persian, then `---`, then English. -->
|
||||||
|
• 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.
|
||||||
+151
-6
@@ -2482,8 +2482,27 @@ where
|
|||||||
while body.len() < cl {
|
while body.len() < cl {
|
||||||
let need = cl - body.len();
|
let need = cl - body.len();
|
||||||
let want = need.min(tmp.len());
|
let want = need.min(tmp.len());
|
||||||
let n = timeout(Duration::from_secs(20), stream.read(&mut tmp[..want])).await
|
// Handle ungraceful TLS close-without-close_notify (rustls
|
||||||
.map_err(|_| FronterError::Timeout)??;
|
// 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 {
|
if n == 0 {
|
||||||
return Err(FronterError::BadResponse(
|
return Err(FronterError::BadResponse(
|
||||||
"connection closed before full response body".into(),
|
"connection closed before full response body".into(),
|
||||||
@@ -2492,11 +2511,17 @@ where
|
|||||||
body.extend_from_slice(&tmp[..n]);
|
body.extend_from_slice(&tmp[..n]);
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
loop {
|
||||||
match timeout(Duration::from_secs(2), stream.read(&mut tmp)).await {
|
match timeout(Duration::from_secs(2), stream.read(&mut tmp)).await {
|
||||||
Ok(Ok(0)) => break,
|
Ok(Ok(0)) => break,
|
||||||
Ok(Ok(n)) => body.extend_from_slice(&tmp[..n]),
|
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()),
|
Ok(Err(e)) => return Err(e.into()),
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
@@ -2542,8 +2567,18 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
while buf.len() < size + 2 {
|
while buf.len() < size + 2 {
|
||||||
let n = timeout(Duration::from_secs(20), stream.read(&mut tmp)).await
|
// UnexpectedEof tolerance — see read_http_response for
|
||||||
.map_err(|_| FronterError::Timeout)??;
|
// 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 {
|
if n == 0 {
|
||||||
return Err(FronterError::BadResponse(
|
return Err(FronterError::BadResponse(
|
||||||
"connection closed mid-chunked response".into(),
|
"connection closed mid-chunked response".into(),
|
||||||
@@ -2899,7 +2934,117 @@ impl ServerCertVerifier for NoVerify {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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<u8>,
|
||||||
|
position: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncRead for UnexpectedEofAfter {
|
||||||
|
fn poll_read(
|
||||||
|
mut self: Pin<&mut Self>,
|
||||||
|
_cx: &mut Context<'_>,
|
||||||
|
buf: &mut ReadBuf<'_>,
|
||||||
|
) -> Poll<std::io::Result<()>> {
|
||||||
|
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]
|
#[test]
|
||||||
fn unix_to_ymd_utc_handles_known_epochs() {
|
fn unix_to_ymd_utc_handles_known_epochs() {
|
||||||
|
|||||||
Reference in New Issue
Block a user