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).
This commit is contained in:
therealaleph
2026-04-22 01:49:21 +03:00
parent 68effd2477
commit e575bf6bf4
6 changed files with 218 additions and 21 deletions
Generated
+1 -1
View File
@@ -1360,7 +1360,7 @@ dependencies = [
[[package]]
name = "mhrv-rs"
version = "0.4.4"
version = "0.5.0"
dependencies = [
"base64 0.22.1",
"bytes",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "mhrv-rs"
version = "0.4.4"
version = "0.5.0"
edition = "2021"
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
license = "MIT"
+47 -2
View File
@@ -187,7 +187,29 @@ The tool listens on **two** ports. Use whichever your client supports:
**SOCKS5 proxy** (Telegram, xray, app-level clients) — `127.0.0.1:8086`, no auth.
- 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.
- 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.
## Telegram, IMAP, SSH — pair with xray (optional)
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.
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.
```
Telegram ┐ ┌─ Apps Script ── HTTP/HTTPS
├─ SOCKS5 :8086 ─┤ mhrv-rs ├─ SNI rewrite ─── google.com, youtube.com, …
Browser ┘ └─ upstream SOCKS5 ─ xray ── VLESS ── your VPS (Telegram, IMAP, SSH, raw TCP)
```
Example config fragment (both UI and JSON):
```json
{
"upstream_socks5": "127.0.0.1:50529"
}
```
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.
## Diagnostics
@@ -217,6 +239,7 @@ This port focuses on the **`apps_script` mode** — the only one that reliably w
- [x] `test` and `scan-ips` subcommands
- [x] Script IDs masked in logs (`prefix…suffix`) so `info` logs don't leak deployment IDs
- [x] Desktop UI (egui) — cross-platform, no bundler needed
- [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.
Intentionally **not** implemented (rationale included so future contributors don't spend cycles on them):
@@ -382,7 +405,29 @@ Firefox cert store خودش را جدا دارد؛ installer تلاش می‌ک
**SOCKS5 proxy** (تلگرام، xray، کلاینت‌های app-level) — `127.0.0.1:8086`، بدون auth.
برای HTTP و HTTPS و **هم** پروتکل‌های غیر-HTTP (MTProto تلگرام، TCP خام) کار می‌کند. ابزار به‌صورت هوشمند تشخیص می‌دهد و اگر ترافیک HTTP نبود، به‌صورت plain TCP passthrough می‌فرستد.
برای HTTP و HTTPS و **هم** پروتکل‌های غیر-HTTP (MTProto تلگرام، TCP خام) کار می‌کند. ابزار به‌صورت هوشمند تشخیص می‌دهد: HTTP/HTTPS از رلهٔ Apps Script می‌رود، دامنه‌های قابل SNI-rewrite از تونل مستقیم لبهٔ گوگل، و بقیه به TCP خام می‌افتد.
### تلگرام، IMAP، SSH — با xray جفت کنید (اختیاری)
رلهٔ Apps Script فقط HTTP request/response می‌فهمد، پس پروتکل‌های غیر-HTTP (MTProto تلگرام، IMAP، SSH، TCP خام) از داخلش عبور نمی‌کنند. بدون کار اضافه این جور ترافیک به مسیر TCP مستقیم می‌افتد — یعنی واقعاً tunnel نمی‌شود و اگر ISP تلگرام را بلاک کرده باشد، همچنان بلاک است.
راه حل: یک [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 رد شده و به تونل واقعی می‌رسد.
```
تلگرام ┐ ┌─ Apps Script ── HTTP/HTTPS
├─ SOCKS5 :8086 ┤ mhrv-rs ├─ SNI rewrite ─── google.com, youtube.com, …
مرورگر ┘ └─ upstream SOCKS5 ─ xray ── VLESS ── VPS شما (تلگرام، IMAP، SSH، TCP خام)
```
قطعه‌ای از config:
```json
{
"upstream_socks5": "127.0.0.1:50529"
}
```
HTTP/HTTPS هیچ تغییری نمی‌کند (همچنان از Apps Script می‌رود) و تونل SNI-rewrite برای `google.com` / `youtube.com` / … هم سر جای خودش است — پس یوتوب مثل قبل سریع می‌ماند و تلگرام بالاخره یک تونل واقعی می‌گیرد.
### محدودیت‌های شناخته‌شده
+24
View File
@@ -104,6 +104,7 @@ struct FormState {
socks5_port: String,
log_level: String,
verify_ssl: bool,
upstream_socks5: String,
show_auth_key: bool,
}
@@ -137,6 +138,7 @@ fn load_form() -> FormState {
socks5_port: c.socks5_port.map(|p| p.to_string()).unwrap_or_default(),
log_level: c.log_level,
verify_ssl: c.verify_ssl,
upstream_socks5: c.upstream_socks5.unwrap_or_default(),
show_auth_key: false,
}
} else {
@@ -150,6 +152,7 @@ fn load_form() -> FormState {
socks5_port: "8086".into(),
log_level: "info".into(),
verify_ssl: true,
upstream_socks5: String::new(),
show_auth_key: false,
}
}
@@ -201,6 +204,10 @@ impl FormState {
verify_ssl: self.verify_ssl,
hosts: std::collections::HashMap::new(),
enable_batching: false,
upstream_socks5: {
let v = self.upstream_socks5.trim();
if v.is_empty() { None } else { Some(v.to_string()) }
},
})
}
}
@@ -232,6 +239,8 @@ struct ConfigWire<'a> {
verify_ssl: bool,
#[serde(skip_serializing_if = "std::collections::HashMap::is_empty")]
hosts: &'a std::collections::HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
upstream_socks5: Option<&'a str>,
}
#[derive(serde::Serialize)]
@@ -259,6 +268,7 @@ impl<'a> From<&'a Config> for ConfigWire<'a> {
log_level: c.log_level.as_str(),
verify_ssl: c.verify_ssl,
hosts: &c.hosts,
upstream_socks5: c.upstream_socks5.as_deref(),
}
}
}
@@ -358,6 +368,20 @@ impl eframe::App for App {
ui.add(egui::TextEdit::singleline(&mut self.form.socks5_port).desired_width(80.0));
ui.end_row();
ui.label("Upstream SOCKS5")
.on_hover_text(
"Optional. host:port of an upstream SOCKS5 proxy (e.g. xray / v2ray / sing-box).\n\
When set, non-HTTP / raw-TCP traffic arriving on the SOCKS5 listener is\n\
chained through this proxy instead of connecting directly — this is what\n\
makes Telegram MTProto, IMAP, SSH etc. actually tunnel.\n\
HTTP/HTTPS traffic still routes through the Apps Script relay and the\n\
SNI-rewrite tunnel as before."
);
ui.add(egui::TextEdit::singleline(&mut self.form.upstream_socks5)
.hint_text("empty = direct; 127.0.0.1:50529 for a local xray")
.desired_width(f32::INFINITY));
ui.end_row();
ui.label("Log level");
egui::ComboBox::from_id_source("loglevel")
.selected_text(&self.form.log_level)
+8
View File
@@ -54,6 +54,14 @@ pub struct Config {
pub hosts: HashMap<String, String>,
#[serde(default)]
pub enable_batching: bool,
/// Optional upstream SOCKS5 proxy for non-HTTP / raw-TCP traffic
/// (e.g. `"127.0.0.1:50529"` pointing at a local xray / v2ray instance).
/// When set, the SOCKS5 listener forwards raw-TCP flows through it
/// instead of connecting directly. HTTP/HTTPS traffic (which goes
/// through the Apps Script relay) and SNI-rewrite tunnels are
/// unaffected.
#[serde(default)]
pub upstream_socks5: Option<String>,
}
fn default_google_ip() -> String {
+137 -17
View File
@@ -74,6 +74,7 @@ pub struct RewriteCtx {
pub front_domain: String,
pub hosts: std::collections::HashMap<String, String>,
pub tls_connector: TlsConnector,
pub upstream_socks5: Option<String>,
}
impl ProxyServer {
@@ -100,6 +101,7 @@ impl ProxyServer {
front_domain: config.front_domain.clone(),
hosts: config.hosts.clone(),
tls_connector,
upstream_socks5: config.upstream_socks5.clone(),
});
let socks5_port = config.socks5_port.unwrap_or(config.listen_port + 1);
@@ -326,7 +328,7 @@ async fn dispatch_tunnel(
Ok(Err(_)) => return Ok(()),
Err(_) => {
// Client silent: likely a server-first protocol.
plain_tcp_passthrough(sock, &host, port).await;
plain_tcp_passthrough(sock, &host, port, rewrite_ctx.upstream_socks5.as_deref()).await;
return Ok(());
}
};
@@ -345,32 +347,63 @@ async fn dispatch_tunnel(
return Ok(());
}
plain_tcp_passthrough(sock, &host, port).await;
plain_tcp_passthrough(sock, &host, port, rewrite_ctx.upstream_socks5.as_deref()).await;
Ok(())
}
// ---------- Plain TCP passthrough ----------
async fn plain_tcp_passthrough(mut sock: TcpStream, host: &str, port: u16) {
async fn plain_tcp_passthrough(
mut sock: TcpStream,
host: &str,
port: u16,
upstream_socks5: Option<&str>,
) {
let target_host = host.trim_start_matches('[').trim_end_matches(']');
let upstream = match tokio::time::timeout(
std::time::Duration::from_secs(10),
TcpStream::connect((target_host, port)),
)
.await
{
Ok(Ok(s)) => s,
Ok(Err(e)) => {
tracing::debug!("plain-tcp connect {}:{} failed: {}", host, port, e);
return;
let upstream = if let Some(proxy) = upstream_socks5 {
match socks5_connect_via(proxy, target_host, port).await {
Ok(s) => {
tracing::info!("tcp via upstream-socks5 {} -> {}:{}", proxy, host, port);
s
}
Err(e) => {
tracing::warn!(
"upstream-socks5 {} -> {}:{} failed: {} (falling back to direct)",
proxy, host, port, e
);
match tokio::time::timeout(
std::time::Duration::from_secs(10),
TcpStream::connect((target_host, port)),
)
.await
{
Ok(Ok(s)) => s,
_ => return,
}
}
}
Err(_) => {
tracing::debug!("plain-tcp connect {}:{} timeout", host, port);
return;
} else {
match tokio::time::timeout(
std::time::Duration::from_secs(10),
TcpStream::connect((target_host, port)),
)
.await
{
Ok(Ok(s)) => {
tracing::info!("plain-tcp passthrough -> {}:{}", host, port);
s
}
Ok(Err(e)) => {
tracing::debug!("plain-tcp connect {}:{} failed: {}", host, port, e);
return;
}
Err(_) => {
tracing::debug!("plain-tcp connect {}:{} timeout", host, port);
return;
}
}
};
let _ = upstream.set_nodelay(true);
tracing::info!("plain-tcp passthrough -> {}:{}", host, port);
let (mut ar, mut aw) = sock.split();
let (mut br, mut bw) = {
let (r, w) = upstream.into_split();
@@ -384,6 +417,93 @@ async fn plain_tcp_passthrough(mut sock: TcpStream, host: &str, port: u16) {
}
}
/// Open a TCP stream to `(host, port)` through an upstream SOCKS5 proxy
/// (no-auth only). Returns the connected stream after SOCKS5 negotiation.
async fn socks5_connect_via(
proxy: &str,
host: &str,
port: u16,
) -> std::io::Result<TcpStream> {
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
let mut s = tokio::time::timeout(
std::time::Duration::from_secs(5),
TcpStream::connect(proxy),
)
.await
.map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "connect timeout"))??;
let _ = s.set_nodelay(true);
// Greeting: VER=5, NMETHODS=1, METHOD=no-auth
s.write_all(&[0x05, 0x01, 0x00]).await?;
let mut reply = [0u8; 2];
s.read_exact(&mut reply).await?;
if reply[0] != 0x05 || reply[1] != 0x00 {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!("socks5 greet rejected: {:?}", reply),
));
}
// CONNECT request: VER=5, CMD=1, RSV=0, ATYP=3 (domain) | 1 (IPv4) | 4 (IPv6)
let mut req: Vec<u8> = Vec::with_capacity(8 + host.len());
req.extend_from_slice(&[0x05, 0x01, 0x00]);
if let Ok(v4) = host.parse::<std::net::Ipv4Addr>() {
req.push(0x01);
req.extend_from_slice(&v4.octets());
} else if let Ok(v6) = host.parse::<std::net::Ipv6Addr>() {
req.push(0x04);
req.extend_from_slice(&v6.octets());
} else {
let hb = host.as_bytes();
if hb.len() > 255 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"hostname > 255",
));
}
req.push(0x03);
req.push(hb.len() as u8);
req.extend_from_slice(hb);
}
req.extend_from_slice(&port.to_be_bytes());
s.write_all(&req).await?;
// Reply header: VER, REP, RSV, ATYP, BND.ADDR, BND.PORT
let mut head = [0u8; 4];
s.read_exact(&mut head).await?;
if head[0] != 0x05 || head[1] != 0x00 {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!("socks5 connect rejected rep=0x{:02x}", head[1]),
));
}
// Skip BND.ADDR + BND.PORT.
match head[3] {
0x01 => {
let mut b = [0u8; 4 + 2];
s.read_exact(&mut b).await?;
}
0x04 => {
let mut b = [0u8; 16 + 2];
s.read_exact(&mut b).await?;
}
0x03 => {
let mut len = [0u8; 1];
s.read_exact(&mut len).await?;
let mut name = vec![0u8; len[0] as usize + 2];
s.read_exact(&mut name).await?;
}
other => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("socks5 bad ATYP in reply: {}", other),
));
}
}
Ok(s)
}
fn looks_like_http(first_bytes: &[u8]) -> bool {
// Cheap sniff: must start with an ASCII HTTP method token followed by a space.
for m in [