From 5a46d0e26659dc219a285f558b4e7e9f94f43b56 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:43:32 +0400 Subject: [PATCH] fix: resolve VPN SOCKS5 domain CONNECT requests through the tunnel --- src-tauri/Cargo.toml | 2 +- src-tauri/src/vpn/socks5_server.rs | 144 +++++++++++++++++++++++++---- 2 files changed, 129 insertions(+), 17 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b30e47d..c6e10cf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -107,7 +107,7 @@ quick-xml = { version = "0.40", features = ["serialize"] } # VPN support boringtun = "0.7" -smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] } +smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp", "socket-dns"] } # Tray icon decoding (main-process system tray) image = "0.25" diff --git a/src-tauri/src/vpn/socks5_server.rs b/src-tauri/src/vpn/socks5_server.rs index ddd13e8..30252cb 100644 --- a/src-tauri/src/vpn/socks5_server.rs +++ b/src-tauri/src/vpn/socks5_server.rs @@ -3,6 +3,7 @@ use boringtun::noise::{Tunn, TunnResult}; use boringtun::x25519::{PublicKey, StaticSecret}; use smoltcp::iface::{Config as IfaceConfig, Interface, SocketHandle, SocketSet}; use smoltcp::phy::{Device, DeviceCapabilities, Medium, RxToken, TxToken}; +use smoltcp::socket::dns; use smoltcp::socket::tcp::{Socket as TcpSocket, SocketBuffer}; use smoltcp::socket::udp; use smoltcp::time::Instant as SmolInstant; @@ -483,8 +484,31 @@ impl WireGuardSocks5Server { actual_port ); + // DNS resolution for domain-name CONNECT requests must go THROUGH the tunnel, never + // the host resolver (which would leak the query to the local network — the + // whole point of the VPN). Resolve via the WireGuard config's DNS server + // (default 1.1.1.1, still routed through the tunnel). `dns_servers` must + // outlive `sockets` since the socket borrows it, so declare it first. + let dns_servers = [self + .config + .dns + .as_deref() + .and_then(|s| s.trim().parse::().ok()) + .map(|v4| { + let o = v4.octets(); + IpAddress::Ipv4(Ipv4Address::new(o[0], o[1], o[2], o[3])) + }) + .unwrap_or_else(|| IpAddress::Ipv4(Ipv4Address::new(1, 1, 1, 1)))]; + let mut sockets = SocketSet::new(vec![]); + // Single DNS socket shared by all connections; queries are driven through + // the tunnel by iface.poll like every other socket. Build the query storage + // without vec![None; N] since DnsQuery isn't Clone. + let dns_queries: Vec> = (0..64).map(|_| None).collect(); + let dns_socket = dns::Socket::new(&dns_servers, dns_queries); + let dns_handle = sockets.add(dns_socket); + // A live SOCKS5 UDP ASSOCIATE: the loopback relay socket the browser sends // datagrams to, and the browser's learned source address. The tunnel-side // smoltcp UDP socket lives in `sockets`, keyed by the connection's @@ -494,6 +518,12 @@ impl WireGuardSocks5Server { client_addr: Option, } + // An in-flight through-tunnel DNS resolution for a domain-name CONNECT. + struct PendingDns { + query: dns::QueryHandle, + port: u16, + } + struct Connection { smol_handle: SocketHandle, tcp_stream: TcpStream, @@ -503,6 +533,7 @@ impl WireGuardSocks5Server { read_buf: Vec, dest_addr: Option, udp: Option, + pending_dns: Option, } let mut connections: Vec = Vec::new(); @@ -527,6 +558,7 @@ impl WireGuardSocks5Server { read_buf: Vec::new(), dest_addr: None, udp: None, + pending_dns: None, }); } @@ -543,7 +575,67 @@ impl WireGuardSocks5Server { // Process each connection let mut completed = Vec::new(); for (idx, conn) in connections.iter_mut().enumerate() { - if conn.connecting { + if let Some(pending) = conn.pending_dns.take() { + // A through-tunnel DNS resolution is in flight for this connection. + let result = { + let dns_sock = sockets.get_mut::(dns_handle); + dns_sock.get_query_result(pending.query) + }; + match result { + Ok(addrs) => { + // Tunnel routing is IPv4-only, so take the first A record. + let resolved = addrs.iter().copied().find_map(|a| match a { + IpAddress::Ipv4(v4) => Some(v4), + _ => None, + }); + match resolved { + Some(v4) => { + let o = v4.octets(); + conn.dest_addr = Some(SocketAddr::new( + std::net::IpAddr::V4(std::net::Ipv4Addr::new(o[0], o[1], o[2], o[3])), + pending.port, + )); + let socket = sockets.get_mut::(conn.smol_handle); + let local_port = 10000 + (rand::random::() % 50000); + if socket + .connect( + iface.context(), + (IpAddress::Ipv4(v4), pending.port), + local_port, + ) + .is_err() + { + let _ = conn + .tcp_stream + .try_write(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]); + completed.push(idx); + continue; + } + conn.connecting = true; + } + None => { + // Resolved, but no usable A record — host unreachable. + let _ = conn + .tcp_stream + .try_write(&[0x05, 0x04, 0x00, 0x01, 0, 0, 0, 0, 0, 0]); + completed.push(idx); + continue; + } + } + } + Err(dns::GetQueryResultError::Pending) => { + // Still resolving; put it back and check again next poll. + conn.pending_dns = Some(pending); + } + Err(_) => { + let _ = conn + .tcp_stream + .try_write(&[0x05, 0x04, 0x00, 0x01, 0, 0, 0, 0, 0, 0]); + completed.push(idx); + continue; + } + } + } else if conn.connecting { let socket = sockets.get_mut::(conn.smol_handle); if socket.may_send() { let _ = conn.tcp_stream.try_write(&[ @@ -645,27 +737,42 @@ impl WireGuardSocks5Server { let port_start = 5 + domain_len; let port = u16::from_be_bytes([conn.read_buf[port_start], conn.read_buf[port_start + 1]]); - // Resolve domain - match format!("{}:{}", domain, port).to_socket_addrs() { - Ok(mut addrs) => { - if let Some(addr) = addrs.next() { - (addr, needed) - } else { - // Send SOCKS5 error: host unreachable + if cmd == 0x03 { + // UDP ASSOCIATE ignores the DST address (the relay learns the + // client's source from its first datagram), so no resolution + // is needed — never resolve it on the host. + ( + SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), port), + needed, + ) + } else { + // TCP CONNECT: resolve the domain THROUGH THE TUNNEL via the + // config DNS server, never the host resolver (which would leak + // the query onto the local network). Start an async query and + // defer the connection until it resolves. + conn.read_buf.drain(..needed); + let dns_sock = sockets.get_mut::(dns_handle); + match dns_sock.start_query( + iface.context(), + &domain, + smoltcp::wire::DnsQueryType::A, + ) { + Ok(query) => { + conn.pending_dns = Some(PendingDns { query, port }); + } + Err(e) => { + log::warn!( + "[vpn-worker] Failed to start DNS query for {}: {:?}", + domain, + e + ); let _ = conn .tcp_stream .try_write(&[0x05, 0x04, 0x00, 0x01, 0, 0, 0, 0, 0, 0]); completed.push(idx); - continue; } } - Err(_) => { - let _ = conn - .tcp_stream - .try_write(&[0x05, 0x04, 0x00, 0x01, 0, 0, 0, 0, 0, 0]); - completed.push(idx); - continue; - } + continue; } } 0x04 => { @@ -888,6 +995,11 @@ impl WireGuardSocks5Server { completed.dedup(); for idx in completed.into_iter().rev() { let conn = connections.remove(idx); + if let Some(pending) = conn.pending_dns { + sockets + .get_mut::(dns_handle) + .cancel_query(pending.query); + } sockets.remove(conn.smol_handle); }