diff --git a/grpc/Cargo.toml b/grpc/Cargo.toml index 01f1eb75c..3195a448e 100644 --- a/grpc/Cargo.toml +++ b/grpc/Cargo.toml @@ -57,6 +57,7 @@ hickory-resolver = { version = "0.26.1", optional = true } http = "1.1.0" http-body = "1.0.1" hyper = { version = "1.6.0", features = ["client", "http2"] } +hyper-util = { version = "0.1", features = ["client-proxy"] } itoa = "1.0" parking_lot = "0.12.4" percent-encoding = "2.3" diff --git a/grpc/src/client/channel.rs b/grpc/src/client/channel.rs index b44baeff1..45dccf03a 100644 --- a/grpc/src/client/channel.rs +++ b/grpc/src/client/channel.rs @@ -254,7 +254,7 @@ impl PersistentChannel { .unwrap_or_else(|| resolver_builder.default_authority(&target).to_owned()); let security_opts = SecurityOpts { credentials, - authority: parse_authority(&authority), + authority: Authority::from_host_port_str(&authority), handshake_info: ClientHandshakeInfo::default(), }; @@ -616,163 +616,3 @@ fn parse_authority(host_and_port: &str) -> Authority { } Authority::new(host_and_port.to_string(), None) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_authority() { - struct TestCase { - input: &'static str, - expected: Authority, - } - - let cases = [ - TestCase { - input: "localhost:http", - expected: Authority::new("localhost:http", None), - }, - TestCase { - input: "localhost:80", - expected: Authority::new("localhost", Some(80)), - }, - // host name with zone identifier. - TestCase { - input: "localhost%lo0:80", - expected: Authority::new("localhost%lo0", Some(80)), - }, - TestCase { - input: "localhost%lo0:http", - expected: Authority::new("localhost%lo0:http", None), - }, - TestCase { - input: "[localhost%lo0]:http", - expected: Authority::new("[localhost%lo0]:http", None), - }, - TestCase { - input: "[localhost%lo0]:80", - expected: Authority::new("localhost%lo0", Some(80)), - }, - // IP literal - TestCase { - input: "127.0.0.1:http", - expected: Authority::new("127.0.0.1:http", None), - }, - TestCase { - input: "127.0.0.1:80", - expected: Authority::new("127.0.0.1", Some(80)), - }, - TestCase { - input: "[::1]:http", - expected: Authority::new("[::1]:http", None), - }, - TestCase { - input: "[::1]:80", - expected: Authority::new("::1", Some(80)), - }, - // IP literal with zone identifier. - TestCase { - input: "[::1%lo0]:http", - expected: Authority::new("[::1%lo0]:http", None), - }, - TestCase { - input: "[::1%lo0]:80", - expected: Authority::new("::1%lo0", Some(80)), - }, - TestCase { - input: ":http", - expected: Authority::new(":http", None), - }, - TestCase { - input: ":80", - expected: Authority::new("", Some(80)), - }, - TestCase { - input: "grpc.io:", - expected: Authority::new("grpc.io:", None), - }, - TestCase { - input: "127.0.0.1:", - expected: Authority::new("127.0.0.1:", None), - }, - TestCase { - input: "[::1]:", - expected: Authority::new("[::1]:", None), - }, - TestCase { - input: "grpc.io:https%foo", - expected: Authority::new("grpc.io:https%foo", None), - }, - TestCase { - input: "grpc.io", - expected: Authority::new("grpc.io", None), - }, - TestCase { - input: "127.0.0.1", - expected: Authority::new("127.0.0.1", None), - }, - TestCase { - input: "[::1]", - expected: Authority::new("[::1]", None), - }, - TestCase { - input: "[fe80::1%lo0]", - expected: Authority::new("[fe80::1%lo0]", None), - }, - TestCase { - input: "[localhost%lo0]", - expected: Authority::new("[localhost%lo0]", None), - }, - TestCase { - input: "localhost%lo0", - expected: Authority::new("localhost%lo0", None), - }, - TestCase { - input: "::1", - expected: Authority::new("::1", None), - }, - TestCase { - input: "fe80::1%lo0", - expected: Authority::new("fe80::1%lo0", None), - }, - TestCase { - input: "fe80::1%lo0:80", - expected: Authority::new("fe80::1%lo0:80", None), - }, - TestCase { - input: "[foo:bar]", - expected: Authority::new("[foo:bar]", None), - }, - TestCase { - input: "[foo:bar]baz", - expected: Authority::new("[foo:bar]baz", None), - }, - TestCase { - input: "[foo]bar:baz", - expected: Authority::new("[foo]bar:baz", None), - }, - TestCase { - input: "[foo]:[bar]:baz", - expected: Authority::new("[foo]:[bar]:baz", None), - }, - TestCase { - input: "[foo]:[bar]baz", - expected: Authority::new("[foo]:[bar]baz", None), - }, - TestCase { - input: "foo[bar]:baz", - expected: Authority::new("foo[bar]:baz", None), - }, - TestCase { - input: "foo]bar:baz", - expected: Authority::new("foo]bar:baz", None), - }, - ]; - - for TestCase { input, expected } in cases { - let auth = parse_authority(input); - assert_eq!(auth, expected, "authority mismatch for {}", input); - } - } -} diff --git a/grpc/src/client/name_resolution/dns/mod.rs b/grpc/src/client/name_resolution/dns/mod.rs index ce47dfc42..cd00a2b09 100644 --- a/grpc/src/client/name_resolution/dns/mod.rs +++ b/grpc/src/client/name_resolution/dns/mod.rs @@ -110,7 +110,7 @@ pub(crate) fn reg() { global_registry().add_builder(Box::new(Builder {})); } -struct Builder {} +pub(crate) struct Builder {} struct DnsOptions { min_resolution_interval: Duration, diff --git a/grpc/src/client/name_resolution/mod.rs b/grpc/src/client/name_resolution/mod.rs index 4c6a45d19..f8e1266a6 100644 --- a/grpc/src/client/name_resolution/mod.rs +++ b/grpc/src/client/name_resolution/mod.rs @@ -57,6 +57,7 @@ pub(crate) mod unix; #[cfg(target_os = "linux")] pub(crate) mod unix_abstract; pub(crate) use registry::global_registry; +pub(crate) mod proxy_resolver; /// Target represents a target for gRPC, as specified in: /// https://github.com/grpc/grpc/blob/master/doc/naming.md. diff --git a/grpc/src/client/name_resolution/proxy_resolver.rs b/grpc/src/client/name_resolution/proxy_resolver.rs new file mode 100644 index 000000000..6e6488794 --- /dev/null +++ b/grpc/src/client/name_resolution/proxy_resolver.rs @@ -0,0 +1,705 @@ +/* + * + * Copyright 2026 gRPC authors. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + * + */ + +use std::sync::Arc; +use std::sync::LazyLock; + +use hyper_util::client::proxy::matcher::Matcher; +use url::Url; + +use crate::client::name_resolution::ChannelController; +use crate::client::name_resolution::NopResolver; +use crate::client::name_resolution::Resolver; +use crate::client::name_resolution::ResolverBuilder; +use crate::client::name_resolution::ResolverOptions; +use crate::client::name_resolution::ResolverUpdate; +use crate::client::name_resolution::Target; +use crate::client::name_resolution::dns; +use crate::client::service_config::ServiceConfig; +use crate::client::transport::ProxyOptions; +use crate::credentials::common::Authority; + +static MATCHER: LazyLock> = LazyLock::new(build_matcher); + +fn build_matcher() -> Option { + // Avoid using a proxy in a Common Gateway Interface (CGI) environment. + if std::env::var_os("REQUEST_METHOD").is_some() { + return None; + } + + let https_proxy = get_first_env(&["HTTPS_PROXY", "https_proxy"]); + if https_proxy.is_empty() { + return None; + } + + let builder = Matcher::builder(); + // Only read NO_PROXY and HTTPS_PROXY. This avoids reading ALL_PROXY, + // which is not read by gRPC Go and C++. + Some( + builder + .no(get_first_env(&["NO_PROXY", "no_proxy"])) + .https(https_proxy) + .build(), + ) +} + +/// A resolver builder that wraps another `ResolverBuilder` and applies proxy +/// configuration. +/// +/// This builder checks if the target URI should be proxied based on environment +/// variables (like `HTTPS_PROXY`, `NO_PROXY`). If a proxy is needed, it creates +/// a resolver that resolves the proxy address and injects proxy options into +/// the resolved addresses. +pub(crate) struct Builder { + child_builder: Arc, +} + +impl ResolverBuilder for Builder { + fn build(&self, target: &Target, options: ResolverOptions) -> Box { + self.new_resolver(target, options, MATCHER.as_ref()) + } + + fn scheme(&self) -> &str { + self.child_builder.scheme() + } + + fn is_valid_uri(&self, uri: &Target) -> bool { + self.child_builder.is_valid_uri(uri) + } + + fn default_authority(&self, target: &Target) -> String { + self.child_builder.default_authority(target) + } +} + +impl Builder { + /// Creates a new `Builder` that wraps the given `child_builder`. + pub(crate) fn new(child_builder: Arc) -> Self { + Self { child_builder } + } + + fn new_resolver( + &self, + target: &Target, + options: ResolverOptions, + matcher: Option<&Matcher>, + ) -> Box { + // Skip proxy lookup for non-DNS targets. + if target.scheme() != "dns" { + return self.child_builder.build(target, options); + } + // If HTTPS_PROXY is unset, avoid parsing the target as a DNS hostname. + let Some(matcher) = matcher else { + return self.child_builder.build(target, options); + }; + + let target_authority = self.child_builder.default_authority(target); + // Use the URL crate to validate the authority and punycode encode it. + let target_authority = authority_with_default_port(&target_authority, 443); + let url_obj = match Url::parse(&format!("https://{target_authority}")) { + Ok(url) => url, + Err(err) => { + return NopResolver::new_with_err( + format!("invalid target host in URL: {err}"), + options, + ); + } + }; + + // The URL omits the default port for the scheme (443 for HTTPS), so we + // must explicitly add it. + let host = url_obj.host_str().unwrap_or(""); + let port = url_obj.port().unwrap_or(443); + let explicit_authority = format!("{host}:{port}"); + + let uri = match http::Uri::builder() + .scheme("https") + .authority(explicit_authority.as_str()) + .path_and_query("/") + .build() + { + Ok(uri) => uri, + Err(err) => { + // This should not error since the url crate parsed the host. + return NopResolver::new_with_err( + format!("failed to parse target authority: {}", err), + options, + ); + } + }; + + let Some(intercept) = matcher.intercept(&uri) else { + return self.child_builder.build(target, options); + }; + + let mut proxy_authorization_header = intercept.basic_auth().cloned(); + if let Some(ref mut header) = proxy_authorization_header { + header.set_sensitive(true); + } + + let proxy_options = ProxyOptions::new(explicit_authority, proxy_authorization_header); + + let Some(proxy_host) = intercept.uri().authority() else { + return NopResolver::new_with_err( + format!("proxy URI missing authority: {}", intercept.uri()), + options, + ); + }; + + // `proxy_host` is be a valid URL authority. Because the `url` crate + // parses the target using the WHATWG standard, it allows unescaped `[]` + // characters in the path. Therefore, we don't need to explicitly + // percent-encode the host string when adding it to the target path. + let target_str = format!("dns:///{}", proxy_host); + let proxy_target: Target = match target_str.parse() { + Ok(t) => t, + Err(e) => { + return NopResolver::new_with_err( + format!("failed to parse proxy target {target_str}: {e}"), + options, + ); + } + }; + + let child = dns::Builder {}.build(&proxy_target, options); + + Box::new(HttpsProxyResolver { + child, + proxy_options: Arc::new(proxy_options), + }) + } +} + +struct HttpsProxyResolver { + child: Box, + proxy_options: Arc, +} + +impl Resolver for HttpsProxyResolver { + fn resolve_now(&mut self) { + self.child.resolve_now(); + } + + fn work(&mut self, channel_controller: &mut dyn ChannelController) { + let mut interceptor = InterceptingController { + inner: channel_controller, + proxy_options: &self.proxy_options, + }; + self.child.work(&mut interceptor); + } +} + +struct InterceptingController<'a> { + inner: &'a mut dyn ChannelController, + proxy_options: &'a Arc, +} + +impl<'a> ChannelController for InterceptingController<'a> { + fn update(&mut self, mut update: ResolverUpdate) -> Result<(), String> { + if let Ok(endpoints) = &mut update.endpoints { + for endpoint in endpoints { + for address in &mut endpoint.addresses { + ProxyOptions::add_to_addr(address, self.proxy_options.clone()); + } + } + } + self.inner.update(update) + } + + fn parse_service_config(&self, config: &str) -> Result { + self.inner.parse_service_config(config) + } +} + +fn get_first_env(names: &[&str]) -> String { + for name in names { + if let Ok(val) = std::env::var(name) { + return val; + } + } + + String::new() +} + +fn authority_with_default_port(host_port: &str, default_port: u16) -> String { + let mut authority = Authority::from_host_port_str(host_port); + if authority.port().is_none() { + authority.set_port(Some(default_port)); + } + authority.host_port_string() +} + +#[cfg(test)] +mod tests { + use std::future::Future; + use std::net::IpAddr; + use std::pin::Pin; + use std::sync::Arc; + + use http::HeaderValue; + + use super::*; + use crate::attributes::Attributes; + use crate::byte_str::ByteStr; + use crate::client::name_resolution::Address; + use crate::client::name_resolution::test_utils::TestChannelController; + use crate::client::name_resolution::test_utils::TestWorkScheduler; + use crate::rt; + use crate::rt::GrpcEndpoint; + use crate::rt::GrpcRuntime; + use crate::rt::Runtime; + use crate::rt::Sleep; + use crate::rt::TaskHandle; + use crate::rt::TcpOptions; + use crate::rt::tokio::TokioRuntime; + + const DIRECT_ADDRESS: &str = "1.2.3.4:5678"; + + #[derive(Clone, Debug)] + struct FakeDns { + lookup_result: Result, String>, + } + + #[tonic::async_trait] + impl rt::DnsResolver for FakeDns { + async fn lookup_host_name(&self, _: &str) -> Result, String> { + self.lookup_result.clone() + } + + async fn lookup_txt(&self, _: &str) -> Result, String> { + Err("unimplemented".to_string()) + } + } + + #[derive(Debug)] + struct FakeRuntime { + inner: TokioRuntime, + dns: FakeDns, + } + + impl Runtime for FakeRuntime { + fn spawn( + &self, + task: Pin + Send + 'static>>, + ) -> Box { + self.inner.spawn(task) + } + + fn get_dns_resolver( + &self, + _: rt::ResolverOptions, + ) -> Result, String> { + Ok(Box::new(self.dns.clone())) + } + + fn sleep(&self, duration: std::time::Duration) -> Pin> { + self.inner.sleep(duration) + } + + fn tcp_stream( + &self, + target: std::net::SocketAddr, + opts: TcpOptions, + ) -> Pin, String>> + Send>> { + self.inner.tcp_stream(target, opts) + } + } + + struct MockResolverBuilder {} + + impl ResolverBuilder for MockResolverBuilder { + fn build(&self, _target: &Target, options: ResolverOptions) -> Box { + let addr = Address { + network_type: "tcp", + address: ByteStr::from(DIRECT_ADDRESS.to_string()), + attributes: Attributes::new(), + }; + NopResolver::new_with_addr(addr, options) + } + + fn scheme(&self) -> &str { + "dns" + } + + fn is_valid_uri(&self, _uri: &Target) -> bool { + true + } + } + + async fn run_resolver_and_get_addresses_with_builder( + target_uri: &str, + dns_ips: Vec, + matcher: Option<&Matcher>, + child_builder: Arc, + ) -> Vec
{ + let builder = Builder::new(child_builder); + + let target: Target = target_uri.parse().unwrap(); + let (work_scheduler, mut work_rx) = TestWorkScheduler::new_pair(); + let runtime = FakeRuntime { + inner: TokioRuntime::default(), + dns: FakeDns { + lookup_result: Ok(dns_ips), + }, + }; + let options = ResolverOptions { + authority: target.authority_host_port(), + runtime: GrpcRuntime::new(runtime), + work_scheduler, + }; + + let mut resolver = builder.new_resolver(&target, options, matcher); + + work_rx.recv().await.unwrap(); + + let (mut channel_controller, mut update_rx) = TestChannelController::new_pair(); + resolver.work(&mut channel_controller); + + let update = update_rx.recv().await.unwrap(); + let endpoints = update.endpoints.unwrap(); + + let mut addresses = Vec::new(); + for endpoint in endpoints { + for address in endpoint.addresses { + addresses.push(address); + } + } + addresses + } + + async fn run_resolver_and_get_addresses( + target_uri: &str, + dns_ips: Vec, + matcher: Option<&Matcher>, + ) -> Vec
{ + let child_builder = Arc::new(MockResolverBuilder {}); + run_resolver_and_get_addresses_with_builder(target_uri, dns_ips, matcher, child_builder) + .await + } + + #[tokio::test] + async fn proxy_matched() { + let matcher = Matcher::builder() + .https("http://user:password@proxy.example.com:8080") + .build(); + + let dns_ips = vec!["127.0.0.1".parse().unwrap(), "::1".parse().unwrap()]; + let addresses = + run_resolver_and_get_addresses("dns:///target.example.com", dns_ips, Some(&matcher)) + .await; + + assert_eq!(addresses.len(), 2); + assert_eq!(&*addresses[0].address, "127.0.0.1:8080"); + assert_eq!(&*addresses[1].address, "[::1]:8080"); + + let mut expected_header = HeaderValue::from_static("Basic dXNlcjpwYXNzd29yZA=="); + expected_header.set_sensitive(true); + let expected_proxy_opts = + ProxyOptions::new("target.example.com:443".to_string(), Some(expected_header)); + + for address in &addresses { + let proxy_opts = ProxyOptions::from_addr(address).expect("ProxyOptions not found"); + assert_eq!(proxy_opts, &expected_proxy_opts); + } + } + + #[tokio::test] + async fn proxy_not_matched() { + let matcher = Matcher::builder() + .https("http://proxy.example.com:8080") + .no("target.example.com") + .build(); + + let addresses = run_resolver_and_get_addresses( + "dns:///target.example.com", + vec!["127.0.0.1".parse().unwrap()], + Some(&matcher), + ) + .await; + + assert_eq!(addresses.len(), 1); + assert_eq!(&*addresses[0].address, DIRECT_ADDRESS); + assert!(ProxyOptions::from_addr(&addresses[0]).is_none()); + } + + #[tokio::test] + async fn punycode_encoding() { + let matcher = Matcher::builder() + .https("http://proxy.example.com:8080") + .build(); + + let addresses = run_resolver_and_get_addresses( + "dns:///täst.example.com", + vec!["127.0.0.1".parse().unwrap()], + Some(&matcher), + ) + .await; + + assert_eq!(addresses.len(), 1); + let proxy_opts = ProxyOptions::from_addr(&addresses[0]).expect("ProxyOptions not found"); + assert_eq!( + proxy_opts, + &ProxyOptions::new("xn--tst-qla.example.com:443".to_string(), None) + ); + } + + #[tokio::test] + async fn invalid_path_with_proxy_errors() { + let matcher = Matcher::builder() + .https("http://proxy.example.com:8080") + .build(); + + // The path has a space in the first segment of the path, which makes it + // an invalid hostname. + let target_uri = "dns:///var%20/run/grpc.sock"; + + let child_builder = Arc::new(MockResolverBuilder {}); + let builder = Builder::new(child_builder); + + let target: Target = target_uri.parse().unwrap(); + let (work_scheduler, mut work_rx) = TestWorkScheduler::new_pair(); + let runtime = FakeRuntime { + inner: TokioRuntime::default(), + dns: FakeDns { + lookup_result: Ok(vec!["127.0.0.1".parse().unwrap()]), + }, + }; + let options = ResolverOptions { + authority: target.authority_host_port(), + runtime: GrpcRuntime::new(runtime), + work_scheduler, + }; + + let mut resolver = builder.new_resolver(&target, options, Some(&matcher)); + + work_rx.recv().await.unwrap(); + + let (mut channel_controller, mut update_rx) = TestChannelController::new_pair(); + resolver.work(&mut channel_controller); + + let update = update_rx.recv().await.unwrap(); + let err = update.endpoints.unwrap_err(); + assert!(err.contains("invalid target host in URL")); + } + + #[tokio::test] + async fn unix_path_bypass() { + let matcher = Matcher::builder() + .https("http://proxy.example.com:8080") + .build(); + + // Proxy lookup for unix targets should be skipped. + let addresses = run_resolver_and_get_addresses( + "unix:///var%20/run/grpc.sock", + vec!["127.0.0.1".parse().unwrap()], + Some(&matcher), + ) + .await; + + assert_eq!(addresses.len(), 1); + assert_eq!(&*addresses[0].address, DIRECT_ADDRESS); + assert!(ProxyOptions::from_addr(&addresses[0]).is_none()); + + // Check for abstract-unix scheme. + let addresses = run_resolver_and_get_addresses( + "unix-abstract:grpc.sock", + vec!["127.0.0.1".parse().unwrap()], + Some(&matcher), + ) + .await; + + assert_eq!(addresses.len(), 1); + assert_eq!(&*addresses[0].address, DIRECT_ADDRESS); + assert!(ProxyOptions::from_addr(&addresses[0]).is_none()); + } + + #[tokio::test] + async fn matcher_behavior_configured_manually() { + let dns_ips = || vec!["127.0.0.1".parse().unwrap()]; + + // Case 1: http proxy is set, but destination is HTTPS. + // It should NOT be matched. + let matcher = Matcher::builder() + .http("http://proxy.example.com:8080") + .build(); + let addresses = + run_resolver_and_get_addresses("dns:///target.example.com", dns_ips(), Some(&matcher)) + .await; + assert_eq!(addresses.len(), 1); + assert_eq!(&*addresses[0].address, DIRECT_ADDRESS); + assert!( + ProxyOptions::from_addr(&addresses[0]).is_none(), + "HTTP proxy should not match HTTPS destinations" + ); + + // Case 2: https proxy is set, destination is HTTPS. + // It should be matched. + let matcher = Matcher::builder() + .https("http://proxy.example.com:8080") + .build(); + let addresses = + run_resolver_and_get_addresses("dns:///target.example.com", dns_ips(), Some(&matcher)) + .await; + assert_eq!(addresses.len(), 1); + assert_eq!(&*addresses[0].address, "127.0.0.1:8080"); + let expected_proxy_opts = ProxyOptions::new("target.example.com:443".to_string(), None); + let proxy_opts = ProxyOptions::from_addr(&addresses[0]).expect("ProxyOptions not found"); + assert_eq!(proxy_opts, &expected_proxy_opts); + + // Case 3: https proxy and no proxy are configured. + let matcher = Matcher::builder() + .https("http://proxy.example.com:8080") + .no("target.example.com") + .build(); + + // Target A: target.example.com (matched by no_proxy) -> should bypass proxy + let addresses = + run_resolver_and_get_addresses("dns:///target.example.com", dns_ips(), Some(&matcher)) + .await; + assert_eq!(addresses.len(), 1); + assert_eq!(&*addresses[0].address, DIRECT_ADDRESS); + assert!(ProxyOptions::from_addr(&addresses[0]).is_none()); + + // Target B: other.example.com (NOT matched by no_proxy) -> should proxy + let addresses = + run_resolver_and_get_addresses("dns:///other.example.com", dns_ips(), Some(&matcher)) + .await; + assert_eq!(addresses.len(), 1); + assert_eq!(&*addresses[0].address, "127.0.0.1:8080"); + let expected_proxy_opts = ProxyOptions::new("other.example.com:443".to_string(), None); + let proxy_opts = ProxyOptions::from_addr(&addresses[0]).expect("ProxyOptions not found"); + assert_eq!(proxy_opts, &expected_proxy_opts); + } + + #[tokio::test] + async fn no_matcher_returns_child_resolver() { + let addresses = run_resolver_and_get_addresses( + "unix:///invalid/but/doesnt/matter/since/no/matcher", + vec!["127.0.0.1".parse().unwrap()], + None, + ) + .await; + + assert_eq!(addresses.len(), 1); + assert_eq!(&*addresses[0].address, DIRECT_ADDRESS); + assert!(ProxyOptions::from_addr(&addresses[0]).is_none()); + } + + #[tokio::test] + async fn ipv6_proxy_address() { + let matcher = Matcher::builder().https("http://[::1]:8080").build(); + + let addresses = run_resolver_and_get_addresses( + "dns:///target.example.com", + vec!["127.0.0.1".parse().unwrap()], + Some(&matcher), + ) + .await; + + assert_eq!(addresses.len(), 1); + assert_eq!(&*addresses[0].address, "[::1]:8080"); + + let expected_proxy_opts = ProxyOptions::new("target.example.com:443".to_string(), None); + + let proxy_opts = ProxyOptions::from_addr(&addresses[0]).expect("ProxyOptions not found"); + assert_eq!(proxy_opts, &expected_proxy_opts); + } + + #[tokio::test] + async fn ipv6_target_address() { + let matcher = Matcher::builder() + .https("http://proxy.example.com:8080") + .build(); + + let addresses = run_resolver_and_get_addresses( + "dns:///::1", + vec!["127.0.0.1".parse().unwrap()], + Some(&matcher), + ) + .await; + + assert_eq!(addresses.len(), 1); + assert_eq!(&*addresses[0].address, "127.0.0.1:8080"); + + let expected_proxy_opts = ProxyOptions::new("[::1]:443".to_string(), None); + + let proxy_opts = ProxyOptions::from_addr(&addresses[0]).expect("ProxyOptions not found"); + assert_eq!(proxy_opts, &expected_proxy_opts); + } + + struct CustomAuthorityBuilder { + default_authority: String, + } + + impl ResolverBuilder for CustomAuthorityBuilder { + fn build(&self, _target: &Target, options: ResolverOptions) -> Box { + let addr = Address { + network_type: "tcp", + address: ByteStr::from(DIRECT_ADDRESS.to_string()), + attributes: Attributes::new(), + }; + NopResolver::new_with_addr(addr, options) + } + + fn scheme(&self) -> &str { + "dns" + } + + fn is_valid_uri(&self, _uri: &Target) -> bool { + true + } + + fn default_authority(&self, _target: &Target) -> String { + self.default_authority.clone() + } + } + + #[tokio::test] + async fn custom_resolver_builder_default_authority() { + let matcher = Matcher::builder() + .https("http://proxy.example.com:8080") + .build(); + + let custom_authority = "custom.authority.example.com:1234".to_string(); + let child_builder = Arc::new(CustomAuthorityBuilder { + default_authority: custom_authority.clone(), + }); + + let dns_ips = vec!["127.0.0.1".parse().unwrap()]; + let addresses = run_resolver_and_get_addresses_with_builder( + "dns:///whatever", + dns_ips, + Some(&matcher), + child_builder, + ) + .await; + + assert_eq!(addresses.len(), 1); + let expected_proxy_opts = + ProxyOptions::new("custom.authority.example.com:1234".to_string(), None); + + let proxy_opts = ProxyOptions::from_addr(&addresses[0]).expect("ProxyOptions not found"); + assert_eq!(proxy_opts, &expected_proxy_opts); + } +} diff --git a/grpc/src/client/transport/mod.rs b/grpc/src/client/transport/mod.rs index bb4f65a2c..8bce87acb 100644 --- a/grpc/src/client/transport/mod.rs +++ b/grpc/src/client/transport/mod.rs @@ -25,8 +25,11 @@ use std::sync::Arc; use std::time::Duration; +use http::HeaderValue; + use crate::client::DynInvoke; use crate::client::Invoke; +use crate::client::name_resolution::Address; use crate::credentials::ChannelCredentials; use crate::credentials::client::ChannelSecurityInfo; use crate::credentials::client::ClientHandshakeInfo; @@ -128,3 +131,53 @@ pub(crate) struct SecurityOpts { pub(crate) authority: Authority, pub(crate) handshake_info: ClientHandshakeInfo, } + +/// Configuration options for establishing an HTTP `CONNECT` proxy tunnel. +/// +/// This may be added as an [`Address`] attribute by a +/// [`crate::client::name_resolution::Resolver`]. If present, the subchannel +/// will automatically handle the HTTP `CONNECT` handshake. +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] +pub(crate) struct ProxyOptions { + proxy_authorization_header: Option, + target_authority: String, +} + +impl ProxyOptions { + /// Creates a new `ProxyOptions`. + /// + /// # Arguments + /// * `target_authority` - The address of the target server to connect to + /// (host:port). Must be a valid hostname. + /// * `proxy_authorization_header` - The value of the `Proxy-Authorization` header, if present. + pub(crate) fn new( + target_authority: String, + proxy_authorization_header: Option, + ) -> Self { + Self { + proxy_authorization_header, + target_authority, + } + } + + /// Returns the value of the `Proxy-Authorization` header, if present. + pub(crate) fn proxy_authorization_header(&self) -> Option<&HeaderValue> { + self.proxy_authorization_header.as_ref() + } + + /// Returns the address of the proxy server to connect to (host:port). + /// This is Punycode-encoded, i.e., it's a valid URL host:port. + pub(crate) fn target_authority(&self) -> &str { + &self.target_authority + } + + /// Extracts `ProxyOptions` from the given `Address` attributes, if present. + pub(crate) fn from_addr(addr: &Address) -> Option<&Self> { + addr.attributes.get::>().map(AsRef::as_ref) + } + + /// Adds these `ProxyOptions` to the given `Address` attributes. + pub(crate) fn add_to_addr(addr: &mut Address, options: Arc) { + addr.attributes = addr.attributes.add(options); + } +} diff --git a/grpc/src/credentials/mod.rs b/grpc/src/credentials/mod.rs index e8e9adc77..cb5b48ee0 100644 --- a/grpc/src/credentials/mod.rs +++ b/grpc/src/credentials/mod.rs @@ -157,6 +157,27 @@ pub(crate) mod common { } } + /// Parses the host and port from a string. When the input can not be parsed + /// as (host, port) pair, it returns the entire input as the host. + pub(crate) fn from_host_port_str(host_and_port: &str) -> Self { + // Handle bracketed IPv6 addresses (e.g., "[::1]:80"). + if let Some(stripped) = host_and_port.strip_prefix('[') + && let Some((host, port_str)) = stripped.split_once("]:") + && let Ok(port) = port_str.parse::() + { + return Self::new(host, Some(port)); + } + // Handle unbracketed addresses (IPv4 or hostnames, e.g., + // "localhost:8080"). + if let Some((host, port_str)) = host_and_port.rsplit_once(':') + && !host.contains(':') + && let Ok(port) = port_str.parse::() + { + return Self::new(host, Some(port)); + } + Self::new(host_and_port.to_string(), None) + } + pub fn host(&self) -> &str { &self.host } @@ -165,6 +186,10 @@ pub(crate) mod common { self.port } + pub fn set_port(&mut self, port: Option) { + self.port = port; + } + pub fn host_port_string(&self) -> String { let host_str = &self.host; match self.port() { @@ -214,4 +239,159 @@ mod tests { let authority = Authority::new("::1", None); assert_eq!(&authority.host_port_string(), "::1"); } + + #[test] + fn test_parse_authority() { + struct TestCase { + input: &'static str, + expected: Authority, + } + + let cases = [ + TestCase { + input: "localhost:http", + expected: Authority::new("localhost:http", None), + }, + TestCase { + input: "localhost:80", + expected: Authority::new("localhost", Some(80)), + }, + // host name with zone identifier. + TestCase { + input: "localhost%lo0:80", + expected: Authority::new("localhost%lo0", Some(80)), + }, + TestCase { + input: "localhost%lo0:http", + expected: Authority::new("localhost%lo0:http", None), + }, + TestCase { + input: "[localhost%lo0]:http", + expected: Authority::new("[localhost%lo0]:http", None), + }, + TestCase { + input: "[localhost%lo0]:80", + expected: Authority::new("localhost%lo0", Some(80)), + }, + // IP literal + TestCase { + input: "127.0.0.1:http", + expected: Authority::new("127.0.0.1:http", None), + }, + TestCase { + input: "127.0.0.1:80", + expected: Authority::new("127.0.0.1", Some(80)), + }, + TestCase { + input: "[::1]:http", + expected: Authority::new("[::1]:http", None), + }, + TestCase { + input: "[::1]:80", + expected: Authority::new("::1", Some(80)), + }, + // IP literal with zone identifier. + TestCase { + input: "[::1%lo0]:http", + expected: Authority::new("[::1%lo0]:http", None), + }, + TestCase { + input: "[::1%lo0]:80", + expected: Authority::new("::1%lo0", Some(80)), + }, + TestCase { + input: ":http", + expected: Authority::new(":http", None), + }, + TestCase { + input: ":80", + expected: Authority::new("", Some(80)), + }, + TestCase { + input: "grpc.io:", + expected: Authority::new("grpc.io:", None), + }, + TestCase { + input: "127.0.0.1:", + expected: Authority::new("127.0.0.1:", None), + }, + TestCase { + input: "[::1]:", + expected: Authority::new("[::1]:", None), + }, + TestCase { + input: "grpc.io:https%foo", + expected: Authority::new("grpc.io:https%foo", None), + }, + TestCase { + input: "grpc.io", + expected: Authority::new("grpc.io", None), + }, + TestCase { + input: "127.0.0.1", + expected: Authority::new("127.0.0.1", None), + }, + TestCase { + input: "[::1]", + expected: Authority::new("[::1]", None), + }, + TestCase { + input: "[fe80::1%lo0]", + expected: Authority::new("[fe80::1%lo0]", None), + }, + TestCase { + input: "[localhost%lo0]", + expected: Authority::new("[localhost%lo0]", None), + }, + TestCase { + input: "localhost%lo0", + expected: Authority::new("localhost%lo0", None), + }, + TestCase { + input: "::1", + expected: Authority::new("::1", None), + }, + TestCase { + input: "fe80::1%lo0", + expected: Authority::new("fe80::1%lo0", None), + }, + TestCase { + input: "fe80::1%lo0:80", + expected: Authority::new("fe80::1%lo0:80", None), + }, + TestCase { + input: "[foo:bar]", + expected: Authority::new("[foo:bar]", None), + }, + TestCase { + input: "[foo:bar]baz", + expected: Authority::new("[foo:bar]baz", None), + }, + TestCase { + input: "[foo]bar:baz", + expected: Authority::new("[foo]bar:baz", None), + }, + TestCase { + input: "[foo]:[bar]:baz", + expected: Authority::new("[foo]:[bar]:baz", None), + }, + TestCase { + input: "[foo]:[bar]baz", + expected: Authority::new("[foo]:[bar]baz", None), + }, + TestCase { + input: "foo[bar]:baz", + expected: Authority::new("foo[bar]:baz", None), + }, + TestCase { + input: "foo]bar:baz", + expected: Authority::new("foo]bar:baz", None), + }, + ]; + + for TestCase { input, expected } in cases { + let auth = Authority::from_host_port_str(input); + assert_eq!(auth, expected, "authority mismatch for {}", input); + } + } }