diff --git a/src-tauri/src/bin/proxy_server.rs b/src-tauri/src/bin/proxy_server.rs index 64f6298..b3532eb 100644 --- a/src-tauri/src/bin/proxy_server.rs +++ b/src-tauri/src/bin/proxy_server.rs @@ -162,6 +162,11 @@ async fn main() { Arg::new("blocklist-file") .long("blocklist-file") .help("Path to DNS blocklist file (one domain per line)"), + ) + .arg( + Arg::new("local-protocol") + .long("local-protocol") + .help("Protocol served to the browser: http (default) or socks5"), ), ) .subcommand( @@ -251,6 +256,7 @@ async fn main() { .and_then(|s| serde_json::from_str(s).ok()) .unwrap_or_default(); let blocklist_file = start_matches.get_one::("blocklist-file").cloned(); + let local_protocol = start_matches.get_one::("local-protocol").cloned(); match start_proxy_process_with_profile( upstream_url, @@ -258,6 +264,7 @@ async fn main() { profile_id, bypass_rules, blocklist_file, + local_protocol, ) .await { diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index b9879d3..8b21829 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -261,6 +261,11 @@ impl BrowserRunner { Some(&profile_id_str), profile.proxy_bypass_rules.clone(), blocklist_file, + // Camoufox (Firefox 150, and Firefox 135 on the not-yet-updated + // Windows build) keeps the local HTTP proxy: Firefox's QUIC stack + // bypasses a configured proxy, so QUIC is disabled and HTTP CONNECT + // covers everything. SOCKS5 is reserved for Wayfern. + "http", ) .await .map_err(|e| { @@ -527,6 +532,11 @@ impl BrowserRunner { Some(&profile_id_str), profile.proxy_bypass_rules.clone(), blocklist_file, + // Wayfern (Chromium) uses a local SOCKS5 proxy so QUIC and WebRTC + // UDP can be routed through it (via SOCKS5 UDP ASSOCIATE) without + // leaking the real IP, rather than being forced direct as they + // would be over an HTTP CONNECT proxy. + "socks5", ) .await .map_err(|e| { @@ -535,8 +545,9 @@ impl BrowserRunner { error_msg })?; - // Format proxy URL for wayfern - always use HTTP for the local proxy - let proxy_url = format!("http://{}:{}", local_proxy.host, local_proxy.port); + // Format proxy URL for wayfern - use SOCKS5 for the local proxy so + // Chromium proxies UDP (QUIC/WebRTC), not just TCP. + let proxy_url = format!("socks5://{}:{}", local_proxy.host, local_proxy.port); // Set proxy in wayfern config wayfern_config.proxy = Some(proxy_url); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7939d7a..becda87 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -43,6 +43,7 @@ pub mod proxy_runner; pub mod proxy_server; pub mod proxy_storage; mod settings_manager; +pub mod socks5_local; pub mod sync; mod synchronizer; pub mod traffic_stats; diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index fee46fe..59404a8 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -1478,6 +1478,7 @@ impl ProxyManager { // Start a proxy for given proxy settings and associate it with a browser process ID // If proxy_settings is None, starts a direct proxy for traffic monitoring + #[allow(clippy::too_many_arguments)] pub async fn start_proxy( &self, app_handle: tauri::AppHandle, @@ -1486,6 +1487,10 @@ impl ProxyManager { profile_id: Option<&str>, bypass_rules: Vec, blocklist_file: Option, + // Protocol the local worker serves the browser: "http" (Camoufox) or + // "socks5" (Wayfern). Reflected in the returned ProxySettings.proxy_type + // so the caller formats the right local proxy URL scheme. + local_protocol: &str, ) -> Result { if let Some(name) = profile_id { // Check if we have an active proxy recorded for this profile @@ -1519,7 +1524,7 @@ impl ProxyManager { if proxies.contains_key(&browser_pid) { // Already mapped, reuse it return Ok(ProxySettings { - proxy_type: "http".to_string(), + proxy_type: local_protocol.to_string(), host: "127.0.0.1".to_string(), port: existing.local_port, username: None, @@ -1559,7 +1564,7 @@ impl ProxyManager { if profile_id_matches { // Reuse existing local proxy (settings and profile_id match) return Ok(ProxySettings { - proxy_type: "http".to_string(), + proxy_type: local_protocol.to_string(), host: "127.0.0.1".to_string(), port: existing.local_port, username: None, @@ -1618,6 +1623,9 @@ impl ProxyManager { proxy_cmd = proxy_cmd.arg("--blocklist-file").arg(path); } + // Tell the worker which protocol to serve the browser (http or socks5) + proxy_cmd = proxy_cmd.arg("--local-protocol").arg(local_protocol); + // Execute the command and wait for it to complete // The donut-proxy binary should start the worker and then exit let output = proxy_cmd @@ -1709,7 +1717,7 @@ impl ProxyManager { // Return proxy settings for the browser Ok(ProxySettings { - proxy_type: "http".to_string(), + proxy_type: local_protocol.to_string(), host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility port: proxy_info.local_port, username: None, @@ -2885,6 +2893,7 @@ mod tests { profile_id: None, bypass_rules: Vec::new(), blocklist_file: None, + local_protocol: None, }; let dead_config = ProxyConfig { id: dead_id.clone(), @@ -2896,6 +2905,7 @@ mod tests { profile_id: None, bypass_rules: Vec::new(), blocklist_file: None, + local_protocol: None, }; save_proxy_config(&live_config).unwrap(); @@ -2935,6 +2945,7 @@ mod tests { profile_id: Some("prof_abc".to_string()), bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()], blocklist_file: None, + local_protocol: None, }; // Save @@ -3253,6 +3264,7 @@ mod tests { profile_id: None, bypass_rules: Vec::new(), blocklist_file: None, + local_protocol: None, }; save_proxy_config(&config).unwrap(); diff --git a/src-tauri/src/proxy_runner.rs b/src-tauri/src/proxy_runner.rs index 780e5ce..45afe86 100644 --- a/src-tauri/src/proxy_runner.rs +++ b/src-tauri/src/proxy_runner.rs @@ -160,7 +160,7 @@ pub async fn start_proxy_process( upstream_url: Option, port: Option, ) -> Result> { - start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None).await + start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None, None).await } pub async fn start_proxy_process_with_profile( @@ -169,6 +169,7 @@ pub async fn start_proxy_process_with_profile( profile_id: Option, bypass_rules: Vec, blocklist_file: Option, + local_protocol: Option, ) -> Result> { let id = generate_proxy_id(); let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string()); @@ -183,7 +184,8 @@ pub async fn start_proxy_process_with_profile( let config = ProxyConfig::new(id.clone(), upstream, Some(local_port)) .with_profile_id(profile_id.clone()) .with_bypass_rules(bypass_rules) - .with_blocklist_file(blocklist_file); + .with_blocklist_file(blocklist_file) + .with_local_protocol(local_protocol); save_proxy_config(&config)?; // Log profile_id for debugging diff --git a/src-tauri/src/proxy_server.rs b/src-tauri/src/proxy_server.rs index b4728b7..262dd3c 100644 --- a/src-tauri/src/proxy_server.rs +++ b/src-tauri/src/proxy_server.rs @@ -21,9 +21,9 @@ use tokio::net::TcpStream; /// Combined read+write trait for tunnel target streams, allowing /// `handle_connect_from_buffer` to handle plain TCP, SOCKS, and /// Shadowsocks through the same bidirectional-copy path. -trait AsyncStream: AsyncRead + AsyncWrite + Unpin + Send {} +pub(crate) trait AsyncStream: AsyncRead + AsyncWrite + Unpin + Send {} impl AsyncStream for T {} -type BoxedAsyncStream = Box; +pub(crate) type BoxedAsyncStream = Box; use url::Url; enum CompiledRule { @@ -1247,10 +1247,19 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box Result<(), Box { log::error!("Error accepting connection: {:?}", e); @@ -1444,20 +1459,51 @@ async fn handle_connect_from_buffer( ); // Connect to target (directly or via upstream proxy). - // Returns a BoxedAsyncStream so all upstream types (plain TCP, SOCKS, - // Shadowsocks) share the same bidirectional-copy tunnel code below. + let target_stream = connect_to_target_via_upstream( + target_host, + target_port, + upstream_url.as_deref(), + &bypass_matcher, + ) + .await?; + + // Send 200 Connection Established response to client + // CRITICAL: Must flush after writing to ensure response is sent before tunneling + client_stream + .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") + .await?; + client_stream.flush().await?; + + log::trace!("Sent 200 Connection Established response, starting tunnel"); + + tunnel_streams(client_stream, target_stream, domain).await; + + Ok(()) +} + +/// Establish a stream to `target_host:target_port`, either directly or through +/// the configured upstream proxy. Shared by the HTTP CONNECT path and the +/// local SOCKS5 server so every upstream type (direct, HTTP/HTTPS CONNECT, +/// SOCKS4/5, Shadowsocks) is dialed in exactly one place. Returns a +/// `BoxedAsyncStream` so the caller can tunnel over any upstream uniformly. +pub(crate) async fn connect_to_target_via_upstream( + target_host: &str, + target_port: u16, + upstream_url: Option<&str>, + bypass_matcher: &BypassMatcher, +) -> Result> { let should_bypass = bypass_matcher.should_bypass(target_host); // Helper: configure outbound TCP to match browser TCP fingerprint let configure_tcp = |stream: &TcpStream| { let _ = stream.set_nodelay(true); }; - let target_stream: BoxedAsyncStream = match upstream_url.as_ref() { + let target_stream: BoxedAsyncStream = match upstream_url { None => { let s = TcpStream::connect((target_host, target_port)).await?; configure_tcp(&s); Box::new(s) } - Some(url) if url == "DIRECT" => { + Some("DIRECT") => { let s = TcpStream::connect((target_host, target_port)).await?; configure_tcp(&s); Box::new(s) @@ -1616,20 +1662,18 @@ async fn handle_connect_from_buffer( } }; - // TCP_NODELAY is set per-stream where applicable (TcpStream paths). - // For encrypted streams (Shadowsocks), the underlying TCP connection - // is managed by the library and nodelay is handled internally. + Ok(target_stream) +} - // Send 200 Connection Established response to client - // CRITICAL: Must flush after writing to ensure response is sent before tunneling - client_stream - .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") - .await?; - client_stream.flush().await?; - - log::trace!("Sent 200 Connection Established response, starting tunnel"); - - // Now tunnel data bidirectionally with counting +/// Bidirectionally relay `client_stream` <-> `target_stream` until either side +/// closes, counting bytes for traffic stats and attributing them to `domain`. +/// The caller is responsible for having already sent any protocol-specific +/// success reply (HTTP `200` or SOCKS5 reply) before calling this. +pub(crate) async fn tunnel_streams( + client_stream: TcpStream, + target_stream: BoxedAsyncStream, + domain: String, +) { // Wrap streams to count bytes transferred let counting_client = CountingStream::new(client_stream); let counting_target = CountingStream::new(target_stream); @@ -1692,8 +1736,6 @@ async fn handle_connect_from_buffer( if let Some(tracker) = get_traffic_tracker() { tracker.update_domain_bytes(&domain, final_sent, final_recv); } - - Ok(()) } #[cfg(test)] diff --git a/src-tauri/src/proxy_storage.rs b/src-tauri/src/proxy_storage.rs index 7ae6c80..f4e5073 100644 --- a/src-tauri/src/proxy_storage.rs +++ b/src-tauri/src/proxy_storage.rs @@ -16,6 +16,12 @@ pub struct ProxyConfig { pub bypass_rules: Vec, #[serde(default)] pub blocklist_file: Option, + /// Protocol the local worker serves to the browser: "http" (default, used + /// by Camoufox/Firefox) or "socks5" (used by Wayfern/Chromium so QUIC and + /// WebRTC UDP can be proxied without leaking the real IP). Independent of + /// `upstream_url`, which is the real upstream proxy/VPN this worker dials. + #[serde(default)] + pub local_protocol: Option, } impl ProxyConfig { @@ -30,6 +36,7 @@ impl ProxyConfig { profile_id: None, bypass_rules: Vec::new(), blocklist_file: None, + local_protocol: None, } } @@ -47,6 +54,20 @@ impl ProxyConfig { self.blocklist_file = blocklist_file; self } + + pub fn with_local_protocol(mut self, local_protocol: Option) -> Self { + self.local_protocol = local_protocol; + self + } + + /// "socks5" or "http" (default). Lowercased for case-insensitive matching. + pub fn local_protocol_or_default(&self) -> String { + self + .local_protocol + .as_deref() + .unwrap_or("http") + .to_lowercase() + } } pub fn get_storage_dir() -> PathBuf { diff --git a/src-tauri/src/socks5_local.rs b/src-tauri/src/socks5_local.rs new file mode 100644 index 0000000..1dfd154 --- /dev/null +++ b/src-tauri/src/socks5_local.rs @@ -0,0 +1,639 @@ +//! Local SOCKS5 server served to the browser (Wayfern/Chromium). +//! +//! The HTTP front-end (`proxy_server::handle_proxy_connection`) can only tunnel +//! TCP, so QUIC and WebRTC — which are UDP — would be forced direct and leak the +//! real IP. Serving SOCKS5 instead lets Chromium proxy UDP via SOCKS5 UDP +//! ASSOCIATE (RFC 1928). TCP CONNECT reuses the exact same upstream-dial and +//! tunnel code as the HTTP path, so every upstream type (direct, HTTP/HTTPS +//! CONNECT, SOCKS4/5, Shadowsocks) behaves identically. +//! +//! UDP ASSOCIATE is leak-safe by construction: UDP is only relayed where it +//! cannot expose the host IP — directly when there is no upstream proxy, or +//! tunneled through a UDP-capable SOCKS5 upstream. For upstreams that cannot +//! carry UDP (HTTP/HTTPS/SOCKS4/Shadowsocks, or a SOCKS5 upstream that refuses +//! the association) the request is refused, so Chromium falls back to proxied +//! TCP rather than sending UDP from the real IP. + +use crate::proxy_server::{ + connect_to_target_via_upstream, tunnel_streams, BlocklistMatcher, BypassMatcher, +}; +use crate::traffic_stats::get_traffic_tracker; +use async_socks5::{AddrKind, Auth, SocksDatagram}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpStream, UdpSocket}; +use url::Url; + +// SOCKS5 reply codes (RFC 1928 §6). +const REP_SUCCEEDED: u8 = 0x00; +const REP_GENERAL_FAILURE: u8 = 0x01; +const REP_NOT_ALLOWED: u8 = 0x02; +const REP_COMMAND_NOT_SUPPORTED: u8 = 0x07; + +// SOCKS5 commands (RFC 1928 §4). +const CMD_CONNECT: u8 = 0x01; +const CMD_UDP_ASSOCIATE: u8 = 0x03; + +// Max UDP datagram payload; sized for a full 64 KiB datagram plus header slack. +const UDP_BUF: usize = 65_536; + +/// How a UDP ASSOCIATE request must be served for a given upstream so the real +/// IP never leaks. +#[derive(Debug, PartialEq, Eq)] +enum UdpMode { + /// No upstream proxy: relay UDP directly (the host IP is the profile's IP, + /// so there is nothing to hide). + Direct, + /// SOCKS5 upstream: attempt SOCKS5 UDP ASSOCIATE against it. Tunnels UDP if + /// the upstream grants it; refuses (no leak) if it does not. + Socks5Upstream, + /// Upstream that cannot carry UDP (HTTP/HTTPS/SOCKS4/Shadowsocks): refuse so + /// Chromium falls back to proxied TCP instead of leaking UDP. + Refuse, +} + +/// Decide the leak-safe UDP policy for an upstream URL. +fn udp_mode(upstream_url: Option<&str>) -> UdpMode { + match upstream_url { + None => UdpMode::Direct, + Some("DIRECT") => UdpMode::Direct, + Some(url) => match Url::parse(url).ok().map(|u| u.scheme().to_lowercase()) { + Some(scheme) if scheme == "socks5" => UdpMode::Socks5Upstream, + // http / https / socks4 / ss / shadowsocks / anything else: TCP-only. + _ => UdpMode::Refuse, + }, + } +} + +/// `0.0.0.0:0` — used for BND fields in replies where the bound address is +/// irrelevant to the client (e.g. CONNECT). +fn unspecified() -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0) +} + +/// Handle one SOCKS5 client connection from the browser. Mirrors the spawn +/// contract of `proxy_server::handle_proxy_connection`. +pub async fn handle_socks5_connection( + mut stream: TcpStream, + upstream_url: Option, + bypass_matcher: BypassMatcher, + blocklist_matcher: BlocklistMatcher, +) { + let _ = stream.set_nodelay(true); + + if let Err(e) = negotiate_method(&mut stream).await { + log::debug!("SOCKS5 method negotiation failed: {e}"); + return; + } + + let request = match read_request(&mut stream).await { + Ok(r) => r, + Err(e) => { + log::debug!("SOCKS5 request parse failed: {e}"); + let _ = send_reply(&mut stream, REP_GENERAL_FAILURE, unspecified()).await; + return; + } + }; + + match request.cmd { + CMD_CONNECT => { + handle_connect( + stream, + request.host, + request.port, + upstream_url, + bypass_matcher, + blocklist_matcher, + ) + .await; + } + CMD_UDP_ASSOCIATE => { + handle_udp_associate(stream, upstream_url).await; + } + other => { + log::debug!("SOCKS5 unsupported command {other:#04x}"); + let _ = send_reply(&mut stream, REP_COMMAND_NOT_SUPPORTED, unspecified()).await; + } + } +} + +/// Read the SOCKS5 greeting and select the no-auth method. The local proxy is +/// loopback-only, so no authentication is required (Chromium offers no-auth). +async fn negotiate_method(stream: &mut TcpStream) -> std::io::Result<()> { + let mut head = [0u8; 2]; + stream.read_exact(&mut head).await?; + if head[0] != 0x05 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "not a SOCKS5 greeting", + )); + } + let nmethods = head[1] as usize; + let mut methods = vec![0u8; nmethods]; + stream.read_exact(&mut methods).await?; + + if methods.contains(&0x00) { + stream.write_all(&[0x05, 0x00]).await?; + Ok(()) + } else { + // No acceptable methods. + let _ = stream.write_all(&[0x05, 0xFF]).await; + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "no no-auth method offered", + )) + } +} + +struct Socks5Request { + cmd: u8, + host: String, + port: u16, +} + +/// Read a SOCKS5 request line: VER, CMD, RSV, ATYP, DST.ADDR, DST.PORT. +async fn read_request(stream: &mut TcpStream) -> std::io::Result { + let mut head = [0u8; 4]; + stream.read_exact(&mut head).await?; + if head[0] != 0x05 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "bad SOCKS5 request version", + )); + } + let cmd = head[1]; + let atyp = head[3]; + let host = read_addr(stream, atyp).await?; + let mut port = [0u8; 2]; + stream.read_exact(&mut port).await?; + Ok(Socks5Request { + cmd, + host, + port: u16::from_be_bytes(port), + }) +} + +/// Read a SOCKS5 address of the given type into a host string (an IP literal or +/// a domain name; `connect_to_target_via_upstream` handles both). +async fn read_addr(stream: &mut TcpStream, atyp: u8) -> std::io::Result { + match atyp { + 0x01 => { + let mut b = [0u8; 4]; + stream.read_exact(&mut b).await?; + Ok(Ipv4Addr::new(b[0], b[1], b[2], b[3]).to_string()) + } + 0x04 => { + let mut b = [0u8; 16]; + stream.read_exact(&mut b).await?; + Ok(Ipv6Addr::from(b).to_string()) + } + 0x03 => { + let mut len = [0u8; 1]; + stream.read_exact(&mut len).await?; + let mut domain = vec![0u8; len[0] as usize]; + stream.read_exact(&mut domain).await?; + Ok(String::from_utf8_lossy(&domain).to_string()) + } + other => Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("unsupported SOCKS5 address type {other:#04x}"), + )), + } +} + +/// Write a SOCKS5 reply with the given code and bound address. +async fn send_reply(stream: &mut TcpStream, rep: u8, bnd: SocketAddr) -> std::io::Result<()> { + let mut resp = vec![0x05, rep, 0x00]; + push_addr(&mut resp, bnd); + stream.write_all(&resp).await +} + +/// Append an ATYP + address + port to a SOCKS5 message buffer. +fn push_addr(buf: &mut Vec, addr: SocketAddr) { + match addr.ip() { + IpAddr::V4(v4) => { + buf.push(0x01); + buf.extend_from_slice(&v4.octets()); + } + IpAddr::V6(v6) => { + buf.push(0x04); + buf.extend_from_slice(&v6.octets()); + } + } + buf.extend_from_slice(&addr.port().to_be_bytes()); +} + +/// SOCKS5 CONNECT: dial the target via the upstream and bidirectionally tunnel, +/// reusing the same code path as the HTTP CONNECT proxy. +async fn handle_connect( + mut stream: TcpStream, + host: String, + port: u16, + upstream_url: Option, + bypass_matcher: BypassMatcher, + blocklist_matcher: BlocklistMatcher, +) { + if blocklist_matcher.is_blocked(&host) { + log::debug!("[blocklist] Blocked SOCKS5 CONNECT to {host}"); + let _ = send_reply(&mut stream, REP_NOT_ALLOWED, unspecified()).await; + return; + } + + if let Some(tracker) = get_traffic_tracker() { + tracker.record_request(&host, 0, 0); + } + + log::info!( + "SOCKS5 CONNECT {}:{} (upstream={})", + host, + port, + upstream_url.as_deref().unwrap_or("DIRECT") + ); + + // Resolve to the target stream, logging and dropping the (non-Send) dial + // error inside the match arm so it is never held across the await below. + let target = + match connect_to_target_via_upstream(&host, port, upstream_url.as_deref(), &bypass_matcher) + .await + { + Ok(t) => Some(t), + Err(e) => { + log::warn!("SOCKS5 CONNECT to {host}:{port} failed: {e}"); + None + } + }; + + let Some(target) = target else { + let _ = send_reply(&mut stream, REP_GENERAL_FAILURE, unspecified()).await; + return; + }; + + if send_reply(&mut stream, REP_SUCCEEDED, unspecified()) + .await + .is_err() + { + return; + } + tunnel_streams(stream, target, host).await; +} + +/// SOCKS5 UDP ASSOCIATE, leak-safe per upstream (see [`UdpMode`]). +/// +/// `control` is the TCP control connection; the UDP association lives exactly +/// as long as it stays open (RFC 1928 §6), so the relay loop tears down when +/// the browser closes it. +async fn handle_udp_associate(mut control: TcpStream, upstream_url: Option) { + let mode = udp_mode(upstream_url.as_deref()); + + if mode == UdpMode::Refuse { + log::info!( + "SOCKS5 UDP ASSOCIATE refused: upstream ({}) cannot carry UDP without leaking; Chromium will use proxied TCP", + upstream_url.as_deref().unwrap_or("DIRECT") + ); + let _ = send_reply(&mut control, REP_COMMAND_NOT_SUPPORTED, unspecified()).await; + return; + } + + // The UDP relay socket the browser sends its datagrams to. Loopback-only. + let relay = match UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await { + Ok(s) => s, + Err(e) => { + log::warn!("Failed to bind UDP relay socket: {e}"); + let _ = send_reply(&mut control, REP_GENERAL_FAILURE, unspecified()).await; + return; + } + }; + let relay_addr = match relay.local_addr() { + Ok(a) => a, + Err(e) => { + log::warn!("Failed to read UDP relay addr: {e}"); + let _ = send_reply(&mut control, REP_GENERAL_FAILURE, unspecified()).await; + return; + } + }; + + match mode { + UdpMode::Direct => { + // Bind the egress socket before replying so a failure surfaces as a + // refusal (no half-open association). + let out = match UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await { + Ok(s) => s, + Err(e) => { + log::warn!("Failed to bind UDP egress socket: {e}"); + let _ = send_reply(&mut control, REP_GENERAL_FAILURE, unspecified()).await; + return; + } + }; + if send_reply(&mut control, REP_SUCCEEDED, relay_addr) + .await + .is_err() + { + return; + } + log::info!("SOCKS5 UDP ASSOCIATE (direct) relaying on {relay_addr}"); + run_udp_relay_direct(control, relay, out).await; + } + UdpMode::Socks5Upstream => { + // Establish the upstream association FIRST; if the upstream refuses UDP, + // refuse to the browser too (no leak). + let upstream = upstream_url.as_deref().unwrap_or(""); + let datagram = match associate_upstream(upstream).await { + Ok(d) => d, + Err(e) => { + log::info!( + "SOCKS5 upstream did not grant UDP ASSOCIATE ({e}); refusing so Chromium uses proxied TCP" + ); + let _ = send_reply(&mut control, REP_COMMAND_NOT_SUPPORTED, unspecified()).await; + return; + } + }; + if send_reply(&mut control, REP_SUCCEEDED, relay_addr) + .await + .is_err() + { + return; + } + log::info!("SOCKS5 UDP ASSOCIATE (via SOCKS5 upstream) relaying on {relay_addr}"); + run_udp_relay_socks5(control, relay, datagram).await; + } + UdpMode::Refuse => unreachable!("handled above"), + } +} + +/// Open a SOCKS5 UDP association against the upstream proxy. +async fn associate_upstream( + upstream_url: &str, +) -> Result, Box> { + let upstream = Url::parse(upstream_url)?; + let host = upstream.host_str().unwrap_or("127.0.0.1"); + let port = upstream.port().unwrap_or(1080); + let auth = if !upstream.username().is_empty() { + Some(Auth { + username: upstream.username().to_string(), + password: upstream.password().unwrap_or("").to_string(), + }) + } else { + None + }; + + let proxy_stream = TcpStream::connect((host, port)).await?; + let bind_sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await?; + // association_addr None => 0.0.0.0:0 (we accept replies from any peer). + let datagram = SocksDatagram::associate(proxy_stream, bind_sock, auth, None::).await?; + Ok(datagram) +} + +/// Parsed SOCKS5 UDP datagram header (RFC 1928 §7): the destination and the +/// offset at which the payload begins. Fragmented datagrams (FRAG != 0) are +/// rejected by the caller. +struct UdpHeader { + frag: u8, + dst: AddrKind, + data_offset: usize, +} + +fn parse_udp_header(buf: &[u8]) -> Option { + if buf.len() < 4 { + return None; + } + let frag = buf[2]; + let atyp = buf[3]; + match atyp { + 0x01 => { + if buf.len() < 10 { + return None; + } + let ip = Ipv4Addr::new(buf[4], buf[5], buf[6], buf[7]); + let port = u16::from_be_bytes([buf[8], buf[9]]); + Some(UdpHeader { + frag, + dst: AddrKind::Ip(SocketAddr::new(IpAddr::V4(ip), port)), + data_offset: 10, + }) + } + 0x04 => { + if buf.len() < 22 { + return None; + } + let mut octets = [0u8; 16]; + octets.copy_from_slice(&buf[4..20]); + let ip = Ipv6Addr::from(octets); + let port = u16::from_be_bytes([buf[20], buf[21]]); + Some(UdpHeader { + frag, + dst: AddrKind::Ip(SocketAddr::new(IpAddr::V6(ip), port)), + data_offset: 22, + }) + } + 0x03 => { + let dlen = *buf.get(4)? as usize; + let needed = 5 + dlen + 2; + if buf.len() < needed { + return None; + } + let domain = String::from_utf8_lossy(&buf[5..5 + dlen]).to_string(); + let port = u16::from_be_bytes([buf[5 + dlen], buf[6 + dlen]]); + Some(UdpHeader { + frag, + dst: AddrKind::Domain(domain, port), + data_offset: needed, + }) + } + _ => None, + } +} + +/// Build a SOCKS5 UDP response datagram (header + payload) to send back to the +/// browser, naming `peer` as the source. +fn build_udp_response(peer: SocketAddr, data: &[u8]) -> Vec { + let mut out = vec![0x00, 0x00, 0x00]; // RSV(2) + FRAG(0) + push_addr(&mut out, peer); + out.extend_from_slice(data); + out +} + +/// Direct UDP relay: browser <-> a plain egress UDP socket. Used only when +/// there is no upstream proxy, so the host IP is the profile's own IP. +async fn run_udp_relay_direct(mut control: TcpStream, relay: UdpSocket, out: UdpSocket) { + let mut client_addr: Option = None; + let mut from_client = vec![0u8; UDP_BUF]; + let mut from_target = vec![0u8; UDP_BUF]; + let mut ctrl_buf = [0u8; 256]; + + loop { + tokio::select! { + // Control connection closed => association ends. + r = control.read(&mut ctrl_buf) => { + match r { + Ok(0) | Err(_) => break, + Ok(_) => {} // ignore any data on the control channel + } + } + // Browser -> target. + r = relay.recv_from(&mut from_client) => { + let Ok((n, src)) = r else { break }; + client_addr = Some(src); + let Some(header) = parse_udp_header(&from_client[..n]) else { continue }; + if header.frag != 0 { + continue; // fragmentation unsupported + } + let payload = &from_client[header.data_offset..n]; + let dst = match resolve_addr(&header.dst).await { + Some(d) => d, + None => continue, + }; + let _ = out.send_to(payload, dst).await; + } + // Target -> browser. + r = out.recv_from(&mut from_target) => { + let Ok((n, peer)) = r else { continue }; + if let Some(client) = client_addr { + let resp = build_udp_response(peer, &from_target[..n]); + let _ = relay.send_to(&resp, client).await; + } + } + } + } +} + +/// UDP relay tunneled through a SOCKS5 upstream that granted UDP ASSOCIATE. +async fn run_udp_relay_socks5( + mut control: TcpStream, + relay: UdpSocket, + datagram: SocksDatagram, +) { + let mut client_addr: Option = None; + let mut from_client = vec![0u8; UDP_BUF]; + let mut from_upstream = vec![0u8; UDP_BUF]; + let mut ctrl_buf = [0u8; 256]; + + loop { + tokio::select! { + r = control.read(&mut ctrl_buf) => { + match r { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + } + // Browser -> upstream. + r = relay.recv_from(&mut from_client) => { + let Ok((n, src)) = r else { break }; + client_addr = Some(src); + let Some(header) = parse_udp_header(&from_client[..n]) else { continue }; + if header.frag != 0 { + continue; + } + let payload = from_client[header.data_offset..n].to_vec(); + let _ = datagram.send_to(&payload, header.dst).await; + } + // Upstream -> browser. + r = datagram.recv_from(&mut from_upstream) => { + let Ok((n, peer)) = r else { continue }; + if let Some(client) = client_addr { + let resp = build_udp_response(addrkind_to_socketaddr(&peer), &from_upstream[..n]); + let _ = relay.send_to(&resp, client).await; + } + } + } + } +} + +/// Resolve a UDP destination to a concrete socket address for direct relay. +async fn resolve_addr(addr: &AddrKind) -> Option { + match addr { + AddrKind::Ip(s) => Some(*s), + AddrKind::Domain(domain, port) => tokio::net::lookup_host(format!("{domain}:{port}")) + .await + .ok() + .and_then(|mut it| it.next()), + } +} + +/// Best-effort conversion of an upstream-reported source address into a +/// `SocketAddr` for the response header. A domain (rare for UDP) collapses to +/// `0.0.0.0:port`, which clients treat as "from the proxy". +fn addrkind_to_socketaddr(addr: &AddrKind) -> SocketAddr { + match addr { + AddrKind::Ip(s) => *s, + AddrKind::Domain(_, port) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), *port), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn udp_mode_direct_for_none_and_direct() { + assert_eq!(udp_mode(None), UdpMode::Direct); + assert_eq!(udp_mode(Some("DIRECT")), UdpMode::Direct); + } + + #[test] + fn udp_mode_socks5_upstream() { + assert_eq!( + udp_mode(Some("socks5://user:pass@1.2.3.4:1080")), + UdpMode::Socks5Upstream + ); + assert_eq!( + udp_mode(Some("socks5://1.2.3.4:1080")), + UdpMode::Socks5Upstream + ); + } + + #[test] + fn udp_mode_refuses_tcp_only_upstreams() { + // HTTP/HTTPS CONNECT, SOCKS4, and Shadowsocks cannot carry UDP, so UDP + // ASSOCIATE must be refused (Chromium then uses proxied TCP — no leak). + assert_eq!(udp_mode(Some("http://1.2.3.4:8080")), UdpMode::Refuse); + assert_eq!(udp_mode(Some("https://1.2.3.4:8080")), UdpMode::Refuse); + assert_eq!(udp_mode(Some("socks4://1.2.3.4:1080")), UdpMode::Refuse); + assert_eq!( + udp_mode(Some("ss://aes-256-gcm:pw@1.2.3.4:8388")), + UdpMode::Refuse + ); + } + + #[test] + fn parse_udp_header_ipv4() { + // RSV RSV FRAG ATYP=1 1.2.3.4 :443 payload="hi" + let buf = [0, 0, 0, 0x01, 1, 2, 3, 4, 0x01, 0xBB, b'h', b'i']; + let h = parse_udp_header(&buf).expect("ipv4 header"); + assert_eq!(h.frag, 0); + assert_eq!(h.data_offset, 10); + assert_eq!( + h.dst, + AddrKind::Ip(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 443)) + ); + assert_eq!(&buf[h.data_offset..], b"hi"); + } + + #[test] + fn parse_udp_header_domain() { + // ATYP=3, len=3, "abc", port 8080, payload "x" + let mut buf = vec![0, 0, 0, 0x03, 3, b'a', b'b', b'c', 0x1F, 0x90]; + buf.push(b'x'); + let h = parse_udp_header(&buf).expect("domain header"); + assert_eq!(h.dst, AddrKind::Domain("abc".to_string(), 8080)); + assert_eq!(&buf[h.data_offset..], b"x"); + } + + #[test] + fn parse_udp_header_rejects_truncated() { + assert!(parse_udp_header(&[0, 0, 0]).is_none()); + assert!(parse_udp_header(&[0, 0, 0, 0x01, 1, 2]).is_none()); + } + + #[test] + fn build_udp_response_prefixes_header() { + let resp = build_udp_response( + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 53), + b"data", + ); + // RSV RSV FRAG ATYP=1 9.9.9.9 :53 "data" + assert_eq!( + resp, + vec![0, 0, 0, 0x01, 9, 9, 9, 9, 0x00, 0x35, b'd', b'a', b't', b'a'] + ); + } +} diff --git a/src-tauri/src/vpn/socks5_server.rs b/src-tauri/src/vpn/socks5_server.rs index d8a8e63..0e0d4f8 100644 --- a/src-tauri/src/vpn/socks5_server.rs +++ b/src-tauri/src/vpn/socks5_server.rs @@ -4,8 +4,9 @@ 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::tcp::{Socket as TcpSocket, SocketBuffer}; +use smoltcp::socket::udp; use smoltcp::time::Instant as SmolInstant; -use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, Ipv4Address}; +use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, IpEndpoint, Ipv4Address}; use std::collections::VecDeque; use std::net::{SocketAddr, ToSocketAddrs, UdpSocket}; use std::sync::{Arc, Mutex}; @@ -13,6 +14,58 @@ use tokio::net::{TcpListener, TcpStream}; const SMOLTCP_TCP_RX_BUF: usize = 65536; const SMOLTCP_TCP_TX_BUF: usize = 65536; +const SMOLTCP_UDP_BUF: usize = 65536; + +/// Parse an RFC 1928 §7 UDP request header. Returns the destination endpoint +/// and the payload offset, or None if malformed, fragmented, or domain-typed. +/// Only literal IPs are routed through the tunnel: resolving a domain on the +/// host would leak DNS, and QUIC/WebRTC datagrams always carry literal IPs. +fn parse_udp_datagram(buf: &[u8]) -> Option<(IpEndpoint, usize)> { + if buf.len() < 4 || buf[2] != 0 { + // too short, or FRAG != 0 (fragmentation unsupported) + return None; + } + match buf[3] { + 0x01 => { + if buf.len() < 10 { + return None; + } + let ip = Ipv4Address::new(buf[4], buf[5], buf[6], buf[7]); + let port = u16::from_be_bytes([buf[8], buf[9]]); + Some((IpEndpoint::new(IpAddress::Ipv4(ip), port), 10)) + } + 0x04 => { + if buf.len() < 22 { + return None; + } + let mut o = [0u8; 16]; + o.copy_from_slice(&buf[4..20]); + let ip = smoltcp::wire::Ipv6Address::from(o); + let port = u16::from_be_bytes([buf[20], buf[21]]); + Some((IpEndpoint::new(IpAddress::Ipv6(ip), port), 22)) + } + _ => None, + } +} + +/// Wrap a tunnel-received datagram in an RFC 1928 §7 UDP reply header naming +/// `src` as the origin, for delivery back to the browser's relay socket. +fn build_udp_datagram(src: IpEndpoint, payload: &[u8]) -> Vec { + let mut out = vec![0x00, 0x00, 0x00]; // RSV(2) + FRAG(0) + match src.addr { + IpAddress::Ipv4(v4) => { + out.push(0x01); + out.extend_from_slice(&v4.octets()); + } + IpAddress::Ipv6(v6) => { + out.push(0x04); + out.extend_from_slice(&v6.octets()); + } + } + out.extend_from_slice(&src.port.to_be_bytes()); + out.extend_from_slice(payload); + out +} struct WgDevice { tunn: Arc>>, @@ -432,6 +485,15 @@ impl WireGuardSocks5Server { let mut sockets = SocketSet::new(vec![]); + // 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 + // (repurposed) `smol_handle`. + struct UdpAssoc { + relay: UdpSocket, + client_addr: Option, + } + struct Connection { smol_handle: SocketHandle, tcp_stream: TcpStream, @@ -440,6 +502,7 @@ impl WireGuardSocks5Server { greeting_done: bool, read_buf: Vec, dest_addr: Option, + udp: Option, } let mut connections: Vec = Vec::new(); @@ -463,6 +526,7 @@ impl WireGuardSocks5Server { greeting_done: false, read_buf: Vec::new(), dest_addr: None, + udp: None, }); } @@ -540,8 +604,17 @@ impl WireGuardSocks5Server { } if conn.greeting_done && conn.dest_addr.is_none() && conn.read_buf.len() >= 10 { - // SOCKS5 connect request - if conn.read_buf[0] != 0x05 || conn.read_buf[1] != 0x01 { + // SOCKS5 request: CONNECT (0x01) or UDP ASSOCIATE (0x03) + if conn.read_buf[0] != 0x05 { + completed.push(idx); + continue; + } + let cmd = conn.read_buf[1]; + if cmd != 0x01 && cmd != 0x03 { + // command not supported + let _ = conn + .tcp_stream + .try_write(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]); completed.push(idx); continue; } @@ -613,6 +686,75 @@ impl WireGuardSocks5Server { }; conn.read_buf.drain(..addr_len); + + if cmd == 0x03 { + // === SOCKS5 UDP ASSOCIATE === + // The request's DST is the client's intended source (typically + // 0.0.0.0:0) and is ignored — the browser's relay source is + // learned from its first datagram. Bind a loopback relay socket + // the browser sends to, plus a smoltcp UDP socket that egresses + // through the WireGuard tunnel on the interface IP. + let relay = match UdpSocket::bind("127.0.0.1:0") { + Ok(s) => s, + Err(_) => { + let _ = conn + .tcp_stream + .try_write(&[0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]); + completed.push(idx); + continue; + } + }; + let _ = relay.set_nonblocking(true); + let relay_port = relay.local_addr().map(|a| a.port()).unwrap_or(0); + + // Reply with the relay endpoint (127.0.0.1:relay_port). + if conn + .tcp_stream + .try_write(&[ + 0x05, + 0x00, + 0x00, + 0x01, + 127, + 0, + 0, + 1, + (relay_port >> 8) as u8, + (relay_port & 0xff) as u8, + ]) + .is_err() + { + completed.push(idx); + continue; + } + + let udp_rx = udp::PacketBuffer::new( + vec![udp::PacketMetadata::EMPTY; 32], + vec![0u8; SMOLTCP_UDP_BUF], + ); + let udp_tx = udp::PacketBuffer::new( + vec![udp::PacketMetadata::EMPTY; 32], + vec![0u8; SMOLTCP_UDP_BUF], + ); + let mut udp_socket = udp::Socket::new(udp_rx, udp_tx); + let local_port = 20000 + (rand::random::() % 40000); + if udp_socket.bind(local_port).is_err() { + completed.push(idx); + continue; + } + + // Swap this connection's unused TCP socket for the UDP socket; + // `smol_handle` now keys the UDP socket, so teardown is unchanged. + sockets.remove(conn.smol_handle); + conn.smol_handle = sockets.add(udp_socket); + conn.udp = Some(UdpAssoc { + relay, + client_addr: None, + }); + conn.socks_done = true; + continue; + } + conn.dest_addr = Some(addr); // Open smoltcp TCP socket to the destination @@ -641,6 +783,82 @@ impl WireGuardSocks5Server { conn.connecting = true; } + } else if conn.udp.is_some() { + // === UDP ASSOCIATE relay === + // The association lives only while the TCP control connection is + // open (RFC 1928 §6); tear down when the browser closes it. + let mut probe = [0u8; 1]; + match conn.tcp_stream.try_read(&mut probe) { + Ok(0) => { + completed.push(idx); + continue; + } + Ok(_) => {} // ignore any data on the control channel + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(_) => { + completed.push(idx); + continue; + } + } + + let handle = conn.smol_handle; + let Some(udp) = conn.udp.as_mut() else { + continue; + }; + + // Browser → tunnel: strip the §7 header and forward the payload. + let mut dbuf = [0u8; SMOLTCP_UDP_BUF]; + loop { + match udp.relay.recv_from(&mut dbuf) { + Ok((n, src)) => { + udp.client_addr = Some(src); + if let Some((dst, off)) = parse_udp_datagram(&dbuf[..n]) { + let socket = sockets.get_mut::(handle); + let cs = socket.can_send(); + let r = if cs { + socket.send_slice(&dbuf[off..n], dst) + } else { + Ok(()) + }; + log::info!( + "[wg-udp] browser->tunnel {}B dst={} client={} can_send={} send={:?}", + n - off, + dst, + src, + cs, + r + ); + } else { + log::info!("[wg-udp] browser->tunnel parse FAIL ({}B)", n); + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break, + Err(_) => break, + } + } + + // Tunnel → browser: wrap each datagram in a §7 header and relay back. + loop { + let socket = sockets.get_mut::(handle); + if !socket.can_recv() { + break; + } + let (payload, src) = match socket.recv() { + Ok((data, meta)) => (data.to_vec(), meta.endpoint), + Err(_) => break, + }; + if let Some(client) = udp.client_addr { + let resp = build_udp_datagram(src, &payload); + let r = udp.relay.send_to(&resp, client); + log::info!( + "[wg-udp] tunnel->browser {}B src={} -> client={} send={:?}", + payload.len(), + src, + client, + r + ); + } + } } else { // Data relay between SOCKS5 client and smoltcp socket let socket = sockets.get_mut::(conn.smol_handle); diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index 9712f95..8afec72 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -728,9 +728,21 @@ impl WayfernManager { } if let Some(proxy) = proxy_url { + // Map the local proxy scheme to the matching PAC directive. SOCKS5 lets + // Chromium route UDP (QUIC/WebRTC) and resolve DNS through the proxy; + // PROXY is HTTP CONNECT (TCP only). The host:port is the same either way. + let (pac_directive, host_port) = if let Some(rest) = proxy.strip_prefix("socks5://") { + ("SOCKS5", rest) + } else { + ( + "PROXY", + proxy + .trim_start_matches("http://") + .trim_start_matches("https://"), + ) + }; let pac_data = format!( - "data:application/x-ns-proxy-autoconfig,function FindProxyForURL(url,host){{return \"PROXY {}\";}}", - proxy.trim_start_matches("http://").trim_start_matches("https://") + "data:application/x-ns-proxy-autoconfig,function FindProxyForURL(url,host){{return \"{pac_directive} {host_port}\";}}", ); args.push(format!("--proxy-pac-url={pac_data}")); args.push("--dns-prefetch-disable".to_string());