gosub_sonar/net/proxy.rs
1//! Programmatic proxy configuration for the [`Fetcher`].
2//!
3//! Without configuration the fetcher keeps reqwest's default behaviour and reads the
4//! `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` environment variables — see
5//! [`ProxyConfig::System`]. An embedder that has its own proxy settings (a browser's network
6//! preferences, a PAC-derived result, a per-profile override) sets
7//! [`FetcherConfig::proxy`] instead, which takes the environment out of the picture entirely.
8//!
9//! ```no_run
10//! use gosub_sonar::{FetcherConfig, ProxyConfig, ProxyRule};
11//!
12//! let cfg = FetcherConfig {
13//! proxy: ProxyConfig::Rules(vec![
14//! ProxyRule::all("http://proxy.corp:8080")
15//! .with_basic_auth("alice", "hunter2")
16//! .bypassing("localhost, 10.0.0.0/8, .internal.corp"),
17//! ]),
18//! ..FetcherConfig::default()
19//! };
20//! ```
21//!
22//! Native-only: on `wasm32` the browser's `fetch()` applies the user's own proxy settings and
23//! offers no way to override them, so this module is not compiled there.
24//!
25//! [`Fetcher`]: crate::net::fetcher::Fetcher
26//! [`FetcherConfig::proxy`]: crate::net::fetcher::FetcherConfig::proxy
27
28use anyhow::Context;
29
30/// Which request URLs a [`ProxyRule`] applies to.
31#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default)]
32pub enum ProxyScope {
33 /// Only `http://` request URLs.
34 Http,
35 /// Only `https://` request URLs. Requests are tunnelled through the proxy with `CONNECT`.
36 Https,
37 /// Every request URL, whatever its scheme. The default.
38 #[default]
39 All,
40}
41
42/// Credentials presented to the proxy itself, sent as `Proxy-Authorization`.
43///
44/// This is separate from any authentication the *origin server* asks for. Credentials embedded
45/// in the proxy URL (`http://user:pass@proxy:8080`) work too, but anything needing escaping is
46/// easier to get right here.
47#[derive(Debug, Clone, Eq, PartialEq)]
48pub enum ProxyAuth {
49 /// `Proxy-Authorization: Basic <base64(username:password)>`.
50 Basic {
51 /// Username presented to the proxy.
52 username: String,
53 /// Password presented to the proxy.
54 password: String,
55 },
56 /// A verbatim `Proxy-Authorization` header value, for schemes other than Basic
57 /// (e.g. `"Bearer <token>"`). Rejected at [`Fetcher::new`](crate::net::fetcher::Fetcher::new)
58 /// if it is not a valid header value.
59 Custom(String),
60}
61
62/// One proxy: where it lives, which requests go through it, and which hosts bypass it.
63#[derive(Debug, Clone, Eq, PartialEq)]
64pub struct ProxyRule {
65 /// Which request URLs this rule applies to.
66 pub scope: ProxyScope,
67 /// The proxy's own URL, e.g. `http://proxy.corp:8080`. `http` and `https` proxies are always
68 /// supported; `socks4`, `socks5`, and `socks5h` need the crate's `socks` feature. An
69 /// unparseable or unsupported URL is reported by
70 /// [`Fetcher::new`](crate::net::fetcher::Fetcher::new).
71 pub url: String,
72 /// Credentials for the proxy, if it demands any.
73 pub auth: Option<ProxyAuth>,
74 /// Hosts that bypass this proxy, in `NO_PROXY` syntax: comma-separated entries, each a
75 /// domain (matching that domain and its subdomains), an IP address, a CIDR block, or `*` for
76 /// everything. `None` sends every in-scope request through the proxy — note that this does
77 /// *not* fall back to the `NO_PROXY` environment variable, since configuring a rule
78 /// programmatically opts out of the environment entirely.
79 pub no_proxy: Option<String>,
80}
81
82impl ProxyRule {
83 /// A rule routing every request through `url`, whatever the scheme.
84 pub fn all(url: impl Into<String>) -> Self {
85 Self::new(ProxyScope::All, url)
86 }
87
88 /// A rule routing `http://` requests through `url`.
89 pub fn http(url: impl Into<String>) -> Self {
90 Self::new(ProxyScope::Http, url)
91 }
92
93 /// A rule routing `https://` requests through `url`.
94 pub fn https(url: impl Into<String>) -> Self {
95 Self::new(ProxyScope::Https, url)
96 }
97
98 /// A rule with an explicit scope, no credentials, and no bypass list.
99 pub fn new(scope: ProxyScope, url: impl Into<String>) -> Self {
100 Self {
101 scope,
102 url: url.into(),
103 auth: None,
104 no_proxy: None,
105 }
106 }
107
108 /// Present `username` / `password` to the proxy via `Proxy-Authorization: Basic`.
109 #[must_use]
110 pub fn with_basic_auth(
111 mut self,
112 username: impl Into<String>,
113 password: impl Into<String>,
114 ) -> Self {
115 self.auth = Some(ProxyAuth::Basic {
116 username: username.into(),
117 password: password.into(),
118 });
119 self
120 }
121
122 /// Present a verbatim `Proxy-Authorization` header value, e.g. `"Bearer <token>"`.
123 #[must_use]
124 pub fn with_custom_auth(mut self, header_value: impl Into<String>) -> Self {
125 self.auth = Some(ProxyAuth::Custom(header_value.into()));
126 self
127 }
128
129 /// Exempt hosts from this proxy, in `NO_PROXY` syntax — see [`ProxyRule::no_proxy`].
130 #[must_use]
131 pub fn bypassing(mut self, no_proxy: impl Into<String>) -> Self {
132 self.no_proxy = Some(no_proxy.into());
133 self
134 }
135
136 /// Translate into a `reqwest::Proxy`, failing on an unusable proxy URL or auth header.
137 fn to_reqwest(&self) -> anyhow::Result<reqwest::Proxy> {
138 // reqwest accepts a socks URL whether or not its own `socks` feature is on, and without
139 // it quietly falls back to speaking HTTP at the socks port — a connect-time failure with
140 // nothing pointing at the cause. Reject it here, while there is still a URL to name.
141 #[cfg(not(feature = "socks"))]
142 {
143 let scheme = self
144 .url
145 .split("://")
146 .next()
147 .unwrap_or_default()
148 .to_ascii_lowercase();
149 anyhow::ensure!(
150 !matches!(scheme.as_str(), "socks4" | "socks4a" | "socks5" | "socks5h"),
151 "proxy URL {:?} needs the `socks` cargo feature",
152 self.url
153 );
154 }
155
156 let mut proxy = match self.scope {
157 ProxyScope::Http => reqwest::Proxy::http(&self.url),
158 ProxyScope::Https => reqwest::Proxy::https(&self.url),
159 ProxyScope::All => reqwest::Proxy::all(&self.url),
160 }
161 .with_context(|| format!("unusable proxy URL {:?}", self.url))?;
162
163 match self.auth {
164 Some(ProxyAuth::Basic {
165 ref username,
166 ref password,
167 }) => proxy = proxy.basic_auth(username, password),
168 Some(ProxyAuth::Custom(ref value)) => {
169 let header = value.parse().with_context(|| {
170 format!("proxy {:?}: invalid Proxy-Authorization value", self.url)
171 })?;
172 proxy = proxy.custom_http_auth(header);
173 }
174 None => {}
175 }
176
177 if let Some(ref list) = self.no_proxy {
178 proxy = proxy.no_proxy(reqwest::NoProxy::from_string(list));
179 }
180
181 Ok(proxy)
182 }
183}
184
185/// How the fetcher chooses a proxy for outgoing requests.
186#[derive(Debug, Clone, Eq, PartialEq, Default)]
187pub enum ProxyConfig {
188 /// Take the proxy from the environment: `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and the
189 /// `NO_PROXY` bypass list (lowercase spellings included). The default, and what the fetcher
190 /// did before proxies were configurable.
191 #[default]
192 System,
193 /// Send every request directly, ignoring the environment variables.
194 Disabled,
195 /// Use exactly these rules and nothing from the environment. Each request URL is matched
196 /// against the rules in order and takes the first whose scope matches and whose bypass list
197 /// does not exempt it; an empty list therefore behaves like [`ProxyConfig::Disabled`].
198 Rules(Vec<ProxyRule>),
199}
200
201impl ProxyConfig {
202 /// A single proxy for all schemes, with no bypass list — the common case.
203 pub fn single(url: impl Into<String>) -> Self {
204 ProxyConfig::Rules(vec![ProxyRule::all(url)])
205 }
206
207 /// Apply this configuration to a client builder.
208 ///
209 /// [`ProxyConfig::System`] leaves the builder untouched, since reading the environment is
210 /// reqwest's own default; the other variants clear it first so no environment proxy leaks in.
211 pub(crate) fn apply(
212 &self,
213 builder: reqwest::ClientBuilder,
214 ) -> anyhow::Result<reqwest::ClientBuilder> {
215 match self {
216 ProxyConfig::System => Ok(builder),
217 ProxyConfig::Disabled => Ok(builder.no_proxy()),
218 ProxyConfig::Rules(rules) => {
219 // `no_proxy()` first: with an empty `rules` nothing else would switch the
220 // environment lookup off, and a caller that spelled out its rules never wants
221 // `HTTP_PROXY` silently appended to them.
222 let mut builder = builder.no_proxy();
223 for rule in rules {
224 builder = builder.proxy(rule.to_reqwest()?);
225 }
226 Ok(builder)
227 }
228 }
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn builders_set_scope_and_extras() {
238 let rule = ProxyRule::https("http://p:8080")
239 .with_basic_auth("u", "p")
240 .bypassing("localhost");
241 assert_eq!(rule.scope, ProxyScope::Https);
242 assert_eq!(rule.url, "http://p:8080");
243 assert_eq!(
244 rule.auth,
245 Some(ProxyAuth::Basic {
246 username: "u".into(),
247 password: "p".into()
248 })
249 );
250 assert_eq!(rule.no_proxy.as_deref(), Some("localhost"));
251
252 assert_eq!(ProxyRule::http("http://p:8080").scope, ProxyScope::Http);
253 assert_eq!(ProxyRule::all("http://p:8080").scope, ProxyScope::All);
254 assert!(ProxyRule::all("http://p:8080").auth.is_none());
255 }
256
257 #[test]
258 fn default_is_system() {
259 assert_eq!(ProxyConfig::default(), ProxyConfig::System);
260 }
261
262 #[test]
263 fn single_is_an_all_scheme_rule() {
264 assert_eq!(
265 ProxyConfig::single("http://p:8080"),
266 ProxyConfig::Rules(vec![ProxyRule::all("http://p:8080")])
267 );
268 }
269
270 #[test]
271 fn valid_rules_build() {
272 for rule in [
273 ProxyRule::all("http://proxy.example:8080"),
274 ProxyRule::http("http://user:pass@proxy.example:8080"),
275 ProxyRule::https("https://proxy.example:8443").with_basic_auth("u", "p"),
276 ProxyRule::all("http://proxy.example:8080").with_custom_auth("Bearer token"),
277 ProxyRule::all("http://proxy.example:8080").bypassing("localhost, 10.0.0.0/8"),
278 ] {
279 assert!(rule.to_reqwest().is_ok(), "should build: {rule:?}");
280 }
281 }
282
283 /// The `socks` feature is the only thing standing between a `socks5://` URL and a working
284 /// proxy, so assert both directions — the docs promise exactly this trade.
285 #[test]
286 fn socks_urls_need_the_socks_feature() {
287 let built = ProxyRule::all("socks5://127.0.0.1:1080")
288 .to_reqwest()
289 .is_ok();
290 assert_eq!(built, cfg!(feature = "socks"));
291 }
292
293 #[test]
294 fn unusable_proxy_url_is_reported() {
295 let err = ProxyRule::all("not a url").to_reqwest().unwrap_err();
296 assert!(
297 err.to_string().contains("not a url"),
298 "error should name the offending URL, got: {err}"
299 );
300 }
301
302 #[test]
303 fn invalid_custom_auth_header_is_reported() {
304 let err = ProxyRule::all("http://proxy.example:8080")
305 .with_custom_auth("bad\nvalue")
306 .to_reqwest()
307 .unwrap_err();
308 assert!(
309 err.to_string().contains("Proxy-Authorization"),
310 "error should mention the header, got: {err}"
311 );
312 }
313
314 #[test]
315 fn apply_accepts_every_variant() {
316 for cfg in [
317 ProxyConfig::System,
318 ProxyConfig::Disabled,
319 ProxyConfig::Rules(vec![]),
320 ProxyConfig::single("http://proxy.example:8080"),
321 ] {
322 assert!(
323 cfg.apply(reqwest::Client::builder()).is_ok(),
324 "should apply: {cfg:?}"
325 );
326 }
327 }
328
329 #[test]
330 fn apply_propagates_rule_errors() {
331 let cfg = ProxyConfig::single("not a url");
332 assert!(cfg.apply(reqwest::Client::builder()).is_err());
333 }
334}