From 37fdf78cd84aacf9cdfe2df286d866f24574a60e Mon Sep 17 00:00:00 2001 From: cfal Date: Mon, 26 Oct 2026 02:45:46 +0000 Subject: [PATCH] fix(dns): preserve bootstrap policy across proxy hops Resolve intermediate DNS proxy endpoints through the guarded bootstrap resolver while retaining directly dialed address fallback and TLS names. Classify native DNS dependence using the explicit bootstrap route rather than hostname presence alone. --- src/client_proxy_chain.rs | 16 +++++++++--- src/dns/builder.rs | 19 +++++++------- src/dns/builder/tests.rs | 24 +++++++++++++++++ src/dns/proxy_runtime.rs | 4 +-- src/dns/proxy_runtime/tests.rs | 47 ++++++++++++++++++++++++++++++++++ src/rule_updater/http.rs | 2 +- 6 files changed, 96 insertions(+), 16 deletions(-) diff --git a/src/client_proxy_chain.rs b/src/client_proxy_chain.rs index b87abb7d..a88a16ed 100644 --- a/src/client_proxy_chain.rs +++ b/src/client_proxy_chain.rs @@ -708,8 +708,7 @@ impl ClientProxyChain { .await } - #[cfg(unix)] - pub(crate) async fn connect_rule_download( + pub(crate) async fn connect_tcp_pinned( &self, target: ResolvedLocation, resolver: &Arc, @@ -739,7 +738,7 @@ impl ClientProxyChain { .ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::NotFound, - "rule download proxy resolved to no addresses", + "proxy resolved to no addresses", ) })?; pinned.insert( @@ -1231,6 +1230,17 @@ impl ClientChainGroup { .await } + pub(crate) async fn connect_tcp_pinned( + &self, + remote_location: ResolvedLocation, + resolver: &Arc, + ) -> std::io::Result { + let index = self.next_tcp_index.fetch_add(1, Ordering::Relaxed) as usize; + self.chains[index % self.chains.len()] + .connect_tcp_pinned(remote_location, resolver) + .await + } + pub(crate) async fn connect_tcp_with_info( &self, remote_location: ResolvedLocation, diff --git a/src/dns/builder.rs b/src/dns/builder.rs index fb5c1055..57aa396f 100644 --- a/src/dns/builder.rs +++ b/src/dns/builder.rs @@ -375,23 +375,22 @@ fn group_uses_system( if spec.url == "system" { return Ok(true); } - let mut native_proxy = false; + let mut proxy_hostname = false; for chain in &mut spec.client_chains { for hop in chain.hops.iter_mut() { crate::config::client_visit::visit_hop(hop, &mut |client| { - native_proxy |= client.address.address().hostname().is_some(); + proxy_hostname |= client.address.address().hostname().is_some(); Ok(()) })?; } } - if native_proxy - || (ParsedDnsUrl::parse(&spec.url).is_ok_and(|url| url.has_hostname()) - && spec.bootstrap_url.as_ref().is_none_or(|bootstrap| { - system_groups - .get(bootstrap) - .copied() - .unwrap_or(bootstrap == "system") - })) + if (proxy_hostname || ParsedDnsUrl::parse(&spec.url).is_ok_and(|url| url.has_hostname())) + && spec.bootstrap_url.as_ref().is_none_or(|bootstrap| { + system_groups + .get(bootstrap) + .copied() + .unwrap_or(bootstrap == "system") + }) { return Ok(true); } diff --git a/src/dns/builder/tests.rs b/src/dns/builder/tests.rs index b6a8d198..913f7489 100644 --- a/src/dns/builder/tests.rs +++ b/src/dns/builder/tests.rs @@ -217,6 +217,30 @@ fn system_bootstrap_dependencies_preserve_named_group_precedence() { .unwrap(), ]; assert!(group_uses_system(group, &known).unwrap()); + group.specs[0].bootstrap_url = Some("udp://127.0.0.1:53".into()); + assert!(!group_uses_system(group, &known).unwrap()); + group.specs[0].bootstrap_url = Some("system".into()); + assert!(group_uses_system(group, &known).unwrap()); + known.insert("system".into(), false); + assert!(!group_uses_system(group, &known).unwrap()); +} + +#[tokio::test] +async fn explicit_proxy_bootstrap_does_not_require_native_dns_preflight() { + let mut groups = fake_groups(false); + let spec = &mut groups[0].specs[0]; + spec.url = "tcp://127.0.0.1:53".into(); + spec.bootstrap_url = Some("udp://127.0.0.1:54".into()); + spec.client_chains = vec![ + serde_json::from_value(serde_json::json!({ + "address": "proxy.invalid:1080", "protocol": {"type": "socks"} + })) + .unwrap(), + ]; + let mut registry = build_dns_registry(groups).await.unwrap(); + registry.forbidden = Arc::from(["0.0.0.0/0".parse().unwrap(), "::/0".parse().unwrap()]); + assert!(registry.rule_bootstrap("fake").await.is_ok()); + assert!(registry.rule_bootstrap("system").await.is_err()); } #[tokio::test] diff --git a/src/dns/proxy_runtime.rs b/src/dns/proxy_runtime.rs index 3d2a3d15..3d28ecd4 100644 --- a/src/dns/proxy_runtime.rs +++ b/src/dns/proxy_runtime.rs @@ -24,7 +24,7 @@ const TEST_CONNECT_TIMEOUT: Duration = Duration::from_millis(100); pub struct ProxyRuntimeProvider { chain_group: Arc, /// Resolver for proxy server hostnames (not the DNS queries themselves). - /// Uses NativeResolver since we can't use the DNS server we're trying to reach. + /// Uses the explicit bootstrap route, or native DNS when none was configured. bootstrap_resolver: Arc, /// QUIC socket binder that selects from the same direct-only chain. quic_binder: ProxyQuicBinder, @@ -106,7 +106,7 @@ impl RuntimeProvider for ProxyRuntimeProvider { let target = NetLocation::new(address, server_addr.port()); let started = std::time::Instant::now(); - let connect_future = chain_group.connect_tcp(target.into(), &resolver); + let connect_future = chain_group.connect_tcp_pinned(target.into(), &resolver); match tokio::time::timeout(timeout, connect_future).await { Ok(Ok(result)) => { log::debug!( diff --git a/src/dns/proxy_runtime/tests.rs b/src/dns/proxy_runtime/tests.rs index 4b90ba4b..50949e56 100644 --- a/src/dns/proxy_runtime/tests.rs +++ b/src/dns/proxy_runtime/tests.rs @@ -6,6 +6,53 @@ use crate::tcp::chain_builder::build_direct_chain_group; use crate::tcp::socket_connector::SocketConnector; use async_trait::async_trait; +#[tokio::test] +async fn synthetic_intermediate_proxy_is_rejected_before_socks_connect() { + #[derive(Debug)] + struct Bootstrap; + impl Resolver for Bootstrap { + fn resolve_location(&self, location: &NetLocation) -> crate::resolver::ResolveFuture { + assert_eq!(location.address().to_string(), "second.invalid"); + let address = SocketAddr::new("198.18.0.53".parse().unwrap(), location.port()); + Box::pin(async move { Ok(vec![address]) }) + } + } + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let resolver = crate::dns::real_only( + Arc::new(Bootstrap), + Arc::from(["198.18.0.0/15".parse().unwrap()]), + ); + let chain: crate::config::ClientChain = serde_json::from_value(serde_json::json!([ + {"address": listener.local_addr().unwrap().to_string(), "protocol": {"type": "socks"}}, + {"address": "second.invalid:1080", "protocol": {"type": "socks"}} + ])) + .unwrap(); + let chains = crate::tcp::chain_builder::build_client_chain_group_with_limits( + crate::option_util::NoneOrSome::One(chain), + resolver.clone(), + Default::default(), + ) + .unwrap(); + let provider = ProxyRuntimeProvider::with_bootstrap( + Arc::new(chains), + resolver, + Duration::from_secs(1), + ); + for _ in 0..2 { + let error = provider + .connect_tcp("127.0.0.1:53".parse().unwrap(), None, None) + .await + .err() + .unwrap(); + assert!(error.to_string().contains("synthetic"), "{error}"); + assert!( + tokio::time::timeout(Duration::from_millis(50), listener.accept()) + .await + .is_err() + ); + } +} + #[derive(Debug)] struct PendingSocketConnector; diff --git a/src/rule_updater/http.rs b/src/rule_updater/http.rs index a780758f..8bcdb60e 100644 --- a/src/rule_updater/http.rs +++ b/src/rule_updater/http.rs @@ -110,7 +110,7 @@ async fn connect( let chain = &attempt.chain; let connected = crate::tcp::socket_connector_impl::connect_tcp_candidates(targets, |target| { let target = crate::address::NetLocation::from_ip_addr(target.ip(), target.port()); - Ok(chain.connect_rule_download(target.into(), &resolver)) + Ok(chain.connect_tcp_pinned(target.into(), &resolver)) }) .await .map_err(|_| invalid("configured rule download chain failed"))?;