defect_http/proxy.rs
1//! HTTP/HTTPS proxy connector assembly.
2//!
3//! HTTP proxy implementation.
4//!
5//! Architecture: the connection layer wraps a
6//! [`hyper_http_proxy::ProxyConnector<HttpConnector>`],
7//! then wraps that with [`hyper_rustls::HttpsConnector`] for TLS. When the `proxies` list
8//! is empty,
9//! `ProxyConnector` transparently passes through (see the `match_proxy` branch in the
10//! upstream
11//! `Service<Uri>` impl), so the connector type stays the same regardless of whether the
12//! user has
13//! enabled a proxy — `HttpsConnector<ProxyConnector<HttpConnector>>` — avoiding two
14//! different
15//! connector types in [`build_http_stack`].
16//!
17//! NO_PROXY: each proxy entry's [`Intercept`] is written as an [`Intercept::Custom`]
18//! closure that
19//! matches scheme + host against the `NO_PROXY` suffix list. Matching follows
20//! [GNU style](https://about.gitlab.com/blog/we-need-to-talk-no-proxy/):
21//! comma-separated, domain suffixes (`api.openai.com` matches `*.openai.com`), `*` means
22//! block all,
23//! IP CIDR and ports are not currently supported.
24//!
25//! [`build_http_stack`]: super::build_http_stack
26
27use std::env;
28use std::sync::Arc;
29
30use http::Uri;
31use hyper_http_proxy::{Intercept, Proxy, ProxyConnector};
32use hyper_rustls::HttpsConnectorBuilder;
33use hyper_util::client::legacy::connect::HttpConnector;
34
35use super::{HttpStackError, ProxyConfig, ProxySettings};
36
37/// The full connector type used by upper layers to construct a
38/// [`hyper_util::client::legacy::Client`].
39pub type ProxyAwareConnector = hyper_rustls::HttpsConnector<ProxyConnector<HttpConnector>>;
40
41/// Build a full connector from [`ProxyConfig`].
42///
43/// - `Disabled` → still returns a `ProxyConnector`, but with no entries; `match_proxy`
44/// always returns `None`, behaving equivalently to "no proxy".
45/// - `FromEnv` → reads `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` (case-insensitive,
46/// lowercase preferred, matching curl conventions).
47/// - `Explicit` → uses the given values directly.
48///
49/// # Errors
50///
51/// Returns an error if loading native TLS roots fails, or if a proxy URL from the
52/// environment cannot be parsed.
53pub fn build_proxy_connector(config: &ProxyConfig) -> Result<ProxyAwareConnector, HttpStackError> {
54 let entries = resolve_proxy(config)?;
55
56 // ⚠ Must call `enforce_http(false)`: by default `HttpConnector` rejects `https`
57 // schemes. When the outer `HttpsConnector` handles TLS for `https://` URLs, the inner
58 // `HttpConnector` only manages TCP. `ProxyConnector` falls through to the inner
59 // `HttpConnector` when no proxy entry matches (see the fallthrough branch in the
60 // upstream `Service<Uri>` impl). With the default `enforce_http(true)`, all
61 // `https://` requests immediately return `Err(InvalidUri)`. hyper-rustls's own
62 // `HttpsConnectorBuilder::build()` applies this same change, but `wrap_connector(_)`
63 // does not modify custom connectors — it must be done manually.
64 let mut http_connector = HttpConnector::new();
65 http_connector.enforce_http(false);
66
67 // ⚠ Must use `unsecured`: when `__rustls` (any `rustls-tls-*-roots` feature) is
68 // enabled, `ProxyConnector::new` embeds a `tokio_rustls::TlsConnector` that performs
69 // its own TLS handshake over the CONNECT tunnel, returning `ProxyStream::Secured`.
70 // Our outer `HttpsConnector::wrap_connector(_)` then wraps this already-encrypted
71 // stream in another TLS layer — TLS-in-TLS — so the outer handshake never receives a
72 // ServerHello and times out after ~14s. Using `unsecured` disables the proxy
73 // connector's own TLS, making it handle only the CONNECT tunnel + raw TCP (returning
74 // `ProxyStream::Regular`), while the outer `HttpsConnector` handles all TLS
75 // (including HTTP/2 ALPN). That's why the workspace disables all `rustls-*-roots`
76 // features on `hyper-http-proxy`.
77 let mut proxy_connector = ProxyConnector::unsecured(http_connector);
78 for entry in entries {
79 proxy_connector.add_proxy(Proxy::new(entry.intercept, entry.uri));
80 }
81
82 let https = HttpsConnectorBuilder::new()
83 .with_native_roots()
84 .map_err(|e| HttpStackError::Config {
85 hint: format!("load native TLS roots failed: {e}"),
86 })?
87 .https_or_http()
88 .enable_all_versions()
89 .wrap_connector(proxy_connector);
90
91 Ok(https)
92}
93
94/// A single resolved proxy entry.
95struct ResolvedProxy {
96 intercept: Intercept,
97 uri: Uri,
98}
99
100/// Converts a [`ProxyConfig`] into a list of `(Intercept, Uri)` pairs.
101///
102/// Returns an empty list when no proxy is configured (a valid state); returns
103/// `HttpStackError::Config` if a URI fails to parse.
104fn resolve_proxy(config: &ProxyConfig) -> Result<Vec<ResolvedProxy>, HttpStackError> {
105 match config {
106 ProxyConfig::Disabled => Ok(Vec::new()),
107 ProxyConfig::FromEnv => resolve_from_env(),
108 ProxyConfig::Explicit(settings) => resolve_explicit(settings),
109 }
110}
111
112fn resolve_from_env() -> Result<Vec<ResolvedProxy>, HttpStackError> {
113 let http_proxy = env_proxy("http_proxy", "HTTP_PROXY")?;
114 let https_proxy = env_proxy("https_proxy", "HTTPS_PROXY")?;
115 let no_proxy = parse_no_proxy(env_first("no_proxy", "NO_PROXY").as_deref().unwrap_or(""));
116
117 let settings = ProxySettings {
118 http_proxy,
119 https_proxy,
120 no_proxy,
121 };
122 resolve_explicit(&settings)
123}
124
125fn resolve_explicit(settings: &ProxySettings) -> Result<Vec<ResolvedProxy>, HttpStackError> {
126 if no_proxy_disables_all(&settings.no_proxy) {
127 return Ok(Vec::new());
128 }
129
130 let no_proxy = Arc::<[String]>::from(settings.no_proxy.clone());
131 let mut entries = Vec::with_capacity(2);
132
133 if let Some(uri) = settings.http_proxy.clone() {
134 entries.push(ResolvedProxy {
135 intercept: scheme_intercept_with_no_proxy("http", no_proxy.clone()),
136 uri,
137 });
138 }
139 if let Some(uri) = settings.https_proxy.clone() {
140 entries.push(ResolvedProxy {
141 intercept: scheme_intercept_with_no_proxy("https", no_proxy.clone()),
142 uri,
143 });
144 }
145
146 Ok(entries)
147}
148
149/// Reads an env variable and parses it into a [`Uri`]. Prefers the lowercase name,
150/// falling back to uppercase — this is the de‑facto convention used by curl, Go,
151/// requests, and other mainstream clients.
152fn env_proxy(lower: &str, upper: &str) -> Result<Option<Uri>, HttpStackError> {
153 let raw = match env_first(lower, upper) {
154 Some(v) => v,
155 None => return Ok(None),
156 };
157 let trimmed = raw.trim();
158 if trimmed.is_empty() {
159 return Ok(None);
160 }
161 let uri = trimmed.parse::<Uri>().map_err(|e| HttpStackError::Config {
162 hint: format!("invalid proxy URL `{trimmed}` from env: {e}"),
163 })?;
164 Ok(Some(uri))
165}
166
167fn env_first(lower: &str, upper: &str) -> Option<String> {
168 if let Ok(v) = env::var(lower)
169 && !v.trim().is_empty()
170 {
171 return Some(v);
172 }
173 if let Ok(v) = env::var(upper)
174 && !v.trim().is_empty()
175 {
176 return Some(v);
177 }
178 None
179}
180
181/// `Intercept::Custom`: proxy only when the scheme matches and the host is not in the
182/// NO_PROXY list.
183fn scheme_intercept_with_no_proxy(scheme: &'static str, no_proxy: Arc<[String]>) -> Intercept {
184 Intercept::Custom(
185 (move |s: Option<&str>, h: Option<&str>, _p: Option<u16>| -> bool {
186 if s != Some(scheme) {
187 return false;
188 }
189 let host = match h {
190 Some(h) => h,
191 None => return true,
192 };
193 !matches_no_proxy(host, &no_proxy)
194 })
195 .into(),
196 )
197}
198
199/// Parse a comma-separated `NO_PROXY` string, trimming whitespace and skipping empty
200/// entries.
201fn parse_no_proxy(raw: &str) -> Vec<String> {
202 raw.split(',')
203 .map(str::trim)
204 .filter(|s| !s.is_empty())
205 .map(str::to_owned)
206 .collect()
207}
208
209/// A `*` in the list disables all proxies.
210fn no_proxy_disables_all(patterns: &[String]) -> bool {
211 patterns.iter().any(|p| p == "*")
212}
213
214/// Returns whether `host` is exempted by the NO_PROXY list.
215///
216/// GNU style: each pattern is a domain name (leading/trailing `.` are stripped);
217/// `host` is exempt if it matches one of:
218/// - `host == pattern` (after stripping leading dot)
219/// - `host` ends with `.<pattern>`
220///
221/// `*` is already handled by [`no_proxy_disables_all`], so it is not checked here.
222/// Ports (e.g. `example.com:8080`) and IP CIDR are not currently supported — patterns
223/// containing `:` or numeric subnets are compared literally; if they don't match,
224/// the host is not exempted (safe behavior: prefer the proxy over a false match).
225pub(crate) fn matches_no_proxy(host: &str, patterns: &[String]) -> bool {
226 let host = host.trim_end_matches('.').to_ascii_lowercase();
227 if host.is_empty() {
228 return false;
229 }
230 for raw in patterns {
231 let pat = raw
232 .trim_start_matches('.')
233 .trim_end_matches('.')
234 .to_ascii_lowercase();
235 if pat.is_empty() {
236 continue;
237 }
238 if host == pat {
239 return true;
240 }
241 if host.ends_with(&format!(".{pat}")) {
242 return true;
243 }
244 }
245 false
246}
247
248#[cfg(test)]
249mod tests;