Skip to content

Commit 23f0d01

Browse files
feat(client): Add system proxy support for macOS
#3850
1 parent 3ac9187 commit 23f0d01

File tree

6 files changed

+691
-340
lines changed

6 files changed

+691
-340
lines changed

Cargo.toml

+25-4
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ repository = "https://github.com/hyperium/hyper-util"
99
license = "MIT"
1010
authors = ["Sean McArthur <sean@seanmonstar.com>"]
1111
keywords = ["http", "hyper", "hyperium"]
12-
categories = ["network-programming", "web-programming::http-client", "web-programming::http-server"]
12+
categories = [
13+
"network-programming",
14+
"web-programming::http-client",
15+
"web-programming::http-server",
16+
]
1317
edition = "2021"
1418
rust-version = "1.63"
1519

@@ -29,10 +33,19 @@ ipnet = { version = "2.9", optional = true }
2933
percent-encoding = { version = "2.3", optional = true }
3034
pin-project-lite = "0.2.4"
3135
socket2 = { version = "0.5", optional = true, features = ["all"] }
32-
tracing = { version = "0.1", default-features = false, features = ["std"], optional = true }
33-
tokio = { version = "1", optional = true, default-features = false }
36+
tracing = { version = "0.1", default-features = false, features = [
37+
"std",
38+
], optional = true }
39+
tokio = { version = "1", optional = true, default-features = false }
3440
tower-service = { version = "0.3", optional = true }
3541

42+
# Conditional dependencies for system proxy support
43+
[target.'cfg(target_os = "macos")'.dependencies]
44+
system-configuration = { version = "0.6.1", optional = true }
45+
46+
[target.'cfg(target_os = "windows")'.dependencies]
47+
winreg = { version = "0.55.0", optional = true }
48+
3649
[dev-dependencies]
3750
hyper = { version = "1.4.0", features = ["full"] }
3851
bytes = "1"
@@ -58,9 +71,15 @@ full = [
5871
"http1",
5972
"http2",
6073
"tokio",
74+
"system-proxies"
6175
]
6276

63-
client = ["hyper/client", "dep:tracing", "dep:futures-channel", "dep:tower-service"]
77+
client = [
78+
"hyper/client",
79+
"dep:tracing",
80+
"dep:futures-channel",
81+
"dep:tower-service",
82+
]
6483
client-legacy = ["client", "dep:socket2", "tokio/sync"]
6584
client-proxy-env = ["client", "dep:base64", "dep:ipnet", "dep:percent-encoding"]
6685

@@ -75,6 +94,8 @@ http2 = ["hyper/http2"]
7594

7695
tokio = ["dep:tokio", "tokio/net", "tokio/rt", "tokio/time"]
7796

97+
system-proxies = ["system-configuration", "winreg"]
98+
7899
# internal features used in CI
79100
__internal_happy_eyeballs_tests = []
80101

src/client/proxy/builder.rs

+315
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
use super::no_proxy::NoProxy;
2+
use super::utils::{get_first_env, parse_env_uri};
3+
use super::Matcher;
4+
5+
#[derive(Default)]
6+
pub struct Builder {
7+
pub(crate) is_cgi: bool,
8+
pub(crate) all: String,
9+
pub(crate) http: String,
10+
pub(crate) https: String,
11+
pub(crate) no: String,
12+
}
13+
14+
// ===== impl Builder =====
15+
impl Builder {
16+
pub(crate) fn from_env() -> Self {
17+
Builder {
18+
is_cgi: std::env::var_os("REQUEST_METHOD").is_some(),
19+
all: get_first_env(&["ALL_PROXY", "all_proxy"]),
20+
http: get_first_env(&["HTTP_PROXY", "http_proxy"]),
21+
https: get_first_env(&["HTTPS_PROXY", "https_proxy"]),
22+
no: get_first_env(&["NO_PROXY", "no_proxy"]),
23+
}
24+
}
25+
26+
/// Set a proxy for all schemes (ALL_PROXY equivalent).
27+
pub fn all_proxy(mut self, proxy: impl Into<String>) -> Self {
28+
self.all = proxy.into();
29+
self
30+
}
31+
32+
/// Set a proxy for HTTP schemes (HTTP_PROXY equivalent).
33+
pub fn http_proxy(mut self, proxy: impl Into<String>) -> Self {
34+
self.http = proxy.into();
35+
self
36+
}
37+
38+
/// Set a proxy for HTTPS schemes (HTTPS_PROXY equivalent).
39+
pub fn https_proxy(mut self, proxy: impl Into<String>) -> Self {
40+
self.https = proxy.into();
41+
self
42+
}
43+
44+
/// Set no-proxy rules (NO_PROXY equivalent).
45+
pub fn no_proxy(mut self, no_proxy: impl Into<String>) -> Self {
46+
self.no = no_proxy.into();
47+
self
48+
}
49+
50+
pub(crate) fn build(self) -> Matcher {
51+
if self.is_cgi {
52+
return Matcher {
53+
http: None,
54+
https: None,
55+
no: NoProxy::empty(),
56+
};
57+
}
58+
59+
let all = parse_env_uri(&self.all);
60+
61+
Matcher {
62+
http: parse_env_uri(&self.http).or_else(|| all.clone()),
63+
https: parse_env_uri(&self.https).or(all),
64+
no: NoProxy::from_string(&self.no),
65+
}
66+
}
67+
}
68+
69+
// ===== MacOS Builder System Proxies =====
70+
#[cfg(feature = "system-proxies")]
71+
#[cfg(target_os = "macos")]
72+
mod macos_proxies {
73+
use super::*;
74+
75+
use system_configuration::core_foundation::array::CFArray;
76+
use system_configuration::core_foundation::base::{CFType, TCFType, TCFTypeRef};
77+
use system_configuration::core_foundation::dictionary::CFDictionary;
78+
use system_configuration::core_foundation::number::CFNumber;
79+
use system_configuration::core_foundation::string::{CFString, CFStringRef};
80+
use system_configuration::dynamic_store::{SCDynamicStore, SCDynamicStoreBuilder};
81+
82+
impl Builder {
83+
// Helper function to check if a proxy is enabled
84+
fn is_proxy_enabled(&self, prefix: &str, proxies: &CFDictionary<CFString, CFType>) -> bool {
85+
let key = format!("{}Enable", prefix);
86+
proxies
87+
.find(CFString::new(&key))
88+
.map(|val| {
89+
// Try to get the value as i32 directly
90+
unsafe {
91+
let num_ref = val.as_concrete_TypeRef();
92+
if num_ref.is_null() {
93+
return false;
94+
}
95+
let num = CFNumber::wrap_under_get_rule(num_ref as *const _);
96+
num.to_i32() == Some(1)
97+
}
98+
})
99+
.unwrap_or(false)
100+
}
101+
// Helper function to get a string value
102+
fn get_string(
103+
&self,
104+
key: &str,
105+
proxies: &CFDictionary<CFString, CFType>,
106+
) -> Option<String> {
107+
proxies
108+
.find(CFString::new(key))
109+
.map(|val| unsafe {
110+
let str_ref = val.as_concrete_TypeRef();
111+
if str_ref.is_null() {
112+
return None;
113+
}
114+
let cfstr = CFString::wrap_under_get_rule(str_ref as *const _);
115+
Some(cfstr.to_string())
116+
})
117+
.flatten()
118+
}
119+
// Helper function to get an integer value
120+
fn get_int(&self, key: &str, proxies: &CFDictionary<CFString, CFType>) -> Option<i32> {
121+
proxies
122+
.find(CFString::new(key))
123+
.map(|val| unsafe {
124+
let num_ref = val.as_concrete_TypeRef();
125+
if num_ref.is_null() {
126+
return None;
127+
}
128+
let num = CFNumber::wrap_under_get_rule(num_ref as *const _);
129+
num.to_i32()
130+
})
131+
.flatten()
132+
}
133+
134+
pub fn from_system_proxy(mut self) -> Self {
135+
let store = SCDynamicStoreBuilder::new("proxy-fetcher").build();
136+
137+
if let Some(proxies) = store.get_proxies() {
138+
let (http, https, no) = self.extract_system_proxy(proxies);
139+
140+
if let Some(http_proxy) = http {
141+
self.http = http_proxy;
142+
}
143+
if let Some(https_proxy) = https {
144+
self.https = https_proxy;
145+
}
146+
if let Some(no_proxy) = no {
147+
self.no = no_proxy;
148+
}
149+
}
150+
151+
self
152+
}
153+
pub(crate) fn extract_system_proxy(
154+
&self,
155+
proxies: CFDictionary<CFString, CFType>,
156+
) -> (Option<String>, Option<String>, Option<String>) {
157+
let mut http: Option<String> = None;
158+
let mut https: Option<String> = None;
159+
let mut no: Option<String> = None;
160+
161+
// Process HTTP proxy
162+
if self.is_proxy_enabled("HTTP", &proxies) {
163+
if let Some(host) = self.get_string("HTTPProxy", &proxies) {
164+
let port = self.get_int("HTTPPort", &proxies);
165+
http = match port {
166+
Some(p) => Some(format!("http://{}:{}", host, p)),
167+
None => Some(format!("http://{}", host)),
168+
};
169+
}
170+
}
171+
172+
// Process HTTPS proxy
173+
if self.is_proxy_enabled("HTTPS", &proxies) {
174+
if let Some(host) = self.get_string("HTTPSProxy", &proxies) {
175+
let port = self.get_int("HTTPSPort", &proxies);
176+
https = match port {
177+
Some(p) => Some(format!("https://{}:{}", host, p)),
178+
None => Some(format!("https://{}", host)),
179+
};
180+
}
181+
}
182+
183+
// Process exceptions (NO_PROXY)
184+
if let Some(exceptions_ref) = proxies.find(CFString::new("ExceptionsList")) {
185+
if let Some(arr) = exceptions_ref.downcast::<CFArray>() {
186+
let exceptions: Vec<String> = arr
187+
.iter()
188+
.filter_map(|item| unsafe {
189+
// Get the raw pointer value
190+
let ptr = item.as_void_ptr();
191+
if ptr.is_null() {
192+
return None;
193+
}
194+
// Try to convert it to a CFString
195+
let cfstr = CFString::wrap_under_get_rule(ptr as *const _);
196+
Some(cfstr.to_string())
197+
})
198+
.collect();
199+
no = Some(exceptions.join(","));
200+
}
201+
}
202+
203+
(http, https, no)
204+
}
205+
}
206+
207+
#[cfg(test)]
208+
mod tests {
209+
use super::*;
210+
use crate::client::proxy::Matcher;
211+
use system_configuration::core_foundation::array::CFArray;
212+
use std::{net::IpAddr, str::FromStr};
213+
214+
struct MockSCDynamicStore {
215+
pairs: Vec<(CFString, CFType)>,
216+
}
217+
218+
impl MockSCDynamicStore {
219+
fn new() -> Self {
220+
let mut keys = Vec::new();
221+
let mut values = Vec::new();
222+
223+
// HTTP proxy enabled
224+
keys.push(CFString::new("HTTPEnable"));
225+
values.push(CFNumber::from(1).as_CFType());
226+
227+
// HTTP proxy host and port
228+
keys.push(CFString::new("HTTPProxy"));
229+
values.push(CFString::new("test-proxy.example.com").as_CFType());
230+
keys.push(CFString::new("HTTPPort"));
231+
values.push(CFNumber::from(8080).as_CFType());
232+
233+
// HTTPS proxy enabled
234+
keys.push(CFString::new("HTTPSEnable"));
235+
values.push(CFNumber::from(1).as_CFType());
236+
// HTTPS proxy host and port
237+
keys.push(CFString::new("HTTPSProxy"));
238+
values.push(CFString::new("secure-proxy.example.com").as_CFType());
239+
keys.push(CFString::new("HTTPSPort"));
240+
values.push(CFNumber::from(8443).as_CFType());
241+
242+
// Exception list
243+
keys.push(CFString::new("ExceptionsList"));
244+
let exceptions = vec![
245+
CFString::new("localhost").as_CFType(),
246+
CFString::new("127.0.0.1").as_CFType(),
247+
CFString::new("*.local").as_CFType(),
248+
];
249+
values.push(CFArray::from_CFTypes(&exceptions).as_CFType());
250+
251+
let pairs = keys
252+
.iter()
253+
.map(|k| k.clone())
254+
.zip(values.iter().map(|v| v.as_CFType()))
255+
.collect::<Vec<_>>();
256+
257+
MockSCDynamicStore { pairs }
258+
}
259+
260+
fn get_proxies(&self) -> Option<CFDictionary<CFString, CFType>> {
261+
let proxies = CFDictionary::from_CFType_pairs(&self.pairs.clone());
262+
Some(proxies)
263+
}
264+
}
265+
266+
#[test]
267+
fn test_mac_os_proxy_mocked() {
268+
let mock_store = MockSCDynamicStore::new();
269+
let proxies = mock_store.get_proxies().unwrap();
270+
let (http, https, ns) = Matcher::builder().extract_system_proxy(proxies);
271+
272+
assert!(http.is_some());
273+
assert!(https.is_some());
274+
assert!(ns.is_some());
275+
}
276+
277+
#[ignore]
278+
#[test]
279+
fn test_mac_os_proxy() {
280+
let matcher = Matcher::builder().from_system_proxy().build();
281+
assert!(matcher
282+
.http
283+
.unwrap()
284+
.uri
285+
.eq("http://proxy.example.com:8080"));
286+
assert!(matcher
287+
.https
288+
.unwrap()
289+
.uri
290+
.eq("https://proxy.example.com:8080"));
291+
292+
assert!(matcher.no.domains.contains("ebay.com"));
293+
assert!(matcher.no.domains.contains("amazon.com"));
294+
295+
let ip = IpAddr::from_str("54.239.28.85").unwrap();
296+
assert!(matcher.no.ips.contains(ip));
297+
}
298+
}
299+
}
300+
301+
// ===== Windows Builder System Proxies =====
302+
#[cfg(feature = "system-proxies")]
303+
#[cfg(target_os = "win")]
304+
mod win_proxies {
305+
impl Builder {
306+
pub fn from_system_proxy(mut self) -> Self {
307+
todo!("Load Win system proxy settings");
308+
}
309+
}
310+
311+
#[cfg(test)]
312+
mod tests {
313+
use super::*;
314+
}
315+
}

0 commit comments

Comments
 (0)