Skip to content

Commit 481bdbc

Browse files
authored
v1.1.4: YouTube video streaming — expanded SNI-rewrite list + parallel Range fetcher (#56)
Users of the upstream Python port (github.com/masterking32/MasterHttpRelayVPN) reported that YouTube videos render fine through theirs while the Rust port stalls. Diff against the Python source exposed two substantive gaps we were missing: 1. SNI-rewrite list was much shorter than upstream. Added: gvt1.com, gvt2.com — Google Video Transport CDN (YouTube video chunks + Chrome auto-updates + Play Store downloads) doubleclick.net — ads googlesyndication.com googleadservices.com google-analytics.com googletagmanager.com googletagservices.com fonts.googleapis.com — already covered by the googleapis.com suffix but mirrored explicitly for clarity These are all on Google's GFE IP pool, so they route over the existing SNI-rewrite tunnel (direct to `google_ip` with SNI rewritten) instead of the quota-limited Apps Script relay. 2. No range-parallel download path. Apps Script's per-call latency is ~flat (~1-2s regardless of payload), so a 10 MB single GET takes ~10s round-trip; the player times out or stutters. Upstream Python's `relay_parallel` probes with Range: bytes=0-262143, and if the origin supports ranges, fetches the rest in parallel 256 KB chunks (up to 16 concurrent). Ported that logic as a new `DomainFronter::relay_parallel_range` method, called from both MITM-HTTPS and plain-HTTP handlers for GETs without a body. Rust implementation uses `futures::stream::buffered` for ordered bounded-concurrency fan-out; cache layer already skips Range requests (added defensive check in relay() too). The existing single-script fan-out (`parallel_relay` config) is complementary — it races N script IDs for each individual chunk, where the range-parallel path slices the overall download. Both are active simultaneously when both are configured. Helper functions for HTTP parsing (split_response, parse_content_range_total, rewrite_206_to_200, assemble_full_200) mirror the Python equivalents. No behaviour change for non-GET requests; no cache-correctness changes for GETs that don't return 206.
1 parent 4b57007 commit 481bdbc

5 files changed

Lines changed: 284 additions & 7 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.1.3"
3+
version = "1.1.4"
44
edition = "2021"
55
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
66
license = "MIT"

android/app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ android {
1414
applicationId = "com.therealaleph.mhrv"
1515
minSdk = 24 // Android 7.0 — covers 99%+ of live devices.
1616
targetSdk = 34
17-
versionCode = 113
18-
versionName = "1.1.3"
17+
versionCode = 114
18+
versionName = "1.1.4"
1919

2020
// Ship all four mainstream Android ABIs:
2121
// - arm64-v8a — 95%+ of real-world Android phones since 2019

src/domain_fronter.rs

Lines changed: 238 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,15 @@ impl DomainFronter {
454454
url
455455
};
456456

457-
let coalescible = is_cacheable_method(method) && body.is_empty();
457+
// Range requests are partial-content responses; caching or
458+
// coalescing them against a non-range key would be catastrophic
459+
// (wrong bytes for the wrong consumer). The range-parallel
460+
// downloader calls `relay()` concurrently with N different Range
461+
// headers for the same URL, and absolutely needs each call to go
462+
// to the relay independently. Simplest correct answer: if any
463+
// Range header is present, skip cache and coalesce entirely.
464+
let has_range = headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("range"));
465+
let coalescible = is_cacheable_method(method) && body.is_empty() && !has_range;
458466
let key = if coalescible { Some(cache_key(method, url)) } else { None };
459467
let t_start = Instant::now();
460468

@@ -507,6 +515,146 @@ impl DomainFronter {
507515
bytes
508516
}
509517

518+
/// Range-parallel relay — the big difference between this port and
519+
/// the upstream Python version. Apps Script's per-call cost is
520+
/// ~flat (1-2s regardless of payload), so a 10MB single GET is
521+
/// ~10s round-trip; the same 10MB sliced into 40 x 256KB chunks
522+
/// and fetched 16-at-a-time is 3-4 round-trips, total ~6-8s, and
523+
/// the client sees the first byte in 1-2s instead of 10. This is
524+
/// what actually makes YouTube video playback viable through the
525+
/// relay — without it, googlevideo.com chunks timeout or stall
526+
/// while the player waits for the next 10s-away Apps Script call
527+
/// to finish.
528+
///
529+
/// Flow (mirrors upstream `relay_parallel`):
530+
/// 1. For anything other than GET-without-body, defer to
531+
/// `relay()` — range requests on POSTs / PUTs aren't well
532+
/// defined, and the user-sent-Range-header case is handled
533+
/// by relay() already (we skip cache for it).
534+
/// 2. Probe with `Range: bytes=0-<chunk-1>`.
535+
/// 3. 200 back (origin doesn't support ranges) → return as-is.
536+
/// 4. 206 back → parse Content-Range total. If the body fits in
537+
/// the first probe (total <= chunk or body >= total), rewrite
538+
/// the 206 to a 200 so the client — which never asked for a
539+
/// range — doesn't choke on a stray Partial Content. (x.com
540+
/// and Cloudflare turnstile in particular reject unsolicited
541+
/// 206 on XHR/fetch.)
542+
/// 5. Else: compute the remaining ranges, fetch them with
543+
/// bounded concurrency, stitch, return as 200.
544+
///
545+
/// If any chunk fails after retries, we fall back to the probe's
546+
/// single-chunk response as a graceful-degradation — better a
547+
/// truncated video than a blank one.
548+
pub async fn relay_parallel_range(
549+
&self,
550+
method: &str,
551+
url: &str,
552+
headers: &[(String, String)],
553+
body: &[u8],
554+
) -> Vec<u8> {
555+
const CHUNK: u64 = 256 * 1024;
556+
const MAX_PARALLEL: usize = 16;
557+
558+
if method != "GET" || !body.is_empty() {
559+
return self.relay(method, url, headers, body).await;
560+
}
561+
// If the client already sent a Range header, honour it as-is —
562+
// don't second-guess a caller that knows what bytes they want.
563+
if headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("range")) {
564+
return self.relay(method, url, headers, body).await;
565+
}
566+
567+
// Probe with the first chunk.
568+
let mut probe_headers: Vec<(String, String)> = headers.to_vec();
569+
probe_headers.push(("Range".into(), format!("bytes=0-{}", CHUNK - 1)));
570+
let first = self.relay(method, url, &probe_headers, body).await;
571+
572+
let (status, resp_headers, resp_body) = match split_response(&first) {
573+
Some(v) => v,
574+
None => return first,
575+
};
576+
577+
if status != 206 {
578+
// Origin returned the whole thing (or an error). Either way,
579+
// pass through.
580+
return first;
581+
}
582+
583+
let total = match parse_content_range_total(&resp_headers) {
584+
Some(t) => t,
585+
None => return rewrite_206_to_200(&first),
586+
};
587+
588+
if total <= CHUNK || (resp_body.len() as u64) >= total {
589+
return rewrite_206_to_200(&first);
590+
}
591+
592+
// Plan remaining ranges after what the probe already returned.
593+
let mut ranges: Vec<(u64, u64)> = Vec::new();
594+
let mut start = resp_body.len() as u64;
595+
while start < total {
596+
let end = (start + CHUNK - 1).min(total - 1);
597+
ranges.push((start, end));
598+
start = end + 1;
599+
}
600+
601+
tracing::info!(
602+
"range-parallel: {} bytes total, {} chunks remaining after probe, up to {} in flight",
603+
total, ranges.len(), MAX_PARALLEL,
604+
);
605+
606+
// Concurrent fetch with `buffered` — preserves input order
607+
// (important for stitching) and caps in-flight count. Each task
608+
// calls back into `relay()`, which already has retry + fan-out
609+
// wiring on single-request granularity; we don't duplicate
610+
// those here.
611+
use futures_util::stream::{self, StreamExt};
612+
let url_owned = url.to_string();
613+
let base_headers = headers.to_vec();
614+
let fetches = stream::iter(ranges.into_iter())
615+
.map(|(s, e)| {
616+
let url = url_owned.clone();
617+
let mut h = base_headers.clone();
618+
// Force a single Range header — if the caller's headers
619+
// somehow already had one we wouldn't be here, but be
620+
// defensive anyway.
621+
h.retain(|(k, _)| !k.eq_ignore_ascii_case("range"));
622+
h.push(("Range".into(), format!("bytes={}-{}", s, e)));
623+
async move {
624+
let raw = self.relay("GET", &url, &h, &[]).await;
625+
split_response(&raw).map(|(_, _, b)| b.to_vec()).unwrap_or_default()
626+
}
627+
})
628+
.buffered(MAX_PARALLEL)
629+
.collect::<Vec<Vec<u8>>>()
630+
.await;
631+
632+
// Stitch: probe body first, then the chunks in order.
633+
let mut full = Vec::with_capacity(total as usize);
634+
full.extend_from_slice(resp_body);
635+
for chunk in &fetches {
636+
full.extend_from_slice(chunk);
637+
}
638+
639+
// If any chunk came back empty (relay failure) we've now got a
640+
// short body. Better to ship the probe-only 200 than a silently
641+
// truncated 200 — the player will display a clear error or
642+
// retry, vs rendering half the movie and cutting.
643+
if (full.len() as u64) < total {
644+
tracing::warn!(
645+
"range-parallel: stitched {}/{} bytes, some chunks failed; falling back to probe response",
646+
full.len(), total,
647+
);
648+
return rewrite_206_to_200(&first);
649+
}
650+
651+
// Build a 200 OK with Content-Length = full body length. Drop
652+
// the Content-Range header (no longer applicable) and
653+
// Transfer-Encoding/Content-Encoding (origin already decoded
654+
// what we got; we ship plain bytes).
655+
assemble_full_200(&resp_headers, &full)
656+
}
657+
510658
async fn relay_uncoalesced(
511659
&self,
512660
method: &str,
@@ -779,6 +927,95 @@ impl DomainFronter {
779927
/// pattern the input is returned unchanged (as an owned String — the
780928
/// allocation is cheap on the slow path and keeps the caller's
781929
/// type-signature-juggling simple).
930+
// ─── HTTP response helpers used by relay_parallel_range ──────────────────
931+
932+
/// Split an HTTP/1.x response blob into `(status, headers, body)`.
933+
/// Returns `None` if the buffer doesn't even have a status line + CRLFCRLF
934+
/// separator — the caller should then pass the bytes through unchanged.
935+
fn split_response(raw: &[u8]) -> Option<(u16, Vec<(String, String)>, &[u8])> {
936+
// Locate end-of-headers.
937+
let sep = b"\r\n\r\n";
938+
let sep_pos = raw.windows(sep.len()).position(|w| w == sep)?;
939+
let head = &raw[..sep_pos];
940+
let body = &raw[sep_pos + sep.len()..];
941+
942+
let mut lines = head.split(|&b| b == b'\n');
943+
let status_line = lines.next()?;
944+
// Status line: "HTTP/1.1 206 Partial Content"
945+
let status_line = std::str::from_utf8(status_line).ok()?.trim_end_matches('\r');
946+
let mut parts = status_line.splitn(3, ' ');
947+
let _version = parts.next()?;
948+
let code = parts.next()?.parse::<u16>().ok()?;
949+
950+
let mut headers: Vec<(String, String)> = Vec::new();
951+
for line in lines {
952+
let line = std::str::from_utf8(line).ok()?.trim_end_matches('\r');
953+
if line.is_empty() {
954+
continue;
955+
}
956+
if let Some((k, v)) = line.split_once(':') {
957+
headers.push((k.trim().to_string(), v.trim().to_string()));
958+
}
959+
}
960+
961+
Some((code, headers, body))
962+
}
963+
964+
/// Pull the total size out of a `Content-Range: bytes 0-NNN/TOTAL` header.
965+
fn parse_content_range_total(headers: &[(String, String)]) -> Option<u64> {
966+
let cr = headers
967+
.iter()
968+
.find(|(k, _)| k.eq_ignore_ascii_case("content-range"))?;
969+
let slash = cr.1.rfind('/')?;
970+
cr.1[slash + 1..].trim().parse::<u64>().ok()
971+
}
972+
973+
/// Rewrite a 206 response to a 200 OK, dropping Content-Range and
974+
/// recomputing Content-Length. Used when we probed with a synthetic
975+
/// Range header but the client sent a plain GET — handing a 206 back to
976+
/// XHR/fetch code on some sites (x.com, Cloudflare Turnstile) makes them
977+
/// treat the response as aborted. Same rationale as the upstream Python
978+
/// `_rewrite_206_to_200`.
979+
fn rewrite_206_to_200(raw: &[u8]) -> Vec<u8> {
980+
let (_status, headers, body) = match split_response(raw) {
981+
Some(v) => v,
982+
None => return raw.to_vec(),
983+
};
984+
assemble_full_200(&headers, body)
985+
}
986+
987+
/// Build a complete `HTTP/1.1 200 OK` response with the given header
988+
/// set + body. Skips headers the caller shouldn't be forwarding
989+
/// verbatim (content-length/range/encoding, transfer-encoding, hop-by-hop
990+
/// wire-level stuff) — we set Content-Length from the body we're
991+
/// actually shipping.
992+
fn assemble_full_200(src_headers: &[(String, String)], body: &[u8]) -> Vec<u8> {
993+
let skip = |k: &str| {
994+
matches!(
995+
k.to_ascii_lowercase().as_str(),
996+
"content-length"
997+
| "content-range"
998+
| "content-encoding"
999+
| "transfer-encoding"
1000+
| "connection"
1001+
| "keep-alive",
1002+
)
1003+
};
1004+
let mut out: Vec<u8> = b"HTTP/1.1 200 OK\r\n".to_vec();
1005+
for (k, v) in src_headers {
1006+
if skip(k) {
1007+
continue;
1008+
}
1009+
out.extend_from_slice(k.as_bytes());
1010+
out.extend_from_slice(b": ");
1011+
out.extend_from_slice(v.as_bytes());
1012+
out.extend_from_slice(b"\r\n");
1013+
}
1014+
out.extend_from_slice(format!("Content-Length: {}\r\n\r\n", body.len()).as_bytes());
1015+
out.extend_from_slice(body);
1016+
out
1017+
}
1018+
7821019
fn normalize_x_graphql_url(url: &str) -> String {
7831020
// Split host from the rest. We accept both "x.com" and common legacy
7841021
// forms; the Python patch only checks x.com so we do the same to be

src/proxy_server.rs

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,26 @@ const SNI_REWRITE_SUFFIXES: &[&str] = &[
4242
"youtu.be",
4343
"youtube-nocookie.com",
4444
"ytimg.com",
45+
// Google Video Transport CDN — YouTube video chunks, Chrome
46+
// auto-updates, Google Play Store downloads. The single biggest
47+
// gap vs the upstream Python port: without these in the list
48+
// YouTube video playback stalls because every chunk tries to
49+
// traverse Apps Script instead of the direct GFE tunnel.
50+
"gvt1.com",
51+
"gvt2.com",
52+
// Ad + analytics infra. All on GFE, all previously broken the
53+
// same way YouTube was: SNI-blocked on Iranian DPI, but reachable
54+
// via `google_ip` with SNI rewritten.
55+
"doubleclick.net",
56+
"googlesyndication.com",
57+
"googleadservices.com",
58+
"google-analytics.com",
59+
"googletagmanager.com",
60+
"googletagservices.com",
61+
// fonts.googleapis.com is technically covered by the googleapis.com
62+
// suffix above, but mirroring Python's explicit listing makes the
63+
// intent obvious at a glance.
64+
"fonts.googleapis.com",
4565
// Blogger / Blog.google
4666
"blogspot.com",
4767
"blogger.com",
@@ -1047,7 +1067,19 @@ where
10471067

10481068
tracing::info!("relay {} {}", method, url);
10491069

1050-
let response = fronter.relay(&method, &url, &headers, &body).await;
1070+
// For GETs without a body, take the range-parallel path — probes
1071+
// with `Range: bytes=0-<chunk>`, and if the origin supports ranges,
1072+
// fetches the rest in parallel 256 KB chunks. This is what lets
1073+
// YouTube video streaming / gvt1.com Chrome-updates / big static
1074+
// files not stall waiting on one ~2s Apps Script call per MB.
1075+
// Anything with a body (POST/PUT/PATCH) goes through the normal
1076+
// relay path — range semantics on mutating requests are undefined
1077+
// and would break form submissions.
1078+
let response = if method.eq_ignore_ascii_case("GET") && body.is_empty() {
1079+
fronter.relay_parallel_range(&method, &url, &headers, &body).await
1080+
} else {
1081+
fronter.relay(&method, &url, &headers, &body).await
1082+
};
10511083
stream.write_all(&response).await?;
10521084
stream.flush().await?;
10531085

@@ -1290,7 +1322,15 @@ async fn do_plain_http(
12901322
};
12911323

12921324
tracing::info!("HTTP {} {}", method, url);
1293-
let response = fronter.relay(&method, &url, &headers, &body).await;
1325+
// Plain HTTP proxy path — same range-parallel strategy as the
1326+
// MITM-HTTPS path above. Large downloads on port 80 (package
1327+
// mirrors, video poster streams, etc.) need the same acceleration
1328+
// or the relay stalls per-chunk.
1329+
let response = if method.eq_ignore_ascii_case("GET") && body.is_empty() {
1330+
fronter.relay_parallel_range(&method, &url, &headers, &body).await
1331+
} else {
1332+
fronter.relay(&method, &url, &headers, &body).await
1333+
};
12941334
sock.write_all(&response).await?;
12951335
sock.flush().await?;
12961336
Ok(())

0 commit comments

Comments
 (0)