Skip to content

Commit e575bf6

Browse files
committed
v0.5.0: optional upstream SOCKS5 for non-HTTP traffic (Telegram et al.)
The Apps Script relay is HTTP-only, and the SNI-rewrite tunnel only works for Google-hosted domains — so MTProto / IMAP / SSH / anything else used to drop to a direct-TCP passthrough, which provides zero circumvention. Users behind a DPI that blocks Telegram saw constant disconnect/reconnect loops because the raw TCP ran right into the block. Fix: add an optional 'upstream_socks5' config field. When set, the raw-TCP fallback chains the flow into that SOCKS5 proxy (typically a local xray / v2ray / sing-box with a VLESS / Trojan / Shadowsocks outbound to your own VPS) instead of connecting directly. The whole rest of the pipeline is unchanged: - HTTP / HTTPS still MITMs and relays via Apps Script - SNI-rewrite suffixes (google.com, youtube.com, …) still hit the direct Google-edge tunnel (so YouTube stays fast) - Only the raw-TCP bucket (Telegram MTProto, SSH, IMAP, …) gets the new upstream chain Changes: - config.rs: add Option<String> upstream_socks5 field - proxy_server.rs: thread it through RewriteCtx; rewrite plain_tcp_passthrough to call a new socks5_connect_via() helper when configured, with graceful fallback to direct - ui.rs: new 'Upstream SOCKS5' input with tooltip + placeholder, ConfigWire round-trip - README.md: new 'Pair with xray for Telegram' section (EN + FA) with the architecture diagram and example config Verified end-to-end in Docker: xray with the user's working VLESS Reality config, mhrv-rs with upstream_socks5 pointing at it. - HTTPS via mhrv-rs SOCKS5: origin = Google IP (Apps Script path) ✓ - Raw TCP to 3 Telegram DCs + api.telegram.org: all SOCKS5 rep=0, log shows 'tcp via upstream-socks5 127.0.0.1:50529 -> …' ✓ - youtube.com / google.com: 'SNI-rewrite tunnel' (unchanged) ✓ - Real Telegram Desktop stayed connected cleanly (user-confirmed).
1 parent 68effd2 commit e575bf6

6 files changed

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

README.md

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,29 @@ The tool listens on **two** ports. Use whichever your client supports:
187187

188188
**SOCKS5 proxy** (Telegram, xray, app-level clients) — `127.0.0.1:8086`, no auth.
189189

190-
- Works for HTTP, HTTPS, **and** non-HTTP protocols (Telegram's MTProto, raw TCP). The server auto-detects each connection and falls back to plain TCP passthrough when the payload isn't HTTP.
190+
- Works for HTTP, HTTPS, **and** non-HTTP protocols (Telegram's MTProto, raw TCP). The server auto-detects each connection: HTTP/HTTPS go through the Apps Script relay, SNI-rewritable domains go through the direct Google-edge tunnel, and anything else falls through to raw TCP.
191+
192+
## Telegram, IMAP, SSH — pair with xray (optional)
193+
194+
The Apps Script relay only speaks HTTP request/response, so non-HTTP protocols (Telegram MTProto, IMAP, SSH, arbitrary raw TCP) can't travel through it. Without anything else, those flows hit the direct-TCP fallback — which means they're not actually tunneled, and an ISP that blocks Telegram will still block them.
195+
196+
Fix: run a local [xray](https://github.com/XTLS/Xray-core) (or v2ray / sing-box) with a VLESS/Trojan/Shadowsocks outbound that goes to a VPS of your own, and point mhrv-rs at xray's SOCKS5 inbound via the **Upstream SOCKS5** field (or the `upstream_socks5` config key). When set, raw-TCP flows coming through mhrv-rs's SOCKS5 listener get chained into xray → the real tunnel, instead of connecting directly.
197+
198+
```
199+
Telegram ┐ ┌─ Apps Script ── HTTP/HTTPS
200+
├─ SOCKS5 :8086 ─┤ mhrv-rs ├─ SNI rewrite ─── google.com, youtube.com, …
201+
Browser ┘ └─ upstream SOCKS5 ─ xray ── VLESS ── your VPS (Telegram, IMAP, SSH, raw TCP)
202+
```
203+
204+
Example config fragment (both UI and JSON):
205+
206+
```json
207+
{
208+
"upstream_socks5": "127.0.0.1:50529"
209+
}
210+
```
211+
212+
HTTP/HTTPS continues to route through the Apps Script relay (no change), and the SNI-rewrite tunnel for `google.com` / `youtube.com` / etc. keeps bypassing both — so YouTube stays as fast as before while Telegram gets a real tunnel.
191213

192214
## Diagnostics
193215

@@ -217,6 +239,7 @@ This port focuses on the **`apps_script` mode** — the only one that reliably w
217239
- [x] `test` and `scan-ips` subcommands
218240
- [x] Script IDs masked in logs (`prefix…suffix`) so `info` logs don't leak deployment IDs
219241
- [x] Desktop UI (egui) — cross-platform, no bundler needed
242+
- [x] Optional upstream SOCKS5 chaining for non-HTTP traffic (Telegram MTProto, IMAP, SSH…) so raw-TCP flows can be tunneled through xray / v2ray / sing-box instead of connecting directly. HTTP/HTTPS keeps going through the Apps Script relay.
220243

221244
Intentionally **not** implemented (rationale included so future contributors don't spend cycles on them):
222245

@@ -382,7 +405,29 @@ Firefox cert store خودش را جدا دارد؛ installer تلاش می‌ک
382405

383406
**SOCKS5 proxy** (تلگرام، xray، کلاینت‌های app-level) — `127.0.0.1:8086`، بدون auth.
384407

385-
برای HTTP و HTTPS و **هم** پروتکل‌های غیر-HTTP (MTProto تلگرام، TCP خام) کار می‌کند. ابزار به‌صورت هوشمند تشخیص می‌دهد و اگر ترافیک HTTP نبود، به‌صورت plain TCP passthrough می‌فرستد.
408+
برای HTTP و HTTPS و **هم** پروتکل‌های غیر-HTTP (MTProto تلگرام، TCP خام) کار می‌کند. ابزار به‌صورت هوشمند تشخیص می‌دهد: HTTP/HTTPS از رلهٔ Apps Script می‌رود، دامنه‌های قابل SNI-rewrite از تونل مستقیم لبهٔ گوگل، و بقیه به TCP خام می‌افتد.
409+
410+
### تلگرام، IMAP، SSH — با xray جفت کنید (اختیاری)
411+
412+
رلهٔ Apps Script فقط HTTP request/response می‌فهمد، پس پروتکل‌های غیر-HTTP (MTProto تلگرام، IMAP، SSH، TCP خام) از داخلش عبور نمی‌کنند. بدون کار اضافه این جور ترافیک به مسیر TCP مستقیم می‌افتد — یعنی واقعاً tunnel نمی‌شود و اگر ISP تلگرام را بلاک کرده باشد، همچنان بلاک است.
413+
414+
راه حل: یک [xray](https://github.com/XTLS/Xray-core) (یا v2ray / sing-box) با outbound VLESS/Trojan/Shadowsocks به یک VPS شخصی خودتان بالا بیاورید، و mhrv-rs را از طریق فیلد **Upstream SOCKS5** در UI (یا کلید `upstream_socks5` در config) به SOCKS5 inbound آن وصل کنید. با این کار ترافیک TCP خامی که از SOCKS5 mhrv-rs رد می‌شود، به‌جای اتصال مستقیم، از xray رد شده و به تونل واقعی می‌رسد.
415+
416+
```
417+
تلگرام ┐ ┌─ Apps Script ── HTTP/HTTPS
418+
├─ SOCKS5 :8086 ┤ mhrv-rs ├─ SNI rewrite ─── google.com, youtube.com, …
419+
مرورگر ┘ └─ upstream SOCKS5 ─ xray ── VLESS ── VPS شما (تلگرام، IMAP، SSH، TCP خام)
420+
```
421+
422+
قطعه‌ای از config:
423+
424+
```json
425+
{
426+
"upstream_socks5": "127.0.0.1:50529"
427+
}
428+
```
429+
430+
HTTP/HTTPS هیچ تغییری نمی‌کند (همچنان از Apps Script می‌رود) و تونل SNI-rewrite برای `google.com` / `youtube.com` / … هم سر جای خودش است — پس یوتوب مثل قبل سریع می‌ماند و تلگرام بالاخره یک تونل واقعی می‌گیرد.
386431

387432
### محدودیت‌های شناخته‌شده
388433

src/bin/ui.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ struct FormState {
104104
socks5_port: String,
105105
log_level: String,
106106
verify_ssl: bool,
107+
upstream_socks5: String,
107108
show_auth_key: bool,
108109
}
109110

@@ -137,6 +138,7 @@ fn load_form() -> FormState {
137138
socks5_port: c.socks5_port.map(|p| p.to_string()).unwrap_or_default(),
138139
log_level: c.log_level,
139140
verify_ssl: c.verify_ssl,
141+
upstream_socks5: c.upstream_socks5.unwrap_or_default(),
140142
show_auth_key: false,
141143
}
142144
} else {
@@ -150,6 +152,7 @@ fn load_form() -> FormState {
150152
socks5_port: "8086".into(),
151153
log_level: "info".into(),
152154
verify_ssl: true,
155+
upstream_socks5: String::new(),
153156
show_auth_key: false,
154157
}
155158
}
@@ -201,6 +204,10 @@ impl FormState {
201204
verify_ssl: self.verify_ssl,
202205
hosts: std::collections::HashMap::new(),
203206
enable_batching: false,
207+
upstream_socks5: {
208+
let v = self.upstream_socks5.trim();
209+
if v.is_empty() { None } else { Some(v.to_string()) }
210+
},
204211
})
205212
}
206213
}
@@ -232,6 +239,8 @@ struct ConfigWire<'a> {
232239
verify_ssl: bool,
233240
#[serde(skip_serializing_if = "std::collections::HashMap::is_empty")]
234241
hosts: &'a std::collections::HashMap<String, String>,
242+
#[serde(skip_serializing_if = "Option::is_none")]
243+
upstream_socks5: Option<&'a str>,
235244
}
236245

237246
#[derive(serde::Serialize)]
@@ -259,6 +268,7 @@ impl<'a> From<&'a Config> for ConfigWire<'a> {
259268
log_level: c.log_level.as_str(),
260269
verify_ssl: c.verify_ssl,
261270
hosts: &c.hosts,
271+
upstream_socks5: c.upstream_socks5.as_deref(),
262272
}
263273
}
264274
}
@@ -358,6 +368,20 @@ impl eframe::App for App {
358368
ui.add(egui::TextEdit::singleline(&mut self.form.socks5_port).desired_width(80.0));
359369
ui.end_row();
360370

371+
ui.label("Upstream SOCKS5")
372+
.on_hover_text(
373+
"Optional. host:port of an upstream SOCKS5 proxy (e.g. xray / v2ray / sing-box).\n\
374+
When set, non-HTTP / raw-TCP traffic arriving on the SOCKS5 listener is\n\
375+
chained through this proxy instead of connecting directly — this is what\n\
376+
makes Telegram MTProto, IMAP, SSH etc. actually tunnel.\n\
377+
HTTP/HTTPS traffic still routes through the Apps Script relay and the\n\
378+
SNI-rewrite tunnel as before."
379+
);
380+
ui.add(egui::TextEdit::singleline(&mut self.form.upstream_socks5)
381+
.hint_text("empty = direct; 127.0.0.1:50529 for a local xray")
382+
.desired_width(f32::INFINITY));
383+
ui.end_row();
384+
361385
ui.label("Log level");
362386
egui::ComboBox::from_id_source("loglevel")
363387
.selected_text(&self.form.log_level)

src/config.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ pub struct Config {
5454
pub hosts: HashMap<String, String>,
5555
#[serde(default)]
5656
pub enable_batching: bool,
57+
/// Optional upstream SOCKS5 proxy for non-HTTP / raw-TCP traffic
58+
/// (e.g. `"127.0.0.1:50529"` pointing at a local xray / v2ray instance).
59+
/// When set, the SOCKS5 listener forwards raw-TCP flows through it
60+
/// instead of connecting directly. HTTP/HTTPS traffic (which goes
61+
/// through the Apps Script relay) and SNI-rewrite tunnels are
62+
/// unaffected.
63+
#[serde(default)]
64+
pub upstream_socks5: Option<String>,
5765
}
5866

5967
fn default_google_ip() -> String {

src/proxy_server.rs

Lines changed: 137 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ pub struct RewriteCtx {
7474
pub front_domain: String,
7575
pub hosts: std::collections::HashMap<String, String>,
7676
pub tls_connector: TlsConnector,
77+
pub upstream_socks5: Option<String>,
7778
}
7879

7980
impl ProxyServer {
@@ -100,6 +101,7 @@ impl ProxyServer {
100101
front_domain: config.front_domain.clone(),
101102
hosts: config.hosts.clone(),
102103
tls_connector,
104+
upstream_socks5: config.upstream_socks5.clone(),
103105
});
104106

105107
let socks5_port = config.socks5_port.unwrap_or(config.listen_port + 1);
@@ -326,7 +328,7 @@ async fn dispatch_tunnel(
326328
Ok(Err(_)) => return Ok(()),
327329
Err(_) => {
328330
// Client silent: likely a server-first protocol.
329-
plain_tcp_passthrough(sock, &host, port).await;
331+
plain_tcp_passthrough(sock, &host, port, rewrite_ctx.upstream_socks5.as_deref()).await;
330332
return Ok(());
331333
}
332334
};
@@ -345,32 +347,63 @@ async fn dispatch_tunnel(
345347
return Ok(());
346348
}
347349

348-
plain_tcp_passthrough(sock, &host, port).await;
350+
plain_tcp_passthrough(sock, &host, port, rewrite_ctx.upstream_socks5.as_deref()).await;
349351
Ok(())
350352
}
351353

352354
// ---------- Plain TCP passthrough ----------
353355

354-
async fn plain_tcp_passthrough(mut sock: TcpStream, host: &str, port: u16) {
356+
async fn plain_tcp_passthrough(
357+
mut sock: TcpStream,
358+
host: &str,
359+
port: u16,
360+
upstream_socks5: Option<&str>,
361+
) {
355362
let target_host = host.trim_start_matches('[').trim_end_matches(']');
356-
let upstream = match tokio::time::timeout(
357-
std::time::Duration::from_secs(10),
358-
TcpStream::connect((target_host, port)),
359-
)
360-
.await
361-
{
362-
Ok(Ok(s)) => s,
363-
Ok(Err(e)) => {
364-
tracing::debug!("plain-tcp connect {}:{} failed: {}", host, port, e);
365-
return;
363+
let upstream = if let Some(proxy) = upstream_socks5 {
364+
match socks5_connect_via(proxy, target_host, port).await {
365+
Ok(s) => {
366+
tracing::info!("tcp via upstream-socks5 {} -> {}:{}", proxy, host, port);
367+
s
368+
}
369+
Err(e) => {
370+
tracing::warn!(
371+
"upstream-socks5 {} -> {}:{} failed: {} (falling back to direct)",
372+
proxy, host, port, e
373+
);
374+
match tokio::time::timeout(
375+
std::time::Duration::from_secs(10),
376+
TcpStream::connect((target_host, port)),
377+
)
378+
.await
379+
{
380+
Ok(Ok(s)) => s,
381+
_ => return,
382+
}
383+
}
366384
}
367-
Err(_) => {
368-
tracing::debug!("plain-tcp connect {}:{} timeout", host, port);
369-
return;
385+
} else {
386+
match tokio::time::timeout(
387+
std::time::Duration::from_secs(10),
388+
TcpStream::connect((target_host, port)),
389+
)
390+
.await
391+
{
392+
Ok(Ok(s)) => {
393+
tracing::info!("plain-tcp passthrough -> {}:{}", host, port);
394+
s
395+
}
396+
Ok(Err(e)) => {
397+
tracing::debug!("plain-tcp connect {}:{} failed: {}", host, port, e);
398+
return;
399+
}
400+
Err(_) => {
401+
tracing::debug!("plain-tcp connect {}:{} timeout", host, port);
402+
return;
403+
}
370404
}
371405
};
372406
let _ = upstream.set_nodelay(true);
373-
tracing::info!("plain-tcp passthrough -> {}:{}", host, port);
374407
let (mut ar, mut aw) = sock.split();
375408
let (mut br, mut bw) = {
376409
let (r, w) = upstream.into_split();
@@ -384,6 +417,93 @@ async fn plain_tcp_passthrough(mut sock: TcpStream, host: &str, port: u16) {
384417
}
385418
}
386419

420+
/// Open a TCP stream to `(host, port)` through an upstream SOCKS5 proxy
421+
/// (no-auth only). Returns the connected stream after SOCKS5 negotiation.
422+
async fn socks5_connect_via(
423+
proxy: &str,
424+
host: &str,
425+
port: u16,
426+
) -> std::io::Result<TcpStream> {
427+
use tokio::io::AsyncReadExt;
428+
use tokio::io::AsyncWriteExt;
429+
let mut s = tokio::time::timeout(
430+
std::time::Duration::from_secs(5),
431+
TcpStream::connect(proxy),
432+
)
433+
.await
434+
.map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "connect timeout"))??;
435+
let _ = s.set_nodelay(true);
436+
437+
// Greeting: VER=5, NMETHODS=1, METHOD=no-auth
438+
s.write_all(&[0x05, 0x01, 0x00]).await?;
439+
let mut reply = [0u8; 2];
440+
s.read_exact(&mut reply).await?;
441+
if reply[0] != 0x05 || reply[1] != 0x00 {
442+
return Err(std::io::Error::new(
443+
std::io::ErrorKind::Other,
444+
format!("socks5 greet rejected: {:?}", reply),
445+
));
446+
}
447+
448+
// CONNECT request: VER=5, CMD=1, RSV=0, ATYP=3 (domain) | 1 (IPv4) | 4 (IPv6)
449+
let mut req: Vec<u8> = Vec::with_capacity(8 + host.len());
450+
req.extend_from_slice(&[0x05, 0x01, 0x00]);
451+
if let Ok(v4) = host.parse::<std::net::Ipv4Addr>() {
452+
req.push(0x01);
453+
req.extend_from_slice(&v4.octets());
454+
} else if let Ok(v6) = host.parse::<std::net::Ipv6Addr>() {
455+
req.push(0x04);
456+
req.extend_from_slice(&v6.octets());
457+
} else {
458+
let hb = host.as_bytes();
459+
if hb.len() > 255 {
460+
return Err(std::io::Error::new(
461+
std::io::ErrorKind::InvalidInput,
462+
"hostname > 255",
463+
));
464+
}
465+
req.push(0x03);
466+
req.push(hb.len() as u8);
467+
req.extend_from_slice(hb);
468+
}
469+
req.extend_from_slice(&port.to_be_bytes());
470+
s.write_all(&req).await?;
471+
472+
// Reply header: VER, REP, RSV, ATYP, BND.ADDR, BND.PORT
473+
let mut head = [0u8; 4];
474+
s.read_exact(&mut head).await?;
475+
if head[0] != 0x05 || head[1] != 0x00 {
476+
return Err(std::io::Error::new(
477+
std::io::ErrorKind::Other,
478+
format!("socks5 connect rejected rep=0x{:02x}", head[1]),
479+
));
480+
}
481+
// Skip BND.ADDR + BND.PORT.
482+
match head[3] {
483+
0x01 => {
484+
let mut b = [0u8; 4 + 2];
485+
s.read_exact(&mut b).await?;
486+
}
487+
0x04 => {
488+
let mut b = [0u8; 16 + 2];
489+
s.read_exact(&mut b).await?;
490+
}
491+
0x03 => {
492+
let mut len = [0u8; 1];
493+
s.read_exact(&mut len).await?;
494+
let mut name = vec![0u8; len[0] as usize + 2];
495+
s.read_exact(&mut name).await?;
496+
}
497+
other => {
498+
return Err(std::io::Error::new(
499+
std::io::ErrorKind::InvalidData,
500+
format!("socks5 bad ATYP in reply: {}", other),
501+
));
502+
}
503+
}
504+
Ok(s)
505+
}
506+
387507
fn looks_like_http(first_bytes: &[u8]) -> bool {
388508
// Cheap sniff: must start with an ASCII HTTP method token followed by a space.
389509
for m in [

0 commit comments

Comments
 (0)