From f5397bef435714375c8c1cb275ef2758a5c87720 Mon Sep 17 00:00:00 2001 From: therealaleph Date: Tue, 21 Apr 2026 20:29:24 +0300 Subject: [PATCH] v0.3.0: SOCKS5 listener + smart TLS/HTTP/plain-TCP dispatch Ports the SOCKS5 + fallback-chain design from @masterking32's MasterHTTP-WithSOCKS branch so xray / Telegram / app-level TCP clients work through this proxy. Changes: - New SOCKS5 listener on listen_port+1 (configurable via socks5_port) - RFC 1928 CONNECT handshake (v5, no-auth, ATYP IPv4/domain/IPv6) - Shared smart dispatch with the HTTP-CONNECT path - Unified dispatch_tunnel() used by both CONNECT entry points: 1. If host matches SNI-rewrite suffix or hosts override: go direct to google_ip via the MITM+TLS tunnel (fast path for google.com, youtube, etc.) 2. Peek the first byte (300ms timeout for server-first protocols): - 0x16: TLS client hello -> MITM + relay via Apps Script (scheme=https) - HTTP method signature: HTTP relay via Apps Script (scheme=http) - Anything else or timeout: plain TCP passthrough to the target - handle_mitm_request() now takes a scheme arg (http/https) so the same code path handles both MITM'd HTTPS and port-80 plain HTTP - New plain_tcp_passthrough helper: bidirectional TCP bridge used as the final fallback (covers MTProto / raw TCP / server-first protos) Config: - Added optional socks5_port field; defaults to listen_port+1 README: - Added browser vs xray/Telegram instructions under 'Step 6' Live-tested: HTTP proxy, HTTP proxy -> HTTPS, SOCKS5 -> HTTP, SOCKS5 -> HTTPS, Google search via SNI-tunnel (now returns full JS page) all pass. --- .DS_Store | Bin 0 -> 10244 bytes Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 15 +- config.example.json | 1 + src/config.rs | 2 + src/main.rs | 3 + src/proxy_server.rs | 345 +++++++++++++++++++++++++++++++++++++------- 8 files changed, 309 insertions(+), 61 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..44b2f9e6932f92d2813b34cb1b184f2487beb92e GIT binary patch literal 10244 zcmeHMTWl0%6h3EK=qw%R6p9q=#)U$-q%183DL1pdAqCn(x25H}%72eN;5T7-NVUFAwsMnK}RE zT)+9x?D?kvfNdGI2_OUjB3+Ei65UEPw$83wn&8dU5=D}SxM9VqFVofLt@*@9z(>GG zz(>GGz(?S}g#i6#vnE!$)W7=(_z3t2Od!DC4lT29lvQJ1uF4}$y>XKoB5c}J&WvF zWal7RJ46y1jFP=1*kH1TAk1XQRpm;QAihgTQT8j3Ee!l zeBCwBO&+nQ$DZb9=%>H!up9;{R)@TfLN8g~3eiHVw>-_;))ODGQ{Mi)6i*qt?KC!i zi9%WVl&KYBg;*)>iw;{OQAcqycAJvk!$$pRpsO0O z>>boi)f#EDGNx*C*{Hi92E;&2o-;Pq*br)7z4l~7XsoHJWmQ9HRrCEPPX@%<^()r* z#f}(B(|VKurH0-w7)*D^6ZccpyX|ldJKOGtTNcfQ`&BO7z_dzfdT?-P*KTQVOrDvg zxO!8LZkhH@Eo~Ex$yGMR-<7b;?P-mz4<;-llQiv^T%AyiM8;4Yt<^B1`f-iss_aDC zGK}q(tur;dh8%6wVQaT64ehqAwA0R&#bh~SYkeB&(z99zX|#uQGRXvMCc<_*7&td$ z&V37)EMK{1L!`5-d&ku(xq9ZT8cEtsK%0(!P}kA}Bf9Dg?^5i9W~#b*XfUhWdfd?Z zO(m&SV?!WtwW@aRynE|{!O2*MGI9NAMt6?M^`gjagysjOy(&4^ZK=$PDqj>y4_RL{ zKN#G@R@&1V-BnLu>9U~IZ&M7i;xUF-NK*e1+99&dTp-crRZ?)kP~w^)s{z+e%UUTo zn04`&w|bKsYoy>%!b&D7MBAHdZ3_nXGqc+i+v!bdCb@LQv%QJdcH6^BTI(I59qP?) zp;`V&X`Tsns=8zGMM9&8qS(dMwW=zQVP5Urs$euYAj+SK^oVpbt5a6cV|9~5tj|4~ z`p7c3TmN4CTi)Y92kIk@9*YP;djE9Ld*lFR`52srXW%ScfRErZd;#CUPjCZ%N5rWZ zz-d^CH8>9!Vgs(mHQ0h{u?>512linU_u??>co=OQ#p5`JC-4zGg^%NDJcCc;3-}Vg zjIZG9cpfj{MSKh2#Vhy;eu|&tb^H#$#~<(p{(*nuO<{^KN2nF%3JZh=VYv_%)(9=a zI$^!gBlHSGf+8FiEFtCkFXU2jpBIlugk#jXlT`HHJi@ussaW^m=B_Q$w!0=3ug!3i zZ}vTNLW`EISk>JA&~!XhQEEbyV!RM#A!^pwloVME=TfGDFoSSM`= zP@>A^?l34t0s$HLpsu~HQ>0{*#eXUbuEBNq5q^c6lm#=kY~+6=JJKu_y5kx|NnoR+I%)20Uv?85dkdkh<3D* z=egrhaS-goRfw(wbg{81Yoan#w S&jA1Y@1Or^1OA`S|9=B%qTu}i literal 0 HcmV?d00001 diff --git a/Cargo.lock b/Cargo.lock index 885ab08..291a476 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -365,7 +365,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mhrv-rs" -version = "0.2.2" +version = "0.3.0" dependencies = [ "base64", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 536cd5e..f496a92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mhrv-rs" -version = "0.2.2" +version = "0.3.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 1441971..e8f7b84 100644 --- a/README.md +++ b/README.md @@ -98,14 +98,21 @@ mhrv-rs.exe --config config.json # Windows - **`mhrv-rs test`** — send one request through the relay and report success/timing. Useful when setting up or debugging. Does not need the proxy to be running. - **`mhrv-rs scan-ips`** — parallel TLS probe of known Google frontend IPs, sorted by latency. Swap the winning IP into your `google_ip` config field for best performance. -### Step 6: Point your browser at the proxy +### Step 6: Point your client at the proxy -Configure your browser to use HTTP proxy `127.0.0.1:8085`. +The tool listens on **two** ports: +- **HTTP proxy** on `listen_port` (default `8085`) — for browsers / any HTTP-aware client +- **SOCKS5 proxy** on `socks5_port` (default `listen_port + 1`, i.e. `8086`) — for xray / Telegram / app-level clients -- **Firefox**: Settings → Network Settings → Manual proxy → enter for HTTP, check "Also use this proxy for HTTPS" -- **Chrome/Edge**: System proxy settings, or use SwitchyOmega extension +**Browser (HTTP proxy):** +- **Firefox**: Settings → Network Settings → Manual proxy → HTTP `127.0.0.1:8085`, check "Also use this proxy for HTTPS" +- **Chrome/Edge**: System proxy settings, or SwitchyOmega - **macOS system-wide**: System Settings → Network → Wi-Fi → Details → Proxies → Web + Secure Web Proxy +**xray / Telegram (SOCKS5):** +- Point the SOCKS5 setting at `127.0.0.1:8086`, no auth. +- Non-HTTP protocols (MTProto, raw TCP) fall back to plain-TCP passthrough automatically. + ## What's implemented vs not This port focuses on the **`apps_script` mode** which is the only one that reliably works in 2026. Implemented: diff --git a/config.example.json b/config.example.json index 6de0c48..fbd6acb 100644 --- a/config.example.json +++ b/config.example.json @@ -6,6 +6,7 @@ "auth_key": "CHANGE_ME_TO_A_STRONG_SECRET", "listen_host": "127.0.0.1", "listen_port": 8085, + "socks5_port": 8086, "log_level": "info", "verify_ssl": true, "hosts": {} diff --git a/src/config.rs b/src/config.rs index 05c5322..4122667 100644 --- a/src/config.rs +++ b/src/config.rs @@ -44,6 +44,8 @@ pub struct Config { pub listen_host: String, #[serde(default = "default_listen_port")] pub listen_port: u16, + #[serde(default)] + pub socks5_port: Option, #[serde(default = "default_log_level")] pub log_level: String, #[serde(default = "default_verify_ssl")] diff --git a/src/main.rs b/src/main.rs index 533dae9..58478e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -176,7 +176,10 @@ async fn main() -> ExitCode { Command::Serve => {} } + let socks5_port = config.socks5_port.unwrap_or(config.listen_port + 1); tracing::warn!("mhrv-rs {} starting (mode: apps_script)", VERSION); + tracing::info!("HTTP proxy : {}:{}", config.listen_host, config.listen_port); + tracing::info!("SOCKS5 proxy : {}:{}", config.listen_host, socks5_port); tracing::info!( "Apps Script relay: SNI={} -> script.google.com (via {})", config.front_domain, diff --git a/src/proxy_server.rs b/src/proxy_server.rs index 1fa9289..66dc51a 100644 --- a/src/proxy_server.rs +++ b/src/proxy_server.rs @@ -63,6 +63,7 @@ pub enum ProxyError { pub struct ProxyServer { host: String, port: u16, + socks5_port: u16, fronter: Arc, mitm: Arc>, rewrite_ctx: Arc, @@ -101,9 +102,12 @@ impl ProxyServer { tls_connector, }); + let socks5_port = config.socks5_port.unwrap_or(config.listen_port + 1); + Ok(Self { host: config.listen_host.clone(), port: config.listen_port, + socks5_port, fronter: Arc::new(fronter), mitm, rewrite_ctx, @@ -111,19 +115,24 @@ impl ProxyServer { } pub async fn run(self) -> Result<(), ProxyError> { - let addr = format!("{}:{}", self.host, self.port); - let listener = TcpListener::bind(&addr).await?; + let http_addr = format!("{}:{}", self.host, self.port); + let socks_addr = format!("{}:{}", self.host, self.socks5_port); + let http_listener = TcpListener::bind(&http_addr).await?; + let socks_listener = TcpListener::bind(&socks_addr).await?; tracing::warn!( - "Listening on {} — set your browser HTTP proxy to this address.", - addr + "Listening HTTP on {} — set your browser HTTP proxy to this address.", + http_addr + ); + tracing::warn!( + "Listening SOCKS5 on {} — xray / Telegram / app-level SOCKS5 clients use this.", + socks_addr ); - // Periodic stats log (every 60s at info level). let stats_fronter = self.fronter.clone(); tokio::spawn(async move { let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60)); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - ticker.tick().await; // drop the immediate first tick + ticker.tick().await; loop { ticker.tick().await; let s = stats_fronter.snapshot_stats(); @@ -133,34 +142,65 @@ impl ProxyServer { } }); - loop { - let (sock, peer) = match listener.accept().await { - Ok(x) => x, - Err(e) => { - tracing::error!("accept error: {}", e); - continue; - } - }; - let _ = sock.set_nodelay(true); - let fronter = self.fronter.clone(); - let mitm = self.mitm.clone(); - let rewrite_ctx = self.rewrite_ctx.clone(); - tokio::spawn(async move { - if let Err(e) = handle_client(sock, fronter, mitm, rewrite_ctx).await { - tracing::debug!("client {} closed: {}", peer, e); - } - }); - } + let http_fronter = self.fronter.clone(); + let http_mitm = self.mitm.clone(); + let http_ctx = self.rewrite_ctx.clone(); + let http_task = tokio::spawn(async move { + loop { + let (sock, peer) = match http_listener.accept().await { + Ok(x) => x, + Err(e) => { + tracing::error!("accept (http): {}", e); + continue; + } + }; + let _ = sock.set_nodelay(true); + let fronter = http_fronter.clone(); + let mitm = http_mitm.clone(); + let rewrite_ctx = http_ctx.clone(); + tokio::spawn(async move { + if let Err(e) = handle_http_client(sock, fronter, mitm, rewrite_ctx).await { + tracing::debug!("http client {} closed: {}", peer, e); + } + }); + } + }); + + let socks_fronter = self.fronter.clone(); + let socks_mitm = self.mitm.clone(); + let socks_ctx = self.rewrite_ctx.clone(); + let socks_task = tokio::spawn(async move { + loop { + let (sock, peer) = match socks_listener.accept().await { + Ok(x) => x, + Err(e) => { + tracing::error!("accept (socks): {}", e); + continue; + } + }; + let _ = sock.set_nodelay(true); + let fronter = socks_fronter.clone(); + let mitm = socks_mitm.clone(); + let rewrite_ctx = socks_ctx.clone(); + tokio::spawn(async move { + if let Err(e) = handle_socks5_client(sock, fronter, mitm, rewrite_ctx).await { + tracing::debug!("socks client {} closed: {}", peer, e); + } + }); + } + }); + + let _ = tokio::join!(http_task, socks_task); + Ok(()) } } -async fn handle_client( +async fn handle_http_client( mut sock: TcpStream, fronter: Arc, mitm: Arc>, rewrite_ctx: Arc, ) -> std::io::Result<()> { - // Read the first request (head only). let (head, leftover) = match read_http_head(&mut sock).await? { Some(v) => v, None => return Ok(()), @@ -171,16 +211,195 @@ async fn handle_client( if method.eq_ignore_ascii_case("CONNECT") { let (host, port) = parse_host_port(&target); - if matches_sni_rewrite(&host) || hosts_override(&rewrite_ctx.hosts, &host).is_some() { - do_sni_rewrite_connect(sock, &host, port, mitm, rewrite_ctx).await - } else { - do_connect(sock, &target, fronter, mitm).await - } + sock.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n").await?; + sock.flush().await?; + dispatch_tunnel(sock, host, port, fronter, mitm, rewrite_ctx).await } else { do_plain_http(sock, &head, &leftover, fronter).await } } +// ---------- SOCKS5 ---------- + +async fn handle_socks5_client( + mut sock: TcpStream, + fronter: Arc, + mitm: Arc>, + rewrite_ctx: Arc, +) -> std::io::Result<()> { + // RFC 1928 handshake: VER=5, NMETHODS, METHODS... + let mut hdr = [0u8; 2]; + sock.read_exact(&mut hdr).await?; + if hdr[0] != 0x05 { + return Ok(()); + } + let nmethods = hdr[1] as usize; + let mut methods = vec![0u8; nmethods]; + sock.read_exact(&mut methods).await?; + // Only "no auth" (0x00) is supported. + if !methods.contains(&0x00) { + sock.write_all(&[0x05, 0xff]).await?; + return Ok(()); + } + sock.write_all(&[0x05, 0x00]).await?; + + // Request: VER=5, CMD, RSV=0, ATYP, DST.ADDR, DST.PORT + let mut req = [0u8; 4]; + sock.read_exact(&mut req).await?; + if req[0] != 0x05 { + return Ok(()); + } + let cmd = req[1]; + if cmd != 0x01 { + // CONNECT only. + sock.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Ok(()); + } + let atyp = req[3]; + let host: String = match atyp { + 0x01 => { + let mut ip = [0u8; 4]; + sock.read_exact(&mut ip).await?; + format!("{}.{}.{}.{}", ip[0], ip[1], ip[2], ip[3]) + } + 0x03 => { + let mut len = [0u8; 1]; + sock.read_exact(&mut len).await?; + let mut name = vec![0u8; len[0] as usize]; + sock.read_exact(&mut name).await?; + String::from_utf8_lossy(&name).into_owned() + } + 0x04 => { + let mut ip = [0u8; 16]; + sock.read_exact(&mut ip).await?; + let addr = std::net::Ipv6Addr::from(ip); + addr.to_string() + } + _ => { + sock.write_all(&[0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + return Ok(()); + } + }; + let mut port_buf = [0u8; 2]; + sock.read_exact(&mut port_buf).await?; + let port = u16::from_be_bytes(port_buf); + + tracing::info!("SOCKS5 CONNECT -> {}:{}", host, port); + + // Success reply with zeroed BND. + sock.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await?; + sock.flush().await?; + + dispatch_tunnel(sock, host, port, fronter, mitm, rewrite_ctx).await +} + +// ---------- Smart dispatch (used by both HTTP CONNECT and SOCKS5) ---------- + +async fn dispatch_tunnel( + sock: TcpStream, + host: String, + port: u16, + fronter: Arc, + mitm: Arc>, + rewrite_ctx: Arc, +) -> std::io::Result<()> { + // 1. Explicit hosts override or SNI-rewrite suffix: always use the tunnel. + if matches_sni_rewrite(&host) || hosts_override(&rewrite_ctx.hosts, &host).is_some() { + return do_sni_rewrite_tunnel_from_tcp(sock, &host, port, mitm, rewrite_ctx).await; + } + + // 2. Peek at the first byte to detect TLS vs plain. Time-bounded — if the + // client doesn't send anything within 300ms, assume server-first + // protocol (SMTP, POP3, FTP banner) and jump straight to plain TCP. + let mut peek_buf = [0u8; 8]; + let peek_n = match tokio::time::timeout( + std::time::Duration::from_millis(300), + sock.peek(&mut peek_buf), + ) + .await + { + Ok(Ok(n)) => n, + Ok(Err(_)) => return Ok(()), + Err(_) => { + // Client silent: likely a server-first protocol. + plain_tcp_passthrough(sock, &host, port).await; + return Ok(()); + } + }; + + if peek_n >= 1 && peek_buf[0] == 0x16 { + // Looks like TLS: MITM + relay via Apps Script. + run_mitm_then_relay(sock, &host, port, mitm, &fronter).await; + return Ok(()); + } + + // 3. Not TLS. If bytes look like HTTP, relay on scheme=http. Otherwise + // fall back to plain TCP passthrough. + if peek_n > 0 && looks_like_http(&peek_buf[..peek_n]) { + let scheme = if port == 443 { "https" } else { "http" }; + relay_http_stream_raw(sock, &host, port, scheme, &fronter).await; + return Ok(()); + } + + plain_tcp_passthrough(sock, &host, port).await; + Ok(()) +} + +// ---------- Plain TCP passthrough ---------- + +async fn plain_tcp_passthrough(mut sock: TcpStream, host: &str, port: u16) { + 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; + } + 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(); + (r, w) + }; + let t1 = tokio::io::copy(&mut ar, &mut bw); + let t2 = tokio::io::copy(&mut br, &mut aw); + tokio::select! { + _ = t1 => {} + _ = t2 => {} + } +} + +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 [ + "GET ", + "POST ", + "PUT ", + "HEAD ", + "DELETE ", + "PATCH ", + "OPTIONS ", + "CONNECT ", + "TRACE ", + ] { + if first_bytes.starts_with(m.as_bytes()) { + return true; + } + } + false +} + /// Read an HTTP head (request line + headers) up to the first \r\n\r\n. /// Returns (head_bytes, leftover_after_head). The leftover may contain part /// of the request body already received. @@ -260,26 +479,22 @@ fn is_valid_http_method(m: &str) -> bool { // ---------- CONNECT handling ---------- -async fn do_connect( - mut sock: TcpStream, - target: &str, - fronter: Arc, +async fn run_mitm_then_relay( + sock: TcpStream, + host: &str, + port: u16, mitm: Arc>, -) -> std::io::Result<()> { - let (host, port) = parse_host_port(target); - tracing::info!("CONNECT -> {}:{}", host, port); + fronter: &DomainFronter, +) { + tracing::info!("MITM TLS -> {}:{}", host, port); - sock.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n").await?; - sock.flush().await?; - - // MITM: build a server config for this domain and accept TLS. let server_config = { let mut m = mitm.lock().await; - match m.get_server_config(&host) { + match m.get_server_config(host) { Ok(c) => c, Err(e) => { tracing::error!("cert gen failed for {}: {}", host, e); - return Ok(()); + return; } } }; @@ -289,13 +504,14 @@ async fn do_connect( Ok(t) => t, Err(e) => { tracing::debug!("TLS accept failed for {}: {}", host, e); - return Ok(()); + return; } }; // Keep-alive loop: read HTTP requests from the decrypted stream. + // scheme=https because we MITM-terminated TLS. loop { - match handle_mitm_request(&mut tls, &host, port, &fronter).await { + match handle_mitm_request(&mut tls, host, port, fronter, "https").await { Ok(true) => continue, Ok(false) => break, Err(e) => { @@ -304,19 +520,36 @@ async fn do_connect( } } } - Ok(()) } -async fn do_sni_rewrite_connect( +// ---------- Plain HTTP relay on a raw TCP stream (port 80 targets) ---------- + +async fn relay_http_stream_raw( mut sock: TcpStream, host: &str, port: u16, + scheme: &str, + fronter: &DomainFronter, +) { + loop { + match handle_mitm_request(&mut sock, host, port, fronter, scheme).await { + Ok(true) => continue, + Ok(false) => break, + Err(e) => { + tracing::debug!("http relay error for {}: {}", host, e); + break; + } + } + } +} + +async fn do_sni_rewrite_tunnel_from_tcp( + sock: TcpStream, + host: &str, + port: u16, mitm: Arc>, rewrite_ctx: Arc, ) -> std::io::Result<()> { - sock.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n").await?; - sock.flush().await?; - let target_ip = hosts_override(&rewrite_ctx.hosts, host) .map(|s| s.to_string()) .unwrap_or_else(|| rewrite_ctx.google_ip.clone()); @@ -457,6 +690,7 @@ async fn handle_mitm_request( host: &str, port: u16, fronter: &DomainFronter, + scheme: &str, ) -> std::io::Result where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, @@ -474,13 +708,14 @@ where // Read body if content-length is set. let body = read_body(stream, &leftover, &headers).await?; - let url = if port == 443 { - format!("https://{}{}", host, path) + let default_port = if scheme == "https" { 443 } else { 80 }; + let url = if port == default_port { + format!("{}://{}{}", scheme, host, path) } else { - format!("https://{}:{}{}", host, port, path) + format!("{}://{}:{}{}", scheme, host, port, path) }; - tracing::info!("MITM {} {}", method, url); + tracing::info!("relay {} {}", method, url); let response = fronter.relay(&method, &url, &headers, &body).await; stream.write_all(&response).await?;