diff --git a/Cargo.lock b/Cargo.lock index c4b614d..fab11d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2976,6 +2976,7 @@ dependencies = [ name = "proxy-ffi" version = "0.4.40" dependencies = [ + "chrono", "libc", "proxy-client", "proxy-core", diff --git a/README.md b/README.md index 3449260..f80b0e5 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,12 @@ client executable in the process-bypass policy. Additional processes can bypass TUN with repeatable `--tun-bypass-process` options. Use `--tun-list-processes` to list currently running Windows executable names. +Desktop DNS prefers AliDNS and Tencent DNSPod through the physical interface. +Explicit process exclusions also apply to local HTTP/SOCKS clients using the +system proxy; their hostname lookups and outbound sockets use the physical +interface. See [DNS, exclusions and diagnostic logs](docs/client-usage.md#tun-mode) +for resolver order, platform differences and upgrade behavior. + Windows setup leaves every existing default route untouched and captures IPv4 with two owned `/1` routes. Shutdown and hot switching cancel all managed TUN sessions before restoring only those exact routes and the previous DNS state; diff --git a/crates/proxy-client/src/bin/main.rs b/crates/proxy-client/src/bin/main.rs index b1c0de7..899428d 100755 --- a/crates/proxy-client/src/bin/main.rs +++ b/crates/proxy-client/src/bin/main.rs @@ -504,6 +504,7 @@ async fn main() -> Result<()> { upstream_proxy: upstream_proxy.clone(), tun, force_proxy: None, + process_bypass: None, }; let env_listen_host = std::env::var("LISTEN_HOST").ok(); let listen_host = cli diff --git a/crates/proxy-client/src/client/http.rs b/crates/proxy-client/src/client/http.rs index c2fe278..b6bf25c 100644 --- a/crates/proxy-client/src/client/http.rs +++ b/crates/proxy-client/src/client/http.rs @@ -11,8 +11,7 @@ use tokio::io::AsyncWriteExt; use super::{ ForwardContext, ForwarderProvider, HeaderContext, HttpProxySnafu, ProxyContext, - ServerConnection, TcpForwardImpl, connect_native, get_tcp_stream, resolve_server_connection, - split_and_wrap, + ServerConnection, TcpForwardImpl, connect_native, get_tcp_stream, split_and_wrap, }; #[derive(Debug, Snafu)] @@ -251,6 +250,20 @@ impl HttpProxierProvider { ) -> super::Result<(ProxyStream, bool, Option>)> { // if we don't have ssl,only use proxy when host is part of `PROXY_KEYWORDS` if !self.has_ssl { + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + if context.direct_interface.is_some() { + let mut connection = context.connect(&self.host, self.port).await?; + connection + .stream + .write_all(context.buffer) + .await + .with_context(|_| IoSnafu { + uri: Some(self.get_uri()), + detail: "write process-bypass HTTP request", + }) + .context(HttpProxySnafu)?; + return Ok((connection.stream, false, None)); + } // Compiled-in direct hosts win over the upstream proxy and over // `force_proxy`; see `proxy_core::config::FORCED_DIRECT_HOSTS`. if context.honor_forced_direct @@ -378,16 +391,7 @@ impl HttpProxierProvider { stream, need_proxy, msg_key, - } = match resolve_server_connection( - self.host.as_str(), - self.port, - #[cfg(feature = "auto-proxy")] - context.sender, - context.upstream_proxy, - context.honor_forced_direct, - ) - .await - { + } = match context.connect(self.host.as_str(), self.port).await { Ok(connection) => connection, Err(error) => { let _ = context.stream.write_all( diff --git a/crates/proxy-client/src/client/mod.rs b/crates/proxy-client/src/client/mod.rs index 46a7ba2..4e44ef6 100755 --- a/crates/proxy-client/src/client/mod.rs +++ b/crates/proxy-client/src/client/mod.rs @@ -16,6 +16,8 @@ pub mod macos_dns_restore; pub mod macos_tun; #[cfg(feature = "network-extension")] pub mod packet_tunnel; +#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] +mod process_bypass; pub mod socks; pub mod tun; mod udp; @@ -69,6 +71,14 @@ impl Default for ClientConfig { } } +#[derive(Debug, Clone, Default)] +pub struct ProcessBypassConfig { + pub processes: tun2proxy::ProcessBypass, + pub virtual_dns: tun::TunVirtualDnsState, + /// Custom resolver used after preferred and physical-adapter resolvers. + pub dns_fallback: Option, +} + #[derive(Debug, Clone, Default)] pub struct ClientRuntimeConfig { pub client: ClientConfig, @@ -80,6 +90,9 @@ pub struct ClientRuntimeConfig { /// connection uses the remote proxy so client-owned direct sockets cannot /// be captured and fed back into the listener. pub force_proxy: Option>, + /// User-selected process rules, shared with a separately managed TUN. + /// The mandatory self bypass is never a local-proxy direct rule. + pub process_bypass: Option, } impl From for ClientRuntimeConfig { @@ -89,6 +102,7 @@ impl From for ClientRuntimeConfig { upstream_proxy: None, tun: None, force_proxy: None, + process_bypass: None, } } } @@ -119,6 +133,8 @@ pub struct ProxyContext<'a> { honor_forced_direct: bool, /// Whether the local listener accepts SOCKS5 UDP ASSOCIATE requests. enable_udp: bool, + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + direct_interface: Option<&'a process_bypass::DirectRoute>, /// TODO: let this stream abstract stream: TcpStream, } @@ -136,6 +152,34 @@ pub trait ForwarderProvider { ) -> impl std::future::Future> + Send; } +impl ProxyContext<'_> { + async fn connect(&self, host: &str, port: u16) -> Result { + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + if let Some(iface) = self.direct_interface { + let stream = process_bypass::connect(host, port, iface) + .await + .context(IoSnafu { + uri: Some(format!("{host}:{port}")), + detail: "process-bypass direct connection", + })?; + return Ok(ServerConnection { + stream: stream.into(), + need_proxy: false, + msg_key: None, + }); + } + resolve_server_connection( + host, + port, + #[cfg(feature = "auto-proxy")] + self.sender, + self.upstream_proxy, + self.honor_forced_direct, + ) + .await + } +} + pub struct ClientProxyContext { stream: TcpStream, #[cfg(feature = "auto-proxy")] @@ -144,6 +188,8 @@ pub struct ClientProxyContext { upstream_proxy: Option, honor_forced_direct: bool, enable_udp: bool, + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + process_matcher: Option>, } async fn handle_proxy( @@ -206,6 +252,24 @@ pub async fn handle_client(mut context: ClientProxyContext) -> Result<()> { detail: "Read First Header Error", })?; let header = header_buf.as_slice(); + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + let policy = if let Some(matcher) = &context.process_matcher { + match (context.stream.peer_addr(), context.stream.local_addr()) { + (Ok(peer), Ok(local)) => matcher.matcher.match_tcp(peer, local).await, + _ => None, + } + } else { + None + }; + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + let direct_interface = context.process_matcher.as_ref().and_then(|matcher| { + policy + .as_ref() + .is_some_and(tun2proxy::SessionPolicy::bypass) + .then(|| { + process_bypass::DirectRoute::new(matcher.virtual_dns.clone(), matcher.dns_fallback) + }) + }); let header_context = HeaderContext { header }; let proxy_context = ProxyContext { handshake_deadline: deadline, @@ -216,24 +280,39 @@ pub async fn handle_client(mut context: ClientProxyContext) -> Result<()> { upstream_proxy: context.upstream_proxy.as_ref(), honor_forced_direct: context.honor_forced_direct, enable_udp: context.enable_udp, + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + direct_interface: direct_interface.as_ref(), stream: context.stream, }; - if header.first() == Some(&5) { - handle_proxy( - proxy_context, - SocksProxierProvider::try_new(header_context)?, - permit, - ) - .await - } else { - handle_proxy( - proxy_context, - HttpProxierProvider::try_new(header_context)?, - permit, - ) - .await + let relay = async { + if header.first() == Some(&5) { + handle_proxy( + proxy_context, + SocksProxierProvider::try_new(header_context)?, + permit, + ) + .await + } else { + handle_proxy( + proxy_context, + HttpProxierProvider::try_new(header_context)?, + permit, + ) + .await + } + }; + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + if let Some(policy) = policy { + return tokio::select! { + result = relay => result, + _ = policy.wait_for_routing_change() => { + tracing::info!("local proxy connection closed after process policy changed"); + Ok(()) + } + }; } + relay.await } #[cfg(feature = "auto-proxy")] @@ -293,6 +372,24 @@ pub async fn run_client_with_listener_runtime_config( .unwrap_or(true); let upstream_proxy = config.as_ref().and_then(|c| c.upstream_proxy.clone()); let tun_config = config.as_ref().and_then(|c| c.tun.clone()); + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + let process_matcher = config + .as_ref() + .and_then(|c| c.process_bypass.clone()) + .or_else(|| { + tun_config.as_ref().map(|c| ProcessBypassConfig { + processes: c.bypass.user_process_bypass(), + virtual_dns: c.virtual_dns_state.clone(), + dns_fallback: Some(c.dns_server), + }) + }) + .map(|config| { + Arc::new(process_bypass::LocalPolicy { + matcher: tun2proxy::LocalProcessMatcher::new(config.processes), + virtual_dns: config.virtual_dns, + dns_fallback: config.dns_fallback.unwrap_or(tun::DEFAULT_TUN_DNS_SERVER), + }) + }); let force_proxy_controller = config.as_ref().and_then(|c| c.force_proxy.clone()); let enable_udp = config.as_ref().map(|c| c.client.enable_udp).unwrap_or(true); let tun_active = tun_config.is_some(); @@ -407,6 +504,8 @@ pub async fn run_client_with_listener_runtime_config( // See `proxy_core::config::FORCED_DIRECT_HOSTS`. honor_forced_direct: !tun_owns_routes, enable_udp, + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + process_matcher: process_matcher.clone(), }); let wrapped_task = async move { tokio::select! { diff --git a/crates/proxy-client/src/client/packet_tunnel.rs b/crates/proxy-client/src/client/packet_tunnel.rs index b29a7f2..dcef205 100644 --- a/crates/proxy-client/src/client/packet_tunnel.rs +++ b/crates/proxy-client/src/client/packet_tunnel.rs @@ -206,6 +206,7 @@ impl PacketTunnelRuntime { // enumeration or userspace interface binding is needed for direct // decisions made by the existing auto-proxy implementation. force_proxy: None, + process_bypass: None, }; let (input, input_rx) = mpsc::channel(QUEUE_CAPACITY); let (output_tx, output) = mpsc::channel(QUEUE_CAPACITY); diff --git a/crates/proxy-client/src/client/process_bypass.rs b/crates/proxy-client/src/client/process_bypass.rs new file mode 100644 index 0000000..5be8f97 --- /dev/null +++ b/crates/proxy-client/src/client/process_bypass.rs @@ -0,0 +1,363 @@ +//! The local HTTP/SOCKS entry point uses the same owner matcher and physical +//! connector as TUN capture. Internal TUN connections are excluded by PID. + +use std::io; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use proxy_core::datagram::{self, DatagramAddress, DatagramRelayReader, DatagramRelayWriter}; +use tokio::sync::{OnceCell, oneshot}; +use tun2proxy::direct::{self, BindInterface}; + +pub(super) struct LocalPolicy { + pub matcher: tun2proxy::LocalProcessMatcher, + pub virtual_dns: super::tun::TunVirtualDnsState, + pub dns_fallback: IpAddr, +} + +#[derive(Clone)] +pub(super) struct DirectRoute { + iface: Arc>, + virtual_dns: super::tun::TunVirtualDnsState, + dns_fallback: IpAddr, +} + +impl DirectRoute { + pub(super) fn new(virtual_dns: super::tun::TunVirtualDnsState, dns_fallback: IpAddr) -> Self { + Self { + iface: Arc::new(OnceCell::new()), + virtual_dns, + dns_fallback, + } + } + + async fn interface(&self) -> io::Result<&BindInterface> { + // Local services must work even while no physical adapter is available. + self.iface + .get_or_try_init(|| async { + tokio::task::spawn_blocking(|| direct::detect(None, None).map_err(io::Error::other)) + .await + .map_err(io::Error::other)? + }) + .await + } + + async fn resolve(&self, host: &str, port: u16, ipv6: bool) -> io::Result> { + if let Ok(ip) = host.parse::() { + return self.resolve_address(SocketAddr::new(ip, port), ipv6).await; + } + self.resolve_domain(host, port, ipv6).await + } + + async fn resolve_address( + &self, + mut address: SocketAddr, + ipv6: bool, + ) -> io::Result> { + address.set_ip(address.ip().to_canonical()); + match self.virtual_dns.resolve_ip(address.ip()).await? { + Some(domain) => self.resolve_domain(&domain, address.port(), ipv6).await, + None => Ok((address.is_ipv6() == ipv6).then_some(address)), + } + } + + async fn resolve_domain( + &self, + host: &str, + port: u16, + ipv6: bool, + ) -> io::Result> { + if host.eq_ignore_ascii_case("localhost") || host.eq_ignore_ascii_case("localhost.") { + let ip = if ipv6 { + Ipv6Addr::LOCALHOST.into() + } else { + Ipv4Addr::LOCALHOST.into() + }; + return Ok(Some(SocketAddr::new(ip, port))); + } + let iface = self.interface().await?; + if (ipv6 && iface.ipv6_index == 0) || (!ipv6 && iface.ipv4_index == 0) { + return Ok(None); + } + // Resolve the next family only after the previous attempt fails. This + // also covers A records that resolve successfully but cannot connect. + direct::resolve_domain_bound(host, port, self.dns_fallback, ipv6, iface) + .await + .map(Some) + } +} + +pub(super) async fn connect( + host: &str, + port: u16, + route: &DirectRoute, +) -> io::Result { + let mut last_error = io::Error::new( + io::ErrorKind::AddrNotAvailable, + "no usable direct destination", + ); + let mut connect_error = None; + for ipv6 in [false, true] { + let address = match route.resolve(host, port, ipv6).await { + Ok(Some(address)) => address, + Ok(None) => continue, + Err(error) => { + tracing::debug!(host, port, ipv6, %error, "direct destination resolution failed"); + last_error = error; + continue; + } + }; + let iface = if address.ip().is_loopback() { + None + } else { + Some(route.interface().await?) + }; + let started = Instant::now(); + let result = match iface { + Some(iface) => direct::connect_tcp_bound(address, iface).await, + None => tokio::net::TcpStream::connect(address).await, + }; + proxy_core::diagnostics::tcp_connect_finished(address, started.elapsed(), &result); + match result { + Ok(stream) => { + stream.set_nodelay(true)?; + tracing::info!(host, port, destination = %address, local = ?stream.local_addr().ok(), + interface = iface.map(|iface| iface.name.as_str()).unwrap_or("loopback"), + interface_index = iface.map(|iface| if address.is_ipv6() { iface.ipv6_index } else { iface.ipv4_index }), + "excluded local proxy client connected directly"); + return Ok(stream); + } + Err(error) => connect_error = Some(error), + } + } + // A missing AAAA record must not hide the preceding TCP failure. + Err(connect_error.unwrap_or(last_error)) +} + +pub(super) struct UdpReader { + physical: Option, + pending_physical: oneshot::Receiver, + loopback: DatagramRelayReader, +} +pub(super) struct UdpWriter { + physical: Option, + pending_physical: Option>, + loopback: DatagramRelayWriter, + route: DirectRoute, + resolved: std::collections::HashMap, +} + +pub(super) async fn udp_relay(route: &DirectRoute) -> proxy_core::Result<(UdpReader, UdpWriter)> { + // No physical socket is needed until the application sends a non-local + // datagram. Each relay independently validates its response peers. + let (loopback_reader, loopback_writer, _) = + datagram::create_direct_datagram_relay().await?.into_parts(); + let (sender, receiver) = oneshot::channel(); + Ok(( + UdpReader { + physical: None, + pending_physical: receiver, + loopback: loopback_reader, + }, + UdpWriter { + physical: None, + pending_physical: Some(sender), + loopback: loopback_writer, + route: route.clone(), + resolved: Default::default(), + }, + )) +} + +fn udp_error(detail: impl Into, source: io::Error) -> proxy_core::ProxyError { + proxy_core::ProxyError::Io { + context: "udp_bypass", + detail: detail.into(), + source, + } +} + +impl UdpReader { + pub(super) async fn recv(&mut self) -> proxy_core::Result> { + let physical = async { + let reader = match &mut self.physical { + Some(reader) => reader, + slot @ None => slot.insert((&mut self.pending_physical).await.map_err(|_| { + udp_error("physical relay closed", io::ErrorKind::BrokenPipe.into()) + })?), + }; + // Awaiting the reader is cancellation-safe when loopback wins. + reader.recv().await + }; + tokio::select! { result = physical => result, result = self.loopback.recv() => result } + } +} + +impl UdpWriter { + async fn send_to(&mut self, destination: SocketAddr, payload: &[u8]) -> proxy_core::Result<()> { + let packet = + datagram::encode_socks5_udp_packet(&DatagramAddress::Ip(destination), payload)?; + if destination.ip().is_loopback() { + return self.loopback.send(&packet).await; + } + let writer = match &mut self.physical { + Some(writer) => writer, + slot @ None => { + let iface = self + .route + .interface() + .await + .map_err(|source| udp_error("select physical interface", source))?; + let v4 = direct::bind_udp_unconnected(false, iface) + .map_err(|source| udp_error(format!("bind IPv4 to {iface}"), source))?; + let v6 = if iface.ipv6_index != 0 { + direct::bind_udp_unconnected(true, iface).ok() + } else { + None + }; + let (reader, writer, _) = + datagram::create_direct_datagram_relay_with_sockets(v4, v6).into_parts(); + if let Some(sender) = self.pending_physical.take() { + sender.send(reader).map_err(|_| { + udp_error( + "physical relay reader closed", + io::ErrorKind::BrokenPipe.into(), + ) + })?; + } + slot.insert(writer) + } + }; + writer.send(&packet).await + } + + pub(super) async fn send(&mut self, bytes: &[u8]) -> proxy_core::Result<()> { + let packet = datagram::parse_socks5_udp_packet(bytes)?; + let mut send_error = None; + if let DatagramAddress::Domain(host, port) = &packet.destination + && let Some((_, ip)) = self + .resolved + .get(host) + .filter(|(time, _)| time.elapsed() < Duration::from_secs(30)) + { + match self + .send_to(SocketAddr::new(*ip, *port), packet.payload) + .await + { + Ok(()) => return Ok(()), + Err(error) => { + tracing::debug!(host, port, %error, "cached direct UDP destination failed"); + send_error = Some(error); + self.resolved.remove(host); + } + } + } + let mut last_error = udp_error( + "no usable direct destination", + io::ErrorKind::AddrNotAvailable.into(), + ); + for ipv6 in [false, true] { + let resolved = match &packet.destination { + DatagramAddress::Ip(address) => self.route.resolve_address(*address, ipv6).await, + DatagramAddress::Domain(host, port) => self.route.resolve(host, *port, ipv6).await, + }; + let destination = match resolved { + Ok(Some(address)) => address, + Ok(None) => continue, + Err(error) => { + last_error = udp_error( + format!("resolve {:?} (IPv6={ipv6})", packet.destination), + error, + ); + tracing::debug!(error = %last_error, "direct UDP resolution failed"); + continue; + } + }; + match self.send_to(destination, packet.payload).await { + Ok(()) => { + if let DatagramAddress::Domain(host, _) = &packet.destination { + if self.resolved.len() >= 256 { + self.resolved + .retain(|_, (time, _)| time.elapsed() < Duration::from_secs(30)); + if self.resolved.len() >= 256 + && let Some(oldest) = self + .resolved + .iter() + .min_by_key(|(_, (time, _))| *time) + .map(|(host, _)| host.clone()) + { + self.resolved.remove(&oldest); + } + } + self.resolved + .insert(host.clone(), (Instant::now(), destination.ip())); + } + return Ok(()); + } + Err(error) => { + tracing::debug!(%destination, %error, "direct UDP send failed"); + send_error = Some(error); + } + } + } + Err(send_error.unwrap_or(last_error)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn loopback_connections_need_no_physical_interface_and_retry_ipv6() { + let route = DirectRoute::new(Default::default(), "223.5.5.5".parse().unwrap()); + let listener = tokio::net::TcpListener::bind((Ipv6Addr::LOCALHOST, 0)) + .await + .unwrap(); + // Only ::1 listens on this port; the IPv4 attempt must fall back. + let stream = connect("localhost", listener.local_addr().unwrap().port(), &route) + .await + .unwrap(); + assert!(stream.peer_addr().unwrap().is_ipv6()); + assert!(route.iface.get().is_none()); + drop(stream); + let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .unwrap(); + let stream = connect( + "::ffff:127.0.0.1", + listener.local_addr().unwrap().port(), + &route, + ) + .await + .unwrap(); + assert!(stream.peer_addr().unwrap().ip().is_loopback()); + assert!(route.iface.get().is_none()); + let socket = tokio::net::UdpSocket::bind((Ipv6Addr::LOCALHOST, 0)) + .await + .unwrap(); + let (mut reader, mut writer) = udp_relay(&route).await.unwrap(); + let packet = datagram::encode_socks5_udp_packet( + &DatagramAddress::Ip(socket.local_addr().unwrap()), + b"local-udp", + ) + .unwrap(); + writer.send(&packet).await.unwrap(); + let mut buffer = [0; 32]; + let (size, peer) = socket.recv_from(&mut buffer).await.unwrap(); + socket.send_to(&buffer[..size], peer).await.unwrap(); + let response = tokio::time::timeout(Duration::from_secs(1), reader.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!( + datagram::parse_socks5_udp_packet(&response) + .unwrap() + .payload, + b"local-udp" + ); + assert!(route.iface.get().is_none()); + assert!(writer.physical.is_none()); + } +} diff --git a/crates/proxy-client/src/client/socks.rs b/crates/proxy-client/src/client/socks.rs index 8fc21a0..aab2ea1 100644 --- a/crates/proxy-client/src/client/socks.rs +++ b/crates/proxy-client/src/client/socks.rs @@ -8,7 +8,7 @@ use tokio::net::TcpStream; use super::udp::UdpAssociation; use super::{ ForwardContext, Forwarder, ForwarderProvider, ServerConnection, SocksProxySnafu, - TcpForwardImpl, resolve_server_connection, split_and_wrap, + TcpForwardImpl, split_and_wrap, }; #[derive(Debug, Snafu)] @@ -161,16 +161,7 @@ impl ForwarderProvider for SocksProxierProvider { stream: server_stream, need_proxy, msg_key, - } = match resolve_server_connection( - host.as_str(), - port, - #[cfg(feature = "auto-proxy")] - proxy_context.sender, - proxy_context.upstream_proxy, - proxy_context.honor_forced_direct, - ) - .await - { + } = match proxy_context.connect(host.as_str(), port).await { Ok(connection) => connection, Err(error) => { // General server failure (RFC 1928); never signal a @@ -205,7 +196,11 @@ impl ForwarderProvider for SocksProxierProvider { command = "UDP ASSOCIATE", "received SOCKS5 proxy request" ); - if !proxy_context.enable_udp { + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + let direct = proxy_context.direct_interface.is_some(); + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] + let direct = false; + if !proxy_context.enable_udp && !direct { tracing::warn!( transport = "udp", client_address = ?client_addr, @@ -226,6 +221,8 @@ impl ForwarderProvider for SocksProxierProvider { proxy_context.stream, client_addr, proxy_context.upstream_proxy, + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + proxy_context.direct_interface, ) .await?; association.reply_success(relay_addr).await?; diff --git a/crates/proxy-client/src/client/tests.rs b/crates/proxy-client/src/client/tests.rs index a7d9c3e..13ca110 100644 --- a/crates/proxy-client/src/client/tests.rs +++ b/crates/proxy-client/src/client/tests.rs @@ -9,6 +9,8 @@ async fn a_client_that_stalls_after_socks_greeting_still_times_out() { upstream_proxy: None, honor_forced_direct: false, enable_udp: false, + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + process_matcher: None, })); client.write_all(&[5, 1, 0]).await.unwrap(); let mut greeting = [0; 2]; @@ -50,6 +52,8 @@ async fn client_input_deadline_does_not_cancel_outbound_setup() { tokio::time::pause(); let context = super::ProxyContext { handshake_deadline: tokio::time::Instant::now() + super::CLIENT_SETUP_TIMEOUT, + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + direct_interface: None, buffer: b"", #[cfg(feature = "auto-proxy")] sender: None, @@ -287,6 +291,7 @@ async fn start_client_for_test(upstream_proxy: ExternalProxyTarget) -> (String, upstream_proxy: Some(upstream_proxy), tun: None, force_proxy: None, + process_bypass: None, }), )); (addr, token) diff --git a/crates/proxy-client/src/client/tun.rs b/crates/proxy-client/src/client/tun.rs index 1269a8b..1780a61 100644 --- a/crates/proxy-client/src/client/tun.rs +++ b/crates/proxy-client/src/client/tun.rs @@ -18,9 +18,10 @@ use std::os::fd::{IntoRawFd, OwnedFd}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio_util::sync::CancellationToken; -use tun2proxy::{ArgDns, ArgProxy, ArgUdpStrategy, Args, ProcessBypass}; +use tun2proxy::{ArgDns, ArgProxy, ArgUdpStrategy, Args}; pub use tun2proxy::{ - NetworkTeardownError as TunNetworkTeardownError, VirtualDnsState as TunVirtualDnsState, + NetworkTeardownError as TunNetworkTeardownError, ProcessBypass, + VirtualDnsState as TunVirtualDnsState, }; // Desktop browsers and Android applications can create hundreds of concurrent @@ -28,7 +29,7 @@ pub use tun2proxy::{ // giving the embedded client enough headroom for a full-device workload. const EMBEDDED_TUN_MAX_SESSIONS: usize = 1024; -pub const DEFAULT_TUN_DNS_SERVER: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(8, 8, 8, 8)); +pub const DEFAULT_TUN_DNS_SERVER: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(223, 5, 5, 5)); pub fn default_dns_server() -> IpAddr { DEFAULT_TUN_DNS_SERVER @@ -101,6 +102,7 @@ pub type BypassSink = std::sync::Arc) + Send + Sync>; #[derive(Clone)] pub struct TunBypassController { processes: ProcessBypass, + user_processes: ProcessBypass, self_process: String, /// Forwards each update to an out-of-process TUN session, when there is one. /// @@ -134,6 +136,7 @@ impl TunBypassController { let self_process = current_process_name()?; let controller = Self { processes: ProcessBypass::default(), + user_processes: ProcessBypass::default(), self_process, #[cfg(target_os = "macos")] sink: std::sync::Arc::new(std::sync::Mutex::new(None)), @@ -166,6 +169,7 @@ impl TunBypassController { /// cannot be removed by CLI, FFI, or UI callers. pub fn set_user_processes(&self, user_processes: impl IntoIterator) { let mut effective = user_processes.into_iter().collect::>(); + self.user_processes.set_names(effective.iter().cloned()); effective.push(self.self_process.clone()); self.processes.set_names(effective); let names = self.processes.names(); @@ -199,6 +203,11 @@ impl TunBypassController { pub(crate) fn process_bypass(&self) -> ProcessBypass { self.processes.clone() } + + /// Shared explicit rules for local HTTP/SOCKS clients, without the loop guard. + pub fn user_process_bypass(&self) -> ProcessBypass { + self.user_processes.clone() + } } /// Settings for routing local device traffic through the client SOCKS5 port. @@ -211,7 +220,7 @@ pub struct TunConfig { pub virtual_dns_state: TunVirtualDnsState, /// Allocate synthetic DNS addresses only when explicitly enabled. pub fake_ip: bool, - /// Resolver for the TUN DNS portal, reached through the remote proxy. + /// Extra resolver for the desktop physical DNS portal; mobile uses TCP proxy DNS. pub dns_server: IpAddr, pub ipv6_enabled: bool, pub mtu: u16, @@ -260,7 +269,7 @@ impl TunConfig { self } - /// Use Fake-IP DNS instead of real answers resolved over the proxy's TCP path. + /// Use Fake-IP DNS instead of real answers from the platform's DNS path. pub fn with_fake_ip(mut self, enabled: bool) -> Self { self.fake_ip = enabled; self @@ -270,7 +279,15 @@ impl TunConfig { if self.fake_ip { ArgDns::Virtual } else { - ArgDns::OverTcp + if cfg!(any( + target_os = "windows", + target_os = "linux", + target_os = "macos" + )) { + ArgDns::Physical + } else { + ArgDns::OverTcp + } } } @@ -731,8 +748,8 @@ async fn run_with_ready_inner( // Android's VpnService already owns addresses, routes, DNS, and the // per-application policy. Desktop platforms remain managed here. setup: !platform_owned_tun, - // Real DNS answers still travel through the proxy, even when ordinary - // UDP is disabled. Fake-IP allocation is an explicit opt-in. + // Desktop DNS uses physical egress; platform VPNs keep protected proxy + // DNS. Both work with ordinary UDP disabled. Fake-IP is opt-in. dns: config.dns_mode(), dns_addr: config.dns_server, udp_strategy: tun_udp_strategy(config.udp_enabled, config.udp_direct_fallback), @@ -930,9 +947,18 @@ mod tests { use super::*; #[test] - fn tun_dns_defaults_to_proxied_real_answers_and_fake_ip_is_opt_in() { + fn tun_dns_uses_physical_resolvers_on_desktop_and_fake_ip_is_opt_in() { let config = TunConfig::new(Vec::::new()).unwrap(); - assert_eq!(config.dns_mode(), ArgDns::OverTcp); + let mode = if cfg!(any( + target_os = "windows", + target_os = "linux", + target_os = "macos" + )) { + ArgDns::Physical + } else { + ArgDns::OverTcp + }; + assert_eq!(config.dns_mode(), mode); assert_eq!(config.with_fake_ip(true).dns_mode(), ArgDns::Virtual); } diff --git a/crates/proxy-client/src/client/udp.rs b/crates/proxy-client/src/client/udp.rs index e895ddb..ef190ee 100644 --- a/crates/proxy-client/src/client/udp.rs +++ b/crates/proxy-client/src/client/udp.rs @@ -24,6 +24,8 @@ use super::{DatagramSnafu, Forwarder, Result, TransportSnafu}; const UDP_ASSOCIATION_SETUP_TIMEOUT: Duration = Duration::from_secs(10); enum UpstreamReader { + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + Direct(super::process_bypass::UdpReader), Proxy(Box>>>), Socks5(DatagramRelayReader), } @@ -31,6 +33,8 @@ enum UpstreamReader { impl UpstreamReader { async fn recv(&mut self) -> std::result::Result, ProxyError> { match self { + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + Self::Direct(reader) => reader.recv().await.map(std::borrow::Cow::Owned), Self::Proxy(reader) => reader.recv_ref().await.map(std::borrow::Cow::Borrowed), Self::Socks5(reader) => reader.recv().await.map(std::borrow::Cow::Owned), } @@ -38,6 +42,8 @@ impl UpstreamReader { } enum UpstreamWriter { + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + Direct(super::process_bypass::UdpWriter), Proxy(Box>>>), Socks5(DatagramRelayWriter), } @@ -45,6 +51,8 @@ enum UpstreamWriter { impl UpstreamWriter { async fn send(&mut self, packet: &[u8]) -> std::result::Result<(), ProxyError> { match self { + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + Self::Direct(writer) => writer.send(packet).await, Self::Proxy(writer) => writer.send(packet).await, Self::Socks5(writer) => writer.send(packet).await, } @@ -64,10 +72,12 @@ pub struct UdpAssociation { } impl UdpAssociation { - pub async fn bind( + pub(super) async fn bind( control_stream: TcpStream, requested_client: DatagramAddress, upstream_proxy: Option<&ExternalProxyTarget>, + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + direct_interface: Option<&super::process_bypass::DirectRoute>, ) -> Result<(Self, SocketAddr)> { let peer_addr = control_stream .peer_addr() @@ -106,8 +116,33 @@ impl UdpAssociation { // A UDP association remains pinned to the endpoint generation active // when its TCP control connection was accepted. let server_endpoint = runtime::server_endpoint(); - let (upstream_reader, upstream_writer, upstream_guard) = if let Some(proxy) = upstream_proxy + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + let direct = match direct_interface { + Some(iface) => Some( + super::process_bypass::udp_relay(iface) + .await + .context(DatagramSnafu)?, + ), + None => None, + }; + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] + let direct: Option<(UpstreamReader, UpstreamWriter)> = None; + let (upstream_reader, upstream_writer, upstream_guard) = if let Some((reader, writer)) = + direct { + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + { + ( + UpstreamReader::Direct(reader), + UpstreamWriter::Direct(writer), + None, + ) + } + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] + { + (reader, writer, None) + } + } else if let Some(proxy) = upstream_proxy { let relay = create_socks5_datagram_relay(proxy) .await .context(DatagramSnafu)?; @@ -200,14 +235,18 @@ impl UdpAssociation { ) }; + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + let direct = matches!(&upstream_reader, UpstreamReader::Direct(_)); + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] + let direct = false; tracing::info!( tcp_peer = %peer_addr, requested_client = ?requested_client, local_udp_relay = %relay_addr, - upstream = %upstream_proxy + upstream = %if direct { "physical-interface".to_string() } else { upstream_proxy .map(ExternalProxyTarget::display_url) - .unwrap_or_else(|| format!("proxy-server://{}:{}", server_endpoint.host, server_endpoint.port)), - native_encrypted = upstream_proxy.is_none(), + .unwrap_or_else(|| format!("proxy-server://{}:{}", server_endpoint.host, server_endpoint.port)) }, + native_encrypted = !direct && upstream_proxy.is_none(), "SOCKS5 UDP association initialized" ); diff --git a/crates/proxy-client/tests/local_process_bypass.rs b/crates/proxy-client/tests/local_process_bypass.rs new file mode 100644 index 0000000..48a98a6 --- /dev/null +++ b/crates/proxy-client/tests/local_process_bypass.rs @@ -0,0 +1,163 @@ +#![cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + +use std::time::Duration; + +use proxy_client::client::{ + ClientConfig, ClientRuntimeConfig, run_client_with_listener_runtime_config, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream, UdpSocket}; +use tokio_util::sync::CancellationToken; +use tokio_util::task::TaskTracker; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn excluded_loopback_clients_bypass_http_socks_and_udp_but_internal_tun_connections_do_not() { + tokio::time::timeout(Duration::from_secs(30), async { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let local = listener.local_addr().unwrap(); + let destination = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let destination_addr = destination.local_addr().unwrap(); + let udp = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let udp_addr = udp.local_addr().unwrap(); + let remote = TcpListener::bind("127.0.0.1:0").await.unwrap(); + proxy_core::config::runtime::init_config("127.0.0.1".into(), remote.local_addr().unwrap().port(), false, None); + let cancel = CancellationToken::new(); + let _guard = cancel.clone().drop_guard(); + let tracker = TaskTracker::new(); + let name = std::env::current_exe().unwrap().file_name().unwrap().to_string_lossy().into_owned(); + let bypass = tun2proxy::ProcessBypass::new([name]); + tracker.spawn(run_client_with_listener_runtime_config(listener, cancel.clone(), Some(tracker.clone()), Some(ClientRuntimeConfig { + client: ClientConfig { enable_auto_proxy: false, enable_udp: false, cache_dir: None }, + process_bypass: Some(proxy_client::client::ProcessBypassConfig { processes: bypass, ..Default::default() }), ..Default::default() + }))); + let echo_cancel = cancel.clone(); + tracker.spawn(async move { + loop { + let (stream, _) = tokio::select! { r = destination.accept() => r.unwrap(), _ = echo_cancel.cancelled() => break }; + let (mut read, mut write) = stream.into_split(); + tokio::select! { _ = tokio::io::copy(&mut read, &mut write) => {}, _ = echo_cancel.cancelled() => break } + } + }); + let echo_cancel = cancel.clone(); + tracker.spawn(async move { + let mut buffer = vec![0; 65535]; + loop { + let (n, peer) = tokio::select! { r = udp.recv_from(&mut buffer) => r.unwrap(), _ = echo_cancel.cancelled() => break }; + udp.send_to(&buffer[..n], peer).await.unwrap(); + } + }); + + // A different PID with the selected executable name represents the + // excluded application. All destinations remain ephemeral loopback. + let output = tokio::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "loopback_application", "--ignored", "--nocapture"]) + .env("BYPASS_TEST_PROXY", local.to_string()) + .env("BYPASS_TEST_TCP", destination_addr.to_string()) + .env("BYPASS_TEST_UDP", udp_addr.to_string()) + .kill_on_drop(true).output().await.unwrap(); + assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + assert!(tokio::time::timeout(Duration::from_millis(100), remote.accept()).await.is_err(), "excluded application reached remote proxy"); + + // The same executable/PID as the listener represents TUN's internal + // SOCKS caller. It must still follow normal upstream routing. + let mut internal = TcpStream::connect(local).await.unwrap(); + internal.write_all(format!("CONNECT {destination_addr} HTTP/1.1\r\n\r\n").as_bytes()).await.unwrap(); + let (mut upstream, _) = remote.accept().await.unwrap(); + let mut prefix = [0; 8]; + upstream.read_exact(&mut prefix).await.unwrap(); + assert_ne!(&prefix[..4], b"CONN"); + drop(upstream); + drop(internal); + cancel.cancel(); + tracker.close(); + tracker.wait().await; + }).await.expect("process bypass regression timed out"); +} + +#[tokio::test] +#[ignore = "launched as an isolated application by the parent regression"] +async fn loopback_application() { + let Some(proxy) = std::env::var_os("BYPASS_TEST_PROXY") else { + return; + }; + let tcp: std::net::SocketAddr = std::env::var("BYPASS_TEST_TCP").unwrap().parse().unwrap(); + let udp: std::net::SocketAddr = std::env::var("BYPASS_TEST_UDP").unwrap().parse().unwrap(); + for connect in [false, true] { + let mut stream = TcpStream::connect(proxy.to_str().unwrap()).await.unwrap(); + if connect { + stream + .write_all(format!("CONNECT {tcp} HTTP/1.1\r\n\r\n").as_bytes()) + .await + .unwrap(); + let mut header = Vec::new(); + proxy_core::transport::read_http_header(&mut stream, &mut header, 4096) + .await + .unwrap(); + assert!(header.starts_with(b"HTTP/1.1 200")); + stream.write_all(b"connect-ok").await.unwrap(); + let mut echoed = [0; 10]; + stream.read_exact(&mut echoed).await.unwrap(); + assert_eq!(&echoed, b"connect-ok"); + } else { + let request = format!("GET http://{tcp}/game HTTP/1.1\r\nHost: {tcp}\r\n\r\n"); + stream.write_all(request.as_bytes()).await.unwrap(); + let mut echoed = vec![0; request.len()]; + stream.read_exact(&mut echoed).await.unwrap(); + assert_eq!(echoed, request.as_bytes()); + } + } + let mut stream = tokio_socks_connect(proxy.to_str().unwrap(), tcp, 1).await; + stream.0.write_all(b"socks-ok").await.unwrap(); + let mut echoed = [0; 8]; + stream.0.read_exact(&mut echoed).await.unwrap(); + assert_eq!(&echoed, b"socks-ok"); + drop(stream); + // Exclusion also bypasses the user's remote-UDP-disabled policy. + let (control, relay) = + tokio_socks_connect(proxy.to_str().unwrap(), "0.0.0.0:0".parse().unwrap(), 3).await; + let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + for payload in [vec![], vec![42; 4096]] { + let packet = proxy_core::datagram::encode_socks5_udp_packet( + &proxy_core::datagram::DatagramAddress::Ip(udp), + &payload, + ) + .unwrap(); + socket.send_to(&packet, relay).await.unwrap(); + let mut buffer = vec![0; 65535]; + let (size, _) = socket.recv_from(&mut buffer).await.unwrap(); + assert_eq!( + proxy_core::datagram::parse_socks5_udp_packet(&buffer[..size]) + .unwrap() + .payload, + payload + ); + } + drop(control); +} + +async fn tokio_socks_connect( + proxy: &str, + destination: std::net::SocketAddr, + command: u8, +) -> (TcpStream, std::net::SocketAddr) { + let mut stream = TcpStream::connect(proxy).await.unwrap(); + stream.write_all(&[5, 1, 0]).await.unwrap(); + let mut greeting = [0; 2]; + stream.read_exact(&mut greeting).await.unwrap(); + assert_eq!(greeting, [5, 0]); + let mut request = vec![5, command, 0]; + proxy_core::datagram::encode_socks5_address( + &proxy_core::datagram::DatagramAddress::Ip(destination), + &mut request, + ) + .unwrap(); + stream.write_all(&request).await.unwrap(); + let mut response = [0; 10]; + stream.read_exact(&mut response).await.unwrap(); + assert_eq!(&response[..4], &[5, 0, 0, 1]); + let relay = std::net::SocketAddr::from(( + [response[4], response[5], response[6], response[7]], + u16::from_be_bytes([response[8], response[9]]), + )); + (stream, relay) +} diff --git a/crates/proxy-client/tests/socks5_udp_associate.rs b/crates/proxy-client/tests/socks5_udp_associate.rs index 1e4ff1b..4f31f42 100644 --- a/crates/proxy-client/tests/socks5_udp_associate.rs +++ b/crates/proxy-client/tests/socks5_udp_associate.rs @@ -108,6 +108,7 @@ async fn protocol_round_trips(protocol: WireProtocol) { upstream_proxy: None, tun: None, force_proxy: None, + process_bypass: None, }), )); @@ -148,6 +149,7 @@ async fn protocol_round_trips(protocol: WireProtocol) { ), tun: None, force_proxy: None, + process_bypass: None, }), )); assert_udp_round_trip( @@ -252,6 +254,7 @@ async fn socks5_udp_associate_returns_command_not_supported_when_disabled() { upstream_proxy: None, tun: None, force_proxy: None, + process_bypass: None, }), )); diff --git a/crates/proxy-core/src/datagram.rs b/crates/proxy-core/src/datagram.rs index 9dae6dc..d6eb897 100644 --- a/crates/proxy-core/src/datagram.rs +++ b/crates/proxy-core/src/datagram.rs @@ -4,11 +4,12 @@ //! module centralizes the framing needed to preserve those boundaries and the //! direct/SOCKS5 relay implementations shared by the client and server. +use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::sync::Arc; use std::time::Duration; -use dashmap::DashMap; +use parking_lot::Mutex; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpStream, UdpSocket}; @@ -319,13 +320,29 @@ impl DatagramRelay { /// Create a relay that sends UDP datagrams directly from this process. pub async fn create_direct_datagram_relay() -> crate::Result { let sockets = UdpSocketSet::bind().await?; + Ok(direct_datagram_relay(sockets)) +} + +/// Reuse the direct relay's source validation and framing with sockets bound +/// by the platform owner (for example to a physical interface under TUN). +pub fn create_direct_datagram_relay_with_sockets( + v4: UdpSocket, + v6: Option, +) -> DatagramRelay { + direct_datagram_relay(UdpSocketSet { + v4: Arc::new(v4), + v6: v6.map(Arc::new), + }) +} + +fn direct_datagram_relay(sockets: UdpSocketSet) -> DatagramRelay { tracing::info!( ipv4_socket = ?sockets.v4.local_addr().ok(), ipv6_socket = ?sockets.v6.as_ref().and_then(|socket| socket.local_addr().ok()), "direct UDP relay sockets initialized" ); - let peers = Arc::new(DashMap::new()); - Ok(DatagramRelay { + let peers = Arc::new(Mutex::new(HashMap::new())); + DatagramRelay { reader: DatagramRelayReader::Direct(DirectDatagramReader { sockets: sockets.clone(), peers: peers.clone(), @@ -334,7 +351,7 @@ pub async fn create_direct_datagram_relay() -> crate::Result { }), writer: DatagramRelayWriter::Direct(DirectDatagramWriter { sockets, peers }), _control_stream: None, - }) + } } /// Create an RFC 1928 UDP association through an external SOCKS5 proxy. @@ -505,7 +522,7 @@ impl UdpSocketSet { pub struct DirectDatagramReader { sockets: UdpSocketSet, - peers: Arc>, + peers: Arc>>, v4_buffer: Vec, v6_buffer: Vec, } @@ -552,7 +569,7 @@ impl DirectDatagramReader { (size, source, false) } }; - if !self.peers.contains_key(&source) { + if !self.peers.lock().contains_key(&source) { tracing::debug!(%source, "discarding UDP response from an unknown peer"); continue; } @@ -574,7 +591,52 @@ impl DirectDatagramReader { pub struct DirectDatagramWriter { sockets: UdpSocketSet, - peers: Arc>, + peers: Arc>>, +} + +#[derive(Default)] +struct DirectPeer { + pending: usize, + contacted: bool, +} + +// Register before send_to can yield, and roll back failed/cancelled sends. +// Concurrent sends to the same peer must not revoke a successful send's grant. +struct PendingPeer<'a> { + peers: &'a Mutex>, + destination: SocketAddr, +} + +impl<'a> PendingPeer<'a> { + fn new( + peers: &'a Mutex>, + destination: SocketAddr, + ) -> crate::Result { + let mut entries = peers.lock(); + if !entries.contains_key(&destination) && entries.len() >= MAX_DIRECT_UDP_PEERS { + return protocol_error("UDP association exceeded its destination limit"); + } + entries.entry(destination).or_default().pending += 1; + Ok(Self { peers, destination }) + } + + fn confirm(&self) { + if let Some(peer) = self.peers.lock().get_mut(&self.destination) { + peer.contacted = true; + } + } +} + +impl Drop for PendingPeer<'_> { + fn drop(&mut self) { + let mut entries = self.peers.lock(); + if let Some(peer) = entries.get_mut(&self.destination) { + peer.pending -= 1; + if peer.pending == 0 && !peer.contacted { + entries.remove(&self.destination); + } + } + } } impl DirectDatagramWriter { @@ -586,21 +648,16 @@ impl DirectDatagramWriter { let Some(socket) = self.sockets.socket_for(destination) else { continue; }; - // Only destinations contacted by this association may send data - // back. Besides rejecting unsolicited packets, the bounded set - // prevents a local client from growing per-association state - // indefinitely by cycling through arbitrary destination tuples. - if !self.peers.contains_key(&destination) && self.peers.len() >= MAX_DIRECT_UDP_PEERS { - return protocol_error("UDP association exceeded its destination limit"); - } + let authorization = PendingPeer::new(&self.peers, destination)?; match socket.send_to(packet.payload, destination).await { Ok(_) => { - self.peers.insert(destination, ()); + authorization.confirm(); + let tracked_peers = self.peers.lock().len(); tracing::debug!( direction = "tunnel_to_remote", remote = %destination, payload_bytes = packet.payload.len(), - tracked_peers = self.peers.len(), + tracked_peers, "sent direct UDP request" ); return Ok(()); @@ -855,6 +912,38 @@ mod tests { use crate::codec::{AsyncReader, AsyncWriter}; use crate::protocol::get_check_sum; + #[tokio::test] + async fn failed_udp_send_revokes_only_unconfirmed_peers() { + let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap(); + let destination = DatagramAddress::Ip(socket.local_addr().unwrap()); + let (mut reader, writer, _) = create_direct_datagram_relay().await.unwrap().into_parts(); + let DatagramRelayWriter::Direct(writer) = writer else { + unreachable!() + }; + // A valid SOCKS frame can still exceed the IPv4 UDP payload limit. + let oversized = encode_socks5_udp_packet(&destination, &vec![0; 65508]).unwrap(); + assert!(writer.send(&oversized).await.is_err()); + assert!(writer.peers.lock().is_empty()); + + writer + .send(&encode_socks5_udp_packet(&destination, b"request").unwrap()) + .await + .unwrap(); + let mut buffer = [0; 32]; + let (_, peer) = socket.recv_from(&mut buffer).await.unwrap(); + assert!(writer.send(&oversized).await.is_err()); + socket.send_to(b"reply", peer).await.unwrap(); + let response = tokio::time::timeout(Duration::from_secs(1), reader.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!( + parse_socks5_udp_packet(&response).unwrap().payload, + b"reply" + ); + assert_eq!(writer.peers.lock().len(), 1); + } + #[test] fn socks5_udp_packet_round_trips_all_address_types() { let addresses = [ diff --git a/crates/proxy-core/src/diagnostics.rs b/crates/proxy-core/src/diagnostics.rs index c770b72..23cc6d0 100644 --- a/crates/proxy-core/src/diagnostics.rs +++ b/crates/proxy-core/src/diagnostics.rs @@ -79,7 +79,7 @@ pub fn phase(name: &'static str) { }); } -pub(crate) fn tcp_connect_finished( +pub fn tcp_connect_finished( address: SocketAddr, elapsed: Duration, result: &std::io::Result, diff --git a/crates/proxy-ffi/Cargo.toml b/crates/proxy-ffi/Cargo.toml index f7eb4b5..1c3ae44 100644 --- a/crates/proxy-ffi/Cargo.toml +++ b/crates/proxy-ffi/Cargo.toml @@ -16,6 +16,7 @@ tokio = { workspace = true } tokio-util = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +chrono = { workspace = true } serde_json = { workspace = true } reqwest = { workspace = true } diff --git a/crates/proxy-ffi/src/disk_log.rs b/crates/proxy-ffi/src/disk_log.rs new file mode 100644 index 0000000..bb240c4 --- /dev/null +++ b/crates/proxy-ffi/src/disk_log.rs @@ -0,0 +1,385 @@ +//! Native diagnostics are persisted before UI filtering and callback throttling. +//! A bounded worker queue keeps file I/O off forwarding and Flutter threads. + +use std::fs::{self, File, OpenOptions}; +use std::io::{self, BufWriter, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, mpsc}; +use std::time::Duration; + +static SINK: Mutex> = Mutex::new(None); +const MAX_QUEUED_BYTES: usize = 8 * 1024 * 1024; +const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; +const MAX_FILES: usize = 8; +const PREFIX: &str = "proxy-native-"; + +enum Command { + Entry(String), + Flush(mpsc::SyncSender>), +} + +#[derive(Clone)] +struct DiskLog { + directory: PathBuf, + sender: mpsc::SyncSender, + queued_bytes: Arc, + dropped: Arc, +} + +impl DiskLog { + fn new(directory: &Path) -> io::Result { + fs::create_dir_all(directory)?; + let mut files = LogFiles::new(directory.to_path_buf())?; + let (sender, receiver) = mpsc::sync_channel(1024); + let queued_bytes = Arc::new(AtomicUsize::new(0)); + let dropped = Arc::new(AtomicUsize::new(0)); + let bytes = Arc::clone(&queued_bytes); + let lost = Arc::clone(&dropped); + std::thread::Builder::new() + .name("proxy-log-writer".into()) + .spawn(move || { + let mut failed = false; + loop { + let command = receiver.recv_timeout(Duration::from_millis(250)); + let skipped = lost.swap(0, Ordering::Relaxed); + let result = (|| -> io::Result<()> { + if skipped > 0 { + files.write(&format!( + "{} WARN native disk queue overflow: {skipped} entries lost\n", + chrono::Local::now().to_rfc3339() + ))?; + } + match &command { + Ok(Command::Entry(line)) => files.write(line), + _ => files.writer.flush(), + } + })(); + if let Ok(Command::Entry(line)) = &command { + bytes.fetch_sub(line.len(), Ordering::Relaxed); + } + if let Err(error) = &result { + // Never feed writer failures back into the same queue. + eprintln!("native log persistence failed: {error}"); + if !failed { + crate::logging::send_callback( + 4, + &format!("Native log persistence failed: {error}"), + ); + } + failed = true; + } else if failed { + crate::logging::send_callback( + 3, + "Native log persistence recovered; records may have been lost during \ + the disk failure", + ); + failed = false; + } + if let Ok(Command::Flush(done)) = &command { + let _ = done.send(result); + } + if matches!(command, Err(mpsc::RecvTimeoutError::Disconnected)) { + break; + } + } + })?; + Ok(Self { + directory: directory.into(), + sender, + queued_bytes, + dropped, + }) + } + + fn write(&self, level: i32, message: &str) { + let level = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"][level.clamp(0, 4) as usize]; + let line = format!("{} {level} {message}\n", chrono::Local::now().to_rfc3339()); + let length = line.len(); + if self + .queued_bytes + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |bytes| { + (length <= MAX_QUEUED_BYTES.saturating_sub(bytes)).then_some(bytes + length) + }) + .is_err() + { + self.dropped.fetch_add(1, Ordering::Relaxed); + return; + } + if self.sender.try_send(Command::Entry(line)).is_err() { + self.queued_bytes.fetch_sub(length, Ordering::Relaxed); + self.dropped.fetch_add(1, Ordering::Relaxed); + } + } + + fn flush(&self) -> io::Result<()> { + let (done, received) = mpsc::sync_channel(1); + // Shutdown is the only blocking producer: enqueue the barrier even + // when the data queue is full, then wait for the worker's flush result. + self.sender + .send(Command::Flush(done)) + .map_err(io::Error::other)?; + received.recv().map_err(io::Error::other)? + } +} + +struct LogFiles { + directory: PathBuf, + writer: BufWriter, + lease: File, + bytes: u64, + sequence: u64, +} + +impl LogFiles { + fn new(directory: PathBuf) -> io::Result { + let _directory_lock = Self::lock_directory(&directory)?; + let (writer, lease, sequence) = Self::open(&directory, 0)?; + let mut files = Self { + directory, + writer, + lease, + bytes: 0, + sequence, + }; + files.cleanup()?; + Ok(files) + } + + fn lock_directory(directory: &Path) -> io::Result { + let lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(directory.join(".proxy-native.lock"))?; + // Serialize creation and cleanup so a new log cannot be removed before + // its lifetime lock is acquired. Dropping the handle releases the lock. + lock.lock()?; + Ok(lock) + } + + fn open(directory: &Path, mut sequence: u64) -> io::Result<(BufWriter, File, u64)> { + loop { + let name = format!( + "{PREFIX}{}-{}-{sequence:04}.log", + chrono::Local::now().format("%Y%m%d-%H%M%S"), + std::process::id() + ); + let path = directory.join(name); + match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(file) => { + // Lock a sidecar, so diagnostics remain readable on + // Windows where byte-range locks also exclude readers. + let lease = Self::open_lease(&path)?; + lease.lock()?; + return Ok((BufWriter::new(file), lease, sequence)); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => sequence += 1, + Err(error) => return Err(error), + } + } + } + + fn open_lease(path: &Path) -> io::Result { + OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path.with_extension("log.lock")) + } + + fn cleanup(&mut self) -> io::Result<()> { + let mut files = fs::read_dir(&self.directory)? + .filter_map(Result::ok) + .filter(|entry| { + let name = entry.file_name(); + let name = name.to_string_lossy(); + name.starts_with(PREFIX) + && name.ends_with(".log") + && entry.file_type().is_ok_and(|kind| kind.is_file()) + }) + .collect::>(); + files.sort_by_key(|entry| { + entry + .metadata() + .and_then(|metadata| metadata.modified()) + .ok() + }); + let mut remaining = files.len(); + for entry in files { + if remaining <= MAX_FILES { + break; + } + let Ok(lease) = Self::open_lease(&entry.path()) else { + continue; + }; + // Windows, Linux and macOS all keep this exclusive lock while the + // owning worker writes. Never unlink another live writer's file. + if lease.try_lock().is_ok() && fs::remove_file(entry.path()).is_ok() { + let _ = fs::remove_file(entry.path().with_extension("log.lock")); + remaining -= 1; + } + } + Ok(()) + } + + fn write(&mut self, line: &str) -> io::Result<()> { + if self.bytes + line.len() as u64 > MAX_FILE_BYTES { + let _directory_lock = Self::lock_directory(&self.directory)?; + self.writer.flush()?; + (self.writer, self.lease, self.sequence) = + Self::open(&self.directory, self.sequence + 1)?; + self.bytes = 0; + self.cleanup()?; + } + self.writer.write_all(line.as_bytes())?; + self.bytes += line.len() as u64; + Ok(()) + } +} + +pub(crate) fn configure(directory: &Path) -> io::Result<()> { + let mut sink = SINK.lock().unwrap_or_else(|error| error.into_inner()); + if sink + .as_ref() + .is_some_and(|sink| sink.directory == directory) + { + return Ok(()); + } + *sink = Some(DiskLog::new(directory)?); + Ok(()) +} + +pub(crate) fn write(level: i32, message: &str) { + if let Some(sink) = SINK + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_ref() + { + sink.write(level, message); + } +} + +pub(crate) fn flush() { + let sink = SINK + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_ref() + .cloned(); + // Never hold the global producer lock while waiting on I/O or callbacks. + if let Some(sink) = sink + && let Err(error) = sink.flush() + { + eprintln!("native log flush failed: {error}"); + crate::logging::send_callback(4, &format!("Native log flush failed: {error}")); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shutdown_flush_waits_for_a_full_queue_and_the_worker() { + let (sender, receiver) = mpsc::sync_channel(1); + sender.send(Command::Entry("queued record".into())).unwrap(); + let sink = DiskLog { + directory: PathBuf::new(), + sender, + queued_bytes: Arc::new(AtomicUsize::new(0)), + dropped: Arc::new(AtomicUsize::new(0)), + }; + let (started, start) = mpsc::sync_channel(1); + let (finished, finish) = mpsc::sync_channel(1); + let flush = std::thread::spawn(move || { + started.send(()).unwrap(); + let result = sink.flush(); + finished.send(()).unwrap(); + result + }); + start.recv().unwrap(); + assert!(finish.recv_timeout(Duration::from_millis(25)).is_err()); + assert!(matches!(receiver.recv().unwrap(), Command::Entry(_))); + let Command::Flush(done) = receiver.recv_timeout(Duration::from_secs(1)).unwrap() else { + panic!("missing flush barrier"); + }; + assert!(finish.try_recv().is_err()); + done.send(Ok(())).unwrap(); + flush.join().unwrap().unwrap(); + } + + #[test] + fn native_retention_keeps_live_files_readable_and_removes_closed_files() { + let directory = std::env::temp_dir().join(format!( + "proxy-retention-test-{}-{}", + std::process::id(), + chrono::Utc::now().timestamp_nanos_opt().unwrap() + )); + fs::create_dir_all(&directory).unwrap(); + let mut live = Vec::new(); + for _ in 0..MAX_FILES + 1 { + let mut files = LogFiles::new(directory.clone()).unwrap(); + files.write("live diagnostics\n").unwrap(); + files.writer.flush().unwrap(); + live.push(files); + } + let log_paths = || { + fs::read_dir(&directory) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "log")) + .collect::>() + }; + let paths = log_paths(); + assert_eq!(paths.len(), MAX_FILES + 1); + for path in &paths { + assert_eq!(fs::read_to_string(path).unwrap(), "live diagnostics\n"); + } + drop(live); + let files = LogFiles::new(directory.clone()).unwrap(); + assert_eq!(log_paths().len(), MAX_FILES); + drop(files); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn native_disk_keeps_complete_records_independently_of_the_ui() { + let directory = std::env::temp_dir().join(format!( + "proxy-log-test-{}-{}", + std::process::id(), + chrono::Utc::now().timestamp_nanos_opt().unwrap() + )); + let sink = DiskLog::new(&directory).unwrap(); + for i in 0..100 { + sink.write( + 2, + &format!("CONNECT example.test:443 request={i} 中文\nCaused by: connection reset"), + ); + } + sink.flush().unwrap(); + let file = fs::read_dir(&directory) + .unwrap() + .find(|entry| { + entry + .as_ref() + .is_ok_and(|entry| entry.path().extension().is_some_and(|ext| ext == "log")) + }) + .unwrap() + .unwrap() + .path(); + let text = fs::read_to_string(file).unwrap(); + assert_eq!(text.matches("CONNECT example.test:443").count(), 100); + assert!(text.contains("request=99 中文\nCaused by: connection reset")); + drop(sink); + // The worker owns the open file until its disconnected receive flushes. + for _ in 0..100 { + if fs::remove_dir_all(&directory).is_ok() { + return; + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!("log worker did not release its files"); + } +} diff --git a/crates/proxy-ffi/src/handle.rs b/crates/proxy-ffi/src/handle.rs index 25ea1b3..3986e41 100644 --- a/crates/proxy-ffi/src/handle.rs +++ b/crates/proxy-ffi/src/handle.rs @@ -59,6 +59,7 @@ pub struct ProxyHandle { pub(crate) local_port: u16, pub(crate) http_client: Mutex>, tun_bypass: Arc>>, + local_process_bypass: proxy_client::client::tun::ProcessBypass, tun_lifecycle: Mutex, tun_running: Arc, tun_generation: Arc, @@ -141,6 +142,7 @@ pub extern "C" fn proxy_create() -> *mut ProxyHandle { local_port: 0, http_client: Mutex::new(None), tun_bypass: Arc::new(Mutex::new(None)), + local_process_bypass: proxy_client::client::tun::ProcessBypass::default(), tun_lifecycle: Mutex::new(TunLifecycle::default()), tun_running: Arc::new(AtomicBool::new(false)), tun_generation: Arc::new(AtomicU64::new(0)), @@ -599,6 +601,12 @@ fn proxy_start_inner( }; if let Some(cache_dir) = &cache_dir { + if let Err(error) = crate::disk_log::configure(&cache_dir.join("logs")) { + send_log( + 4, + &format!("Failed to initialize native disk logging: {error}"), + ); + } match handle .runtime .block_on(handle.tun_virtual_dns.enable_persistence_in(cache_dir)) @@ -640,6 +648,9 @@ fn proxy_start_inner( Ok(server) => *server, Err(_) => return ProxyResult::RuntimeError, }; + handle + .local_process_bypass + .set_names(tun_bypass_processes.iter().cloned()); let tun_config = if enable_tun { match TunConfig::new(tun_bypass_processes) { Ok(config) => Some( @@ -676,6 +687,11 @@ fn proxy_start_inner( // destroy cannot wait for. tun: None, force_proxy: Some(Arc::clone(&handle.force_proxy)), + process_bypass: Some(proxy_client::client::ProcessBypassConfig { + processes: handle.local_process_bypass.clone(), + virtual_dns: handle.tun_virtual_dns.clone(), + dns_fallback: Some(dns_server), + }), }; // Bind listener synchronously before reporting start success. @@ -995,6 +1011,9 @@ fn start_configured_tun( config: TunConfig, ready_message: String, ) -> ProxyResult { + handle + .local_process_bypass + .set_names(config.bypass.user_process_bypass().names()); // Desktop TUN keeps the user's auto-proxy setting. This process is always in // the TUN bypass list, so a direct socket it opens is relayed out through the // physical interface rather than looping back into this listener, and the @@ -1505,6 +1524,8 @@ pub unsafe extern "C" fn proxy_stop(handle: *mut ProxyHandle) -> ProxyResult { handle.listener_task = None; } + crate::disk_log::flush(); + if handle.cancel_token.take().is_some() { handle.running.store(false, Ordering::SeqCst); handle.tun_running.store(false, Ordering::Release); @@ -1566,10 +1587,10 @@ pub unsafe extern "C" fn proxy_set_tun_bypass_processes( Ok(tun_bypass) => tun_bypass, Err(_) => return ProxyResult::RuntimeError, }; - let Some(controller) = tun_bypass.as_ref() else { - return ProxyResult::NotRunning; - }; - controller.set_user_processes(processes); + if let Some(controller) = tun_bypass.as_ref() { + controller.set_user_processes(processes.iter().cloned()); + } + handle.local_process_bypass.set_names(processes); clear_last_error(handle); ProxyResult::Ok } diff --git a/crates/proxy-ffi/src/lib.rs b/crates/proxy-ffi/src/lib.rs index c288f23..e0b62e3 100644 --- a/crates/proxy-ffi/src/lib.rs +++ b/crates/proxy-ffi/src/lib.rs @@ -3,6 +3,7 @@ //! This module provides a C-compatible interface for external applications to use the proxy client. //! It reuses the existing client module logic. +mod disk_log; mod groups; mod handle; mod latency; diff --git a/crates/proxy-ffi/src/logging.rs b/crates/proxy-ffi/src/logging.rs index 53f45cb..f6d1437 100644 --- a/crates/proxy-ffi/src/logging.rs +++ b/crates/proxy-ffi/src/logging.rs @@ -79,6 +79,17 @@ impl CallbackRateLimiter { /// Internal function to send log to callback pub(crate) fn send_log(level: c_int, message: &str) { + if level >= i32::from(MIN_LOG_LEVEL.load(Ordering::Relaxed).min(2)) { + crate::disk_log::write(level, message); + } + send_callback(level, message); +} + +/// Writer diagnostics bypass the file queue so disk failures cannot recurse. +pub(crate) fn send_callback(level: c_int, message: &str) { + if level < MIN_LOG_LEVEL.load(Ordering::Relaxed) as c_int { + return; + } if LOG_CALLBACK.load(Ordering::Relaxed).is_null() { return; } @@ -199,9 +210,7 @@ where tracing::Level::WARN => 3, tracing::Level::ERROR => 4, }; - if level < MIN_LOG_LEVEL.load(Ordering::Relaxed) as c_int - || LOG_CALLBACK.load(Ordering::Relaxed).is_null() - { + if level < i32::from(MIN_LOG_LEVEL.load(Ordering::Relaxed).min(2)) { return; } diff --git a/deps/ipstack b/deps/ipstack index 56da8d1..ca2fc88 160000 --- a/deps/ipstack +++ b/deps/ipstack @@ -1 +1 @@ -Subproject commit 56da8d139b7ae89322d7ff0605c69c78b051ded1 +Subproject commit ca2fc8856e84248f7f7dbb9f27aab43de9c8212d diff --git a/deps/tun2proxy b/deps/tun2proxy index a82e411..3b16fd6 160000 --- a/deps/tun2proxy +++ b/deps/tun2proxy @@ -1 +1 @@ -Subproject commit a82e411d097d438bd51ff55a0aca951d6870c7f6 +Subproject commit 3b16fd610a14b4965be2e47ac3bb1a90cae24609 diff --git a/docs/client-usage.md b/docs/client-usage.md index e22ee5f..c23b416 100644 --- a/docs/client-usage.md +++ b/docs/client-usage.md @@ -21,8 +21,8 @@ Without `--set-system-proxy`, manually configure system proxy to `127.0.0.1:` | Enable SOCKS5 UDP ASSOCIATE (default: `true`) | | `--tun` | Capture device traffic with a local TUN interface | -| `--tun-fake-ip true` | Opt into virtual DNS; default is real DNS answers through the proxy | -| `--tun-dns-server ` | Resolver for the TUN DNS portal (default: `8.8.8.8`), reached from the proxy server | +| `--tun-fake-ip true` | Opt into virtual DNS; desktop defaults to real answers over the physical interface | +| `--tun-dns-server ` | Additional desktop physical DNS fallback; platform VPN proxy resolver (default: `223.5.5.5`) | | `--tun-udp-direct-fallback ` | Send UDP directly when proxy UDP is off; `false` blocks it (default: `true`) | | `--tun-bypass-process ` | Bypass TUN for an executable name (repeatable) | | `--tun-list-processes` | List running Windows executable names and exit | @@ -51,8 +51,9 @@ Operational details: cannot interpret the new `udp_associate` transport header. If the remote end closes before the readiness byte, the client now reports this as a likely server-version mismatch instead of the unhelpful `unexpected end of file`. -- UDP always uses the configured remote proxy server. TCP keyword/GeoIP split - routing is not applied per UDP datagram. +- UDP uses the configured remote proxy server unless the local calling process + is explicitly excluded. TCP keyword/GeoIP split routing is not applied per + UDP datagram. - `--msg-key` enables AES-256-GCM authentication and encryption per UDP frame. - `socks5://` and `socks5h://` upstream proxies support UDP ASSOCIATE when the upstream server implements it. `socks5://` resolves domain targets locally; @@ -89,9 +90,17 @@ capture policy by application package name. The local port is a protocol-multiplexed HTTP/SOCKS5 listener, but tun2proxy deliberately uses its SOCKS5 endpoint because SOCKS5 preserves both TCP and UDP semantics. A successful local listener bind is therefore a prerequisite for -starting TUN. By default, captured DNS queries (UDP or TCP on port 53) are sent -over TCP through the proxy and return real addresses. This also works when -ordinary UDP forwarding is disabled. Fake-IP is optional: enable **Fake-IP DNS** +starting TUN. Desktop TUN resolves captured DNS queries (UDP or TCP on port 53) +through sockets bound to the physical interface, returning real addresses. The +ordered resolvers are AliDNS `223.5.5.5`, Tencent DNSPod `119.29.29.29`, AliDNS +`223.6.6.6`, then the physical adapter's DNS and the configured fallback. UDP +retries and TCP fallback precede moving to the next resolver; a valid NXDOMAIN +is final. This also works when ordinary UDP forwarding is disabled. Windows' +shared DNS service hides the original application, so the desktop DNS portal +and excluded processes use the same physical resolver path, avoiding a shared +cache populated with CDN addresses selected for the proxy exit. Android, iOS +and the macOS Network Extension keep their platform-managed proxy DNS path. +Fake-IP is optional: enable **Fake-IP DNS** in the GUI configuration or pass `--tun-fake-ip true` to the CLI to allocate virtual addresses and forward the original domain. Missing settings in older configurations default to disabled. Changing this setting requires stopping and @@ -100,16 +109,14 @@ its DNS portal at `172.19.0.2`, outside the `198.18.0.0/15` fake-IP pool, so its opportunistic private-DNS probe cannot collide with an allocated application hostname. -Set **TUN DNS resolver IP** in the GUI or `--tun-dns-server` in the CLI to use a -private or enterprise resolver instead of the displayed `8.8.8.8` default. The -resolver must accept DNS over TCP on port 53 and be reachable from the remote -proxy server; the client does not silently select the host's physical DNS. -This setting supplies the resolver behind private TUN DNS portals; explicitly -addressed public resolvers keep their destination. Restart the proxy connection -after changing it. TCP DNS CONNECT requests always use the configured upstream, -even under reverse-geo or direct-cache rules; non-DNS destinations still follow -auto-proxy policy. IPv4-only TUN sessions remove AAAA records from both UDP and -TCP DNS replies, including fragmented TCP replies. +**TUN DNS resolver IP** / `--tun-dns-server` defaults to `223.5.5.5`; saved values +are preserved. On desktop it is an additional fallback, not an override of the +ordered public resolvers; split-horizon enterprise DNS is not provided by this +policy. In platform-managed VPNs it remains the resolver behind the private +DNS portal, reached through the proxy. Restart the proxy connection after +changing it. IPv4-only TUN sessions remove AAAA records from UDP and TCP replies. +Large upstream answers set DNS truncation when they exceed the client's UDP +limit, allowing that client to retry over TCP. Native embedders can call the additive `proxy_set_tun_dns_server` before startup; it accepts a literal IPv4/IPv6 address (null resets the default), rejects invalid @@ -177,6 +184,18 @@ bypasses its live child-process tree. The matcher uses the ToolHelp process snapshot, which remains available when a protected game process rejects direct process-handle access. If the application already cached a virtual-DNS address, the direct route restores the real destination through the physical interface. +Explicit child executable selections are retained when a parent is selected +or disappears. The mixed checkbox indicates inherited bypass; checking that +child explicitly keeps it excluded after the launcher exits. Rules lost by an +older version cannot be inferred: add the desired executable again once. + +The same explicit rules apply to local HTTP, CONNECT and SOCKS clients, including +applications using the Windows system proxy. Their TCP and UDP sockets and +hostname lookups bind to the physical interface; loopback destinations stay on +loopback. This does not edit the Windows system-proxy bypass list. Remote LAN +clients are not attributed to local processes, and this proxy's own PID is +excluded before ancestor matching so TUN's internal SOCKS connections retain +their original routing decision. Excluded UDP works even when proxy UDP is off. User-selected bypasses apply to new TCP or UDP sessions. On Windows and macOS, Apply evaluates existing sessions against the executable and ancestor identities recorded when each session began. Adding an unrelated application leaves existing @@ -300,6 +319,13 @@ Other bypassed processes connect directly to their original destinations and therefore do not use the proxy. Do not add an application to the bypass list if its traffic should remain proxied. +For excluded local HTTP/SOCKS clients, loopback destinations do not require a +physical adapter. `localhost` supports both loopback families, including an +IPv6-only service. Physical DNS resolution and socket binding are deferred +until a non-local destination is used. If an IPv4 domain connection fails, the +client resolves and tries IPv6; UDP retries the other family only when sending +fails, since a successful UDP send cannot confirm that a service is reachable. + The Windows picker combines live processes with installed Win32 applications registered through App Paths, Uninstall metadata, and Start Menu Shell links. For registrations that only expose an application directory, scanning is @@ -317,8 +343,8 @@ does not control multicast egress. Without the multicast-specific binding, an SSDP packet such as `239.255.255.250:1900` can return through Wintun, create a new proxy-ui UDP socket, and amplify into a self-sustaining relay loop. -TUN TCP continues to work with `--udp false`. DNS still uses the proxy’s TCP -path (or the virtual resolver when Fake-IP is enabled), while other captured UDP +TUN TCP continues to work with `--udp false`. DNS still uses the platform path +described above (or the virtual resolver when Fake-IP is enabled), while other captured UDP is relayed directly instead of being sent to the local SOCKS5 listener. This matches the practical fallback used when a selected outbound cannot carry UDP, but it means non-DNS UDP @@ -351,8 +377,9 @@ explicitly and select either the default direct fallback or strict blocking. ### Desktop log safeguards -The GUI requests native logs at `INFO` by default. Selecting a more verbose -level changes the native callback threshold, but burst delivery remains capped +The GUI requests native logs at `INFO` by default. Selecting WARN or ERROR only +filters the view; disk diagnostics retain INFO. Selecting a more verbose +level lowers the native callback threshold, but burst delivery remains capped and reports how many low-priority records were omitted. The in-memory viewer is an O(1) 1,000-entry queue and refreshes at most ten times per second. Disk logging batches writes, bounds its pending queue, caps each hourly file at 64 @@ -360,6 +387,19 @@ MiB, and retains at most 512 MiB across hourly files. These limits keep packet or connection storms from turning diagnostic work into forwarding latency or unbounded memory growth. +Native FFI logs also go to `logs/proxy-native-*.log` in the existing application +support directory, before UI filtering or callback throttling. This independent +writer preserves complete request destinations and error chains, uses an 8 MiB +bounded queue, and rotates at 64 MiB, retaining eight native log files where +possible. Files still owned by a running instance are exempt from cleanup and +remain readable; small `.log.lock` sidecars coordinate ownership. Shutdown waits +for queued records and the final flush, even when the queue is full. Queue +overflow and disk failures are reported rather than silently truncating records. +UDP relay lifecycle logs include the selected interface, socket endpoints, +datagram counts and close reason. Receiving a large UDP reply now produces IP +fragments of one datagram, rather than splitting it into unrelated UDP messages; +empty UDP payloads are data, not stream EOF. + --- ## Windows diff --git a/ui/flutter b/ui/flutter index 1853af1..e8c381d 160000 --- a/ui/flutter +++ b/ui/flutter @@ -1 +1 @@ -Subproject commit 1853af1d49608543dbd673a114250508a35c25c0 +Subproject commit e8c381dd5c3227df4c601f54c129e263cd3600ef