Skip to content

Commit 8a7a86b

Browse files
committed
fix(proxy): preserve SOCKS5 remote DNS semantics
SOCKS5 clients can send domain-name targets with ATYP=0x03, which gives the proxy an unresolved hostname and lets resolution happen on a remote transport path. If that flow later falls through to raw TCP direct passthrough, TcpStream::connect((host, port)) asks the local resolver for the destination address and can expose the target hostname outside the tunnel. The SOCKS5 request handler now marks ATYP=domain flows as requiring remote DNS preservation before handing the stream to the shared tunnel dispatcher. HTTP CONNECT and plain HTTP proxy requests pass the flag disabled, so this guard is tied to SOCKS5 domain-name semantics rather than changing every proxy mode. Raw TCP passthrough now refuses direct hostname fallback when remote DNS is required and no upstream SOCKS5 proxy is available. If an upstream SOCKS5 proxy is configured, the hostname is sent to that proxy unchanged so resolution can remain remote. If the upstream SOCKS5 connection fails for a hostname that requires remote DNS, the proxy returns without falling back to direct local resolution. IP literals remain eligible for direct passthrough because they do not require DNS resolution. Full Tunnel, Apps Script HTTP relay, MITM relay, and SNI-rewrite paths continue to receive the original hostname without introducing local destination lookups. The guide documents the fail-closed behavior for SOCKS5 domain targets, and unit coverage exercises hostname refusal, IPv4 and IPv6 literal allowance, upstream SOCKS5 allowance, and non-SOCKS call-site behavior.
1 parent 40b5386 commit 8a7a86b

3 files changed

Lines changed: 104 additions & 9 deletions

File tree

docs/guide.fa.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,8 @@ upstream_socks5 = "127.0.0.1:50529"
206206

207207
HTTP / HTTPS مثل قبل از Apps Script می‌رود (تغییری نمی‌کند)، تونل بازنویسی SNI برای `google.com` / `youtube.com` همچنان از هر دو دور می‌زند — یوتیوب به سرعت قبل می‌ماند و تلگرام هم تونل واقعی پیدا می‌کند.
208208

209+
هدف‌های دامنه‌ای SOCKS5 معنای remote-DNS را حفظ می‌کنند: اگر پروکسی برای TCP خام مجبور شود hostname را محلی resolve کند و نه Full Tunnel و نه `upstream_socks5` در دسترس باشد، به‌جای نشت DNS plaintext، اتصال را fail-closed می‌کند.
210+
209211
## حالت تونل کامل
210212

211213
`"mode": "full"` **تمام** ترافیک را end-to-end از Apps Script و یک [tunnel-node](../tunnel-node/) راه دور رد می‌کند — بدون نیاز به نصب گواهی MITM. TCP به‌صورت سشن‌های پایدار تونل، و UDP از کلاینت‌های اندروید / TUN از طریق SOCKS5 `UDP ASSOCIATE` به tunnel-node که UDP واقعی را از سمت سرور منتشر می‌کند. مبادله: تأخیر بیشتر هر درخواست (هر بایت Apps Script → tunnel-node → مقصد می‌رود)، اما برای هر پروتکل و هر برنامه‌ای بدون نصب CA کار می‌کند.

docs/guide.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,8 @@ upstream_socks5 = "127.0.0.1:50529"
206206

207207
HTTP / HTTPS keeps going through Apps Script (no change), and the SNI-rewrite tunnel for `google.com` / `youtube.com` keeps bypassing both — YouTube stays as fast as before while Telegram gets a real tunnel.
208208

209+
SOCKS5 domain targets preserve remote-DNS semantics: if the proxy would have to resolve a hostname locally for raw TCP and neither Full Tunnel nor `upstream_socks5` is available, it fails closed instead of leaking a plaintext DNS lookup.
210+
209211
## Full Tunnel mode
210212

211213
`"mode": "full"` routes **all** traffic end-to-end through Apps Script and a remote [tunnel-node](../tunnel-node/) — no MITM certificate needed. TCP carried as persistent tunnel sessions, UDP from Android / TUN clients via SOCKS5 `UDP ASSOCIATE` to the tunnel-node which emits real UDP server-side. Trade-off: higher per-request latency (every byte goes Apps Script → tunnel-node → destination), but works for any protocol and any app, no CA install required.

src/proxy_server.rs

Lines changed: 100 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -832,7 +832,17 @@ async fn handle_http_client(
832832
sock.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
833833
.await?;
834834
sock.flush().await?;
835-
dispatch_tunnel(sock, host, port, fronter, mitm, rewrite_ctx, tunnel_mux).await
835+
dispatch_tunnel(
836+
sock,
837+
host,
838+
port,
839+
fronter,
840+
mitm,
841+
rewrite_ctx,
842+
tunnel_mux,
843+
false,
844+
)
845+
.await
836846
} else {
837847
// Plain HTTP proxy request (e.g. `GET http://…`).
838848
//
@@ -960,7 +970,18 @@ async fn handle_socks5_client(
960970
.await?;
961971
sock.flush().await?;
962972

963-
dispatch_tunnel(sock, host, port, fronter, mitm, rewrite_ctx, tunnel_mux).await
973+
let require_remote_dns = atyp == 0x03;
974+
dispatch_tunnel(
975+
sock,
976+
host,
977+
port,
978+
fronter,
979+
mitm,
980+
rewrite_ctx,
981+
tunnel_mux,
982+
require_remote_dns,
983+
)
984+
.await
964985
}
965986

966987
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
@@ -1626,6 +1647,7 @@ async fn dispatch_tunnel(
16261647
mitm: Arc<Mutex<MitmCertManager>>,
16271648
rewrite_ctx: Arc<RewriteCtx>,
16281649
tunnel_mux: Option<Arc<TunnelMux>>,
1650+
require_remote_dns: bool,
16291651
) -> std::io::Result<()> {
16301652
// 0. User-configured passthrough list wins over every other path.
16311653
// If the host matches `passthrough_hosts`, we raw-TCP it (through
@@ -1641,7 +1663,7 @@ async fn dispatch_tunnel(
16411663
port,
16421664
via.unwrap_or("direct")
16431665
);
1644-
plain_tcp_passthrough(sock, &host, port, via).await;
1666+
plain_tcp_passthrough(sock, &host, port, via, require_remote_dns).await;
16451667
return Ok(());
16461668
}
16471669

@@ -1675,7 +1697,7 @@ async fn dispatch_tunnel(
16751697
port,
16761698
via.unwrap_or("direct")
16771699
);
1678-
plain_tcp_passthrough(sock, &host, port, via).await;
1700+
plain_tcp_passthrough(sock, &host, port, via, require_remote_dns).await;
16791701
return Ok(());
16801702
}
16811703

@@ -1761,7 +1783,7 @@ async fn dispatch_tunnel(
17611783
port,
17621784
via.unwrap_or("direct")
17631785
);
1764-
plain_tcp_passthrough(sock, &host, port, via).await;
1786+
plain_tcp_passthrough(sock, &host, port, via, require_remote_dns).await;
17651787
return Ok(());
17661788
}
17671789

@@ -1776,7 +1798,14 @@ async fn dispatch_tunnel(
17761798
host,
17771799
port
17781800
);
1779-
plain_tcp_passthrough(sock, &host, port, rewrite_ctx.upstream_socks5.as_deref()).await;
1801+
plain_tcp_passthrough(
1802+
sock,
1803+
&host,
1804+
port,
1805+
rewrite_ctx.upstream_socks5.as_deref(),
1806+
require_remote_dns,
1807+
)
1808+
.await;
17801809
return Ok(());
17811810
}
17821811
};
@@ -1802,7 +1831,7 @@ async fn dispatch_tunnel(
18021831
port,
18031832
via.unwrap_or("direct")
18041833
);
1805-
plain_tcp_passthrough(sock, &host, port, via).await;
1834+
plain_tcp_passthrough(sock, &host, port, via, require_remote_dns).await;
18061835
return Ok(());
18071836
}
18081837
};
@@ -1843,7 +1872,7 @@ async fn dispatch_tunnel(
18431872
port,
18441873
via.unwrap_or("direct")
18451874
);
1846-
plain_tcp_passthrough(sock, &host, port, via).await;
1875+
plain_tcp_passthrough(sock, &host, port, via, require_remote_dns).await;
18471876
Ok(())
18481877
}
18491878

@@ -1854,8 +1883,18 @@ async fn plain_tcp_passthrough(
18541883
host: &str,
18551884
port: u16,
18561885
upstream_socks5: Option<&str>,
1886+
require_remote_dns: bool,
18571887
) {
18581888
let target_host = host.trim_start_matches('[').trim_end_matches(']');
1889+
let target_is_ip = looks_like_ip(target_host);
1890+
if should_refuse_local_dns_fallback(require_remote_dns, target_host, upstream_socks5) {
1891+
tracing::warn!(
1892+
"refusing raw-tcp direct fallback for SOCKS5 domain target {}:{} to avoid local DNS resolution",
1893+
host,
1894+
port
1895+
);
1896+
return;
1897+
}
18591898
// Shorter connect timeout for IP literals (4s vs 10s for hostnames).
18601899
// Ported from upstream Python 7b1812c: when the target is an IP (i.e.
18611900
// a raw Telegram DC, or an IP someone hardcoded), and that route is
@@ -1867,7 +1906,7 @@ async fn plain_tcp_passthrough(
18671906
// Hostnames still get 10s because DNS + first-hop TCP genuinely can
18681907
// take that long on flaky links, and the resolver fallbacks already
18691908
// trim the worst case.
1870-
let connect_timeout = if looks_like_ip(target_host) {
1909+
let connect_timeout = if target_is_ip {
18711910
std::time::Duration::from_secs(4)
18721911
} else {
18731912
std::time::Duration::from_secs(10)
@@ -1879,6 +1918,16 @@ async fn plain_tcp_passthrough(
18791918
s
18801919
}
18811920
Err(e) => {
1921+
if require_remote_dns && !target_is_ip {
1922+
tracing::warn!(
1923+
"upstream-socks5 {} -> {}:{} failed: {}; refusing local DNS fallback",
1924+
proxy,
1925+
host,
1926+
port,
1927+
e
1928+
);
1929+
return;
1930+
}
18821931
tracing::warn!(
18831932
"upstream-socks5 {} -> {}:{} failed: {} (falling back to direct)",
18841933
proxy,
@@ -1928,6 +1977,14 @@ async fn plain_tcp_passthrough(
19281977
}
19291978
}
19301979

1980+
fn should_refuse_local_dns_fallback(
1981+
require_remote_dns: bool,
1982+
target_host: &str,
1983+
upstream_socks5: Option<&str>,
1984+
) -> bool {
1985+
require_remote_dns && upstream_socks5.is_none() && !looks_like_ip(target_host)
1986+
}
1987+
19311988
/// Open a TCP stream to `(host, port)` through an upstream SOCKS5 proxy
19321989
/// (no-auth only). Returns the connected stream after SOCKS5 negotiation.
19331990
async fn socks5_connect_via(proxy: &str, host: &str, port: u16) -> std::io::Result<TcpStream> {
@@ -3228,6 +3285,40 @@ mod tests {
32283285
assert_eq!(build_socks5_udp_packet(&target, payload), raw);
32293286
}
32303287

3288+
#[test]
3289+
fn socks5_remote_dns_refuses_direct_hostname_fallback() {
3290+
assert!(should_refuse_local_dns_fallback(
3291+
true,
3292+
"example.com",
3293+
None
3294+
));
3295+
assert!(should_refuse_local_dns_fallback(
3296+
true,
3297+
"sub.example.com",
3298+
None
3299+
));
3300+
}
3301+
3302+
#[test]
3303+
fn socks5_remote_dns_allows_paths_that_do_not_need_local_dns() {
3304+
assert!(!should_refuse_local_dns_fallback(true, "1.2.3.4", None));
3305+
assert!(!should_refuse_local_dns_fallback(
3306+
true,
3307+
"2001:db8::1",
3308+
None
3309+
));
3310+
assert!(!should_refuse_local_dns_fallback(
3311+
true,
3312+
"example.com",
3313+
Some("127.0.0.1:9050")
3314+
));
3315+
assert!(!should_refuse_local_dns_fallback(
3316+
false,
3317+
"example.com",
3318+
None
3319+
));
3320+
}
3321+
32313322
#[tokio::test(flavor = "current_thread")]
32323323
async fn read_body_decodes_chunked_request() {
32333324
let (mut client, mut server) = duplex(1024);

0 commit comments

Comments
 (0)