diff --git a/Cargo.lock b/Cargo.lock index c461f76..b1a7d1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1360,7 +1360,7 @@ dependencies = [ [[package]] name = "mhrv-rs" -version = "0.4.4" +version = "0.5.0" dependencies = [ "base64 0.22.1", "bytes", diff --git a/Cargo.toml b/Cargo.toml index f64dfcd..f5ecbcd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 839e489..c6137c2 100644 --- a/README.md +++ b/README.md @@ -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` / … هم سر جای خودش است — پس یوتوب مثل قبل سریع می‌ماند و تلگرام بالاخره یک تونل واقعی می‌گیرد. ### محدودیت‌های شناخته‌شده diff --git a/src/bin/ui.rs b/src/bin/ui.rs index b49d826..756faaf 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -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, + #[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) diff --git a/src/config.rs b/src/config.rs index 4122667..2f09758 100644 --- a/src/config.rs +++ b/src/config.rs @@ -54,6 +54,14 @@ pub struct Config { pub hosts: HashMap, #[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, } fn default_google_ip() -> String { diff --git a/src/proxy_server.rs b/src/proxy_server.rs index 83b000b..3f00387 100644 --- a/src/proxy_server.rs +++ b/src/proxy_server.rs @@ -74,6 +74,7 @@ pub struct RewriteCtx { pub front_domain: String, pub hosts: std::collections::HashMap, pub tls_connector: TlsConnector, + pub upstream_socks5: Option, } 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 { + 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 = Vec::with_capacity(8 + host.len()); + req.extend_from_slice(&[0x05, 0x01, 0x00]); + if let Ok(v4) = host.parse::() { + req.push(0x01); + req.extend_from_slice(&v4.octets()); + } else if let Ok(v6) = host.parse::() { + 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 [