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.
This commit is contained in:
cfal
2026-10-31 11:00:55 +00:00
parent f8a163414a
commit 37fdf78cd8
6 changed files with 96 additions and 16 deletions
+13 -3
View File
@@ -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<dyn Resolver>,
@@ -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<dyn Resolver>,
) -> std::io::Result<TcpClientSetupResult> {
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,
+9 -10
View File
@@ -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);
}
+24
View File
@@ -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]
+2 -2
View File
@@ -24,7 +24,7 @@ const TEST_CONNECT_TIMEOUT: Duration = Duration::from_millis(100);
pub struct ProxyRuntimeProvider {
chain_group: Arc<ClientChainGroup>,
/// 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<dyn Resolver>,
/// 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!(
+47
View File
@@ -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;
+1 -1
View File
@@ -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"))?;