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.
This commit is contained in:
therealaleph
2026-04-21 20:29:24 +03:00
parent 343def4c88
commit f5397bef43
8 changed files with 309 additions and 61 deletions
Vendored
BIN
View File
Binary file not shown.
Generated
+1 -1
View File
@@ -365,7 +365,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]] [[package]]
name = "mhrv-rs" name = "mhrv-rs"
version = "0.2.2" version = "0.3.0"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "mhrv-rs" name = "mhrv-rs"
version = "0.2.2" version = "0.3.0"
edition = "2021" edition = "2021"
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting" description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
license = "MIT" license = "MIT"
+11 -4
View File
@@ -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 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. - **`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" **Browser (HTTP proxy):**
- **Chrome/Edge**: System proxy settings, or use SwitchyOmega extension - **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 - **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 ## What's implemented vs not
This port focuses on the **`apps_script` mode** which is the only one that reliably works in 2026. Implemented: This port focuses on the **`apps_script` mode** which is the only one that reliably works in 2026. Implemented:
+1
View File
@@ -6,6 +6,7 @@
"auth_key": "CHANGE_ME_TO_A_STRONG_SECRET", "auth_key": "CHANGE_ME_TO_A_STRONG_SECRET",
"listen_host": "127.0.0.1", "listen_host": "127.0.0.1",
"listen_port": 8085, "listen_port": 8085,
"socks5_port": 8086,
"log_level": "info", "log_level": "info",
"verify_ssl": true, "verify_ssl": true,
"hosts": {} "hosts": {}
+2
View File
@@ -44,6 +44,8 @@ pub struct Config {
pub listen_host: String, pub listen_host: String,
#[serde(default = "default_listen_port")] #[serde(default = "default_listen_port")]
pub listen_port: u16, pub listen_port: u16,
#[serde(default)]
pub socks5_port: Option<u16>,
#[serde(default = "default_log_level")] #[serde(default = "default_log_level")]
pub log_level: String, pub log_level: String,
#[serde(default = "default_verify_ssl")] #[serde(default = "default_verify_ssl")]
+3
View File
@@ -176,7 +176,10 @@ async fn main() -> ExitCode {
Command::Serve => {} Command::Serve => {}
} }
let socks5_port = config.socks5_port.unwrap_or(config.listen_port + 1);
tracing::warn!("mhrv-rs {} starting (mode: apps_script)", VERSION); 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!( tracing::info!(
"Apps Script relay: SNI={} -> script.google.com (via {})", "Apps Script relay: SNI={} -> script.google.com (via {})",
config.front_domain, config.front_domain,
+290 -55
View File
@@ -63,6 +63,7 @@ pub enum ProxyError {
pub struct ProxyServer { pub struct ProxyServer {
host: String, host: String,
port: u16, port: u16,
socks5_port: u16,
fronter: Arc<DomainFronter>, fronter: Arc<DomainFronter>,
mitm: Arc<Mutex<MitmCertManager>>, mitm: Arc<Mutex<MitmCertManager>>,
rewrite_ctx: Arc<RewriteCtx>, rewrite_ctx: Arc<RewriteCtx>,
@@ -101,9 +102,12 @@ impl ProxyServer {
tls_connector, tls_connector,
}); });
let socks5_port = config.socks5_port.unwrap_or(config.listen_port + 1);
Ok(Self { Ok(Self {
host: config.listen_host.clone(), host: config.listen_host.clone(),
port: config.listen_port, port: config.listen_port,
socks5_port,
fronter: Arc::new(fronter), fronter: Arc::new(fronter),
mitm, mitm,
rewrite_ctx, rewrite_ctx,
@@ -111,19 +115,24 @@ impl ProxyServer {
} }
pub async fn run(self) -> Result<(), ProxyError> { pub async fn run(self) -> Result<(), ProxyError> {
let addr = format!("{}:{}", self.host, self.port); let http_addr = format!("{}:{}", self.host, self.port);
let listener = TcpListener::bind(&addr).await?; 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!( tracing::warn!(
"Listening on {} — set your browser HTTP proxy to this address.", "Listening HTTP on {} — set your browser HTTP proxy to this address.",
addr 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(); let stats_fronter = self.fronter.clone();
tokio::spawn(async move { tokio::spawn(async move {
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60)); let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
ticker.tick().await; // drop the immediate first tick ticker.tick().await;
loop { loop {
ticker.tick().await; ticker.tick().await;
let s = stats_fronter.snapshot_stats(); let s = stats_fronter.snapshot_stats();
@@ -133,34 +142,65 @@ impl ProxyServer {
} }
}); });
loop { let http_fronter = self.fronter.clone();
let (sock, peer) = match listener.accept().await { let http_mitm = self.mitm.clone();
Ok(x) => x, let http_ctx = self.rewrite_ctx.clone();
Err(e) => { let http_task = tokio::spawn(async move {
tracing::error!("accept error: {}", e); loop {
continue; let (sock, peer) = match http_listener.accept().await {
} Ok(x) => x,
}; Err(e) => {
let _ = sock.set_nodelay(true); tracing::error!("accept (http): {}", e);
let fronter = self.fronter.clone(); continue;
let mitm = self.mitm.clone(); }
let rewrite_ctx = self.rewrite_ctx.clone(); };
tokio::spawn(async move { let _ = sock.set_nodelay(true);
if let Err(e) = handle_client(sock, fronter, mitm, rewrite_ctx).await { let fronter = http_fronter.clone();
tracing::debug!("client {} closed: {}", peer, e); 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, mut sock: TcpStream,
fronter: Arc<DomainFronter>, fronter: Arc<DomainFronter>,
mitm: Arc<Mutex<MitmCertManager>>, mitm: Arc<Mutex<MitmCertManager>>,
rewrite_ctx: Arc<RewriteCtx>, rewrite_ctx: Arc<RewriteCtx>,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
// Read the first request (head only).
let (head, leftover) = match read_http_head(&mut sock).await? { let (head, leftover) = match read_http_head(&mut sock).await? {
Some(v) => v, Some(v) => v,
None => return Ok(()), None => return Ok(()),
@@ -171,16 +211,195 @@ async fn handle_client(
if method.eq_ignore_ascii_case("CONNECT") { if method.eq_ignore_ascii_case("CONNECT") {
let (host, port) = parse_host_port(&target); let (host, port) = parse_host_port(&target);
if matches_sni_rewrite(&host) || hosts_override(&rewrite_ctx.hosts, &host).is_some() { sock.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n").await?;
do_sni_rewrite_connect(sock, &host, port, mitm, rewrite_ctx).await sock.flush().await?;
} else { dispatch_tunnel(sock, host, port, fronter, mitm, rewrite_ctx).await
do_connect(sock, &target, fronter, mitm).await
}
} else { } else {
do_plain_http(sock, &head, &leftover, fronter).await do_plain_http(sock, &head, &leftover, fronter).await
} }
} }
// ---------- SOCKS5 ----------
async fn handle_socks5_client(
mut sock: TcpStream,
fronter: Arc<DomainFronter>,
mitm: Arc<Mutex<MitmCertManager>>,
rewrite_ctx: Arc<RewriteCtx>,
) -> 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<DomainFronter>,
mitm: Arc<Mutex<MitmCertManager>>,
rewrite_ctx: Arc<RewriteCtx>,
) -> 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. /// 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 /// Returns (head_bytes, leftover_after_head). The leftover may contain part
/// of the request body already received. /// of the request body already received.
@@ -260,26 +479,22 @@ fn is_valid_http_method(m: &str) -> bool {
// ---------- CONNECT handling ---------- // ---------- CONNECT handling ----------
async fn do_connect( async fn run_mitm_then_relay(
mut sock: TcpStream, sock: TcpStream,
target: &str, host: &str,
fronter: Arc<DomainFronter>, port: u16,
mitm: Arc<Mutex<MitmCertManager>>, mitm: Arc<Mutex<MitmCertManager>>,
) -> std::io::Result<()> { fronter: &DomainFronter,
let (host, port) = parse_host_port(target); ) {
tracing::info!("CONNECT -> {}:{}", host, port); 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 server_config = {
let mut m = mitm.lock().await; let mut m = mitm.lock().await;
match m.get_server_config(&host) { match m.get_server_config(host) {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
tracing::error!("cert gen failed for {}: {}", host, e); tracing::error!("cert gen failed for {}: {}", host, e);
return Ok(()); return;
} }
} }
}; };
@@ -289,13 +504,14 @@ async fn do_connect(
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
tracing::debug!("TLS accept failed for {}: {}", host, e); tracing::debug!("TLS accept failed for {}: {}", host, e);
return Ok(()); return;
} }
}; };
// Keep-alive loop: read HTTP requests from the decrypted stream. // Keep-alive loop: read HTTP requests from the decrypted stream.
// scheme=https because we MITM-terminated TLS.
loop { 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(true) => continue,
Ok(false) => break, Ok(false) => break,
Err(e) => { 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, mut sock: TcpStream,
host: &str, host: &str,
port: u16, 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<Mutex<MitmCertManager>>, mitm: Arc<Mutex<MitmCertManager>>,
rewrite_ctx: Arc<RewriteCtx>, rewrite_ctx: Arc<RewriteCtx>,
) -> std::io::Result<()> { ) -> 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) let target_ip = hosts_override(&rewrite_ctx.hosts, host)
.map(|s| s.to_string()) .map(|s| s.to_string())
.unwrap_or_else(|| rewrite_ctx.google_ip.clone()); .unwrap_or_else(|| rewrite_ctx.google_ip.clone());
@@ -457,6 +690,7 @@ async fn handle_mitm_request<S>(
host: &str, host: &str,
port: u16, port: u16,
fronter: &DomainFronter, fronter: &DomainFronter,
scheme: &str,
) -> std::io::Result<bool> ) -> std::io::Result<bool>
where where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
@@ -474,13 +708,14 @@ where
// Read body if content-length is set. // Read body if content-length is set.
let body = read_body(stream, &leftover, &headers).await?; let body = read_body(stream, &leftover, &headers).await?;
let url = if port == 443 { let default_port = if scheme == "https" { 443 } else { 80 };
format!("https://{}{}", host, path) let url = if port == default_port {
format!("{}://{}{}", scheme, host, path)
} else { } 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; let response = fronter.relay(&method, &url, &headers, &body).await;
stream.write_all(&response).await?; stream.write_all(&response).await?;