diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1e51bf3834..439dea79c6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4126,6 +4126,7 @@ dependencies = [ "opentelemetry", "pretty_assertions", "rama-core", + "rama-dns", "rama-http", "rama-http-backend", "rama-net", diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml index 9ab9d6af84..d5280e4eef 100644 --- a/codex-rs/network-proxy/Cargo.toml +++ b/codex-rs/network-proxy/Cargo.toml @@ -53,6 +53,7 @@ tempfile = { workspace = true } rama-unix = { version = "=0.3.0-alpha.4" } [target.'cfg(target_os = "macos")'.dependencies] +rama-dns = { version = "=0.3.0-alpha.4" } security-framework = "3" [target.'cfg(windows)'.dependencies] diff --git a/codex-rs/network-proxy/src/connect_policy.rs b/codex-rs/network-proxy/src/connect_policy.rs index 53b267595e..47430a1929 100644 --- a/codex-rs/network-proxy/src/connect_policy.rs +++ b/codex-rs/network-proxy/src/connect_policy.rs @@ -1,6 +1,8 @@ use crate::policy::is_non_public_ip; use crate::runtime::HostBlockDecision; use crate::state::NetworkProxyState; +#[cfg(target_os = "macos")] +use crate::system_dns::SystemDnsResolver; use rama_core::Service; use rama_core::error::BoxError; use rama_core::error::ErrorExt as _; @@ -38,8 +40,12 @@ where type Error = BoxError; async fn serve(&self, input: Input) -> Result { + let connector = TcpConnector::new(); + #[cfg(target_os = "macos")] + let connector = connector.with_dns(SystemDnsResolver); + if input.extensions().get::().is_some() { - return TcpConnector::new().serve(input).await; + return connector.serve(input).await; } let target = input @@ -48,7 +54,7 @@ where .host_with_port() .ok_or_else(|| OpaqueError::from_display("network target is missing a port"))?; - TcpConnector::new() + connector .with_connector(TargetCheckedStreamConnector { state: self.state.clone(), target, diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index dd8df15aca..ae33185fd7 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -25,6 +25,8 @@ mod responses; mod runtime; mod socks5; mod state; +#[cfg(target_os = "macos")] +mod system_dns; mod upstream; #[cfg(target_os = "windows")] mod windows_proxy_ingress; diff --git a/codex-rs/network-proxy/src/system_dns.rs b/codex-rs/network-proxy/src/system_dns.rs new file mode 100644 index 0000000000..5149bccb41 --- /dev/null +++ b/codex-rs/network-proxy/src/system_dns.rs @@ -0,0 +1,56 @@ +//! This macOS-only module implements DNS resolution +//! using macOS's native system resolver. We have to +//! do this rather than relying on the default behavior +//! (which reads /etc/resolv.conf) since default behavior +//! breaks Codex's ability to interact with network paths +//! that are provided by applications that modify / hook +//! into macOS DNS behavior (i.e. proxies, VPNs, etc) +use rama_dns::DnsResolver; +use rama_net::address::Domain; +use std::io; +use std::net::IpAddr; +use std::net::Ipv4Addr; +use std::net::Ipv6Addr; +use tokio::net::lookup_host; + +/// Resolve TCP destinations through macOS's native resolver, including supplemental +/// resolvers and VPN split DNS that are not represented in `/etc/resolv.conf`. +#[derive(Clone)] +pub(crate) struct SystemDnsResolver; + +impl DnsResolver for SystemDnsResolver { + type Error = io::Error; + + async fn ipv4_lookup(&self, domain: Domain) -> io::Result> { + Ok(lookup_host((domain.as_str(), 0)) + .await? + .filter_map(|addr| match addr.ip() { + IpAddr::V4(ip) => Some(ip), + IpAddr::V6(_) => None, + }) + .collect()) + } + + async fn ipv6_lookup(&self, domain: Domain) -> io::Result> { + Ok(lookup_host((domain.as_str(), 0)) + .await? + .filter_map(|addr| match addr.ip() { + IpAddr::V4(_) => None, + IpAddr::V6(ip) => Some(ip), + }) + .collect()) + } + + async fn txt_lookup(&self, _domain: Domain) -> io::Result>> { + // TCP connectors only need address lookups. Do not silently fall back to + // a resolver with different DNS routing for unsupported record types. + Err(io::Error::new( + io::ErrorKind::Unsupported, + "the system address resolver does not support TXT lookups", + )) + } +} + +#[cfg(test)] +#[path = "system_dns_tests.rs"] +mod tests; diff --git a/codex-rs/network-proxy/src/system_dns_tests.rs b/codex-rs/network-proxy/src/system_dns_tests.rs new file mode 100644 index 0000000000..4ad4990a74 --- /dev/null +++ b/codex-rs/network-proxy/src/system_dns_tests.rs @@ -0,0 +1,95 @@ +use super::*; +use crate::config::NetworkProxyConfig; +use crate::connect_policy::TargetCheckedTcpConnector; +use crate::state::network_proxy_state_for_policy; +use pretty_assertions::assert_eq; +use rama_core::Service; +use rama_core::extensions::ExtensionsMut; +use rama_net::address::Host; +use rama_net::address::HostWithPort; +use rama_net::address::ProxyAddress; +use rama_net::stream::Socket; +use rama_tcp::client::Request; +use std::sync::Arc; +use tokio::net::TcpListener; + +#[tokio::test] +async fn native_resolution_supports_both_address_families() { + let domain: Domain = "localhost".parse().expect("valid domain"); + let (ipv4, ipv6) = tokio::join!( + SystemDnsResolver.ipv4_lookup(domain.clone()), + SystemDnsResolver.ipv6_lookup(domain), + ); + + assert_eq!( + ipv4.expect("resolve IPv4 localhost"), + vec![Ipv4Addr::LOCALHOST] + ); + assert_eq!( + ipv6.expect("resolve IPv6 localhost"), + vec![Ipv6Addr::LOCALHOST] + ); +} + +#[tokio::test] +async fn connector_reaches_native_hostname_over_ipv4_and_ipv6() { + for ip in [ + IpAddr::V4(Ipv4Addr::LOCALHOST), + IpAddr::V6(Ipv6Addr::LOCALHOST), + ] { + let listener = TcpListener::bind((ip, 0)).await.expect("bind listener"); + let target = listener.local_addr().expect("local addr"); + let connector = TargetCheckedTcpConnector::new(Arc::new(network_proxy_state_for_policy( + NetworkProxyConfig { + allow_local_binding: true, + ..NetworkProxyConfig::default() + }, + ))); + let request = Request::new(HostWithPort::new(Host::LOCALHOST_NAME, target.port())); + + let connection = connector + .serve(request) + .await + .expect("connect to localhost"); + + assert_eq!(connection.conn.peer_addr().expect("peer addr"), target); + } +} + +#[tokio::test] +async fn native_resolution_preserves_local_network_rejection() { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind listener"); + let target = listener.local_addr().expect("local addr"); + let connector = TargetCheckedTcpConnector::new(Arc::new(network_proxy_state_for_policy( + NetworkProxyConfig::default(), + ))); + let request = Request::new(HostWithPort::new(Host::LOCALHOST_NAME, target.port())); + + connector + .serve(request) + .await + .expect_err("resolved loopback addresses must still be checked against policy"); +} + +#[tokio::test] +async fn connector_resolves_upstream_proxy_instead_of_destination() { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind listener"); + let target = listener.local_addr().expect("local addr"); + let connector = TargetCheckedTcpConnector::new(Arc::new(network_proxy_state_for_policy( + NetworkProxyConfig::default(), + ))); + let mut request = + Request::new(HostWithPort::try_from("destination.invalid:80").expect("valid destination")); + request.extensions_mut().insert( + ProxyAddress::try_from(format!("http://localhost:{}", target.port())) + .expect("valid upstream proxy"), + ); + + let connection = connector.serve(request).await.expect("connect to proxy"); + + assert_eq!(connection.conn.peer_addr().expect("peer addr"), target); +}