pipedash_core/infrastructure/
http_client.rs1use std::net::{
2 IpAddr,
3 Ipv4Addr,
4 Ipv6Addr,
5};
6use std::sync::{
7 Arc,
8 OnceLock,
9 PoisonError,
10 RwLock,
11};
12use std::time::Duration;
13
14use dashmap::DashMap;
15use pipedash_plugin_api::ClientOptions;
16use reqwest::{
17 redirect,
18 Client,
19 ClientBuilder,
20 NoProxy,
21 Proxy,
22 Url,
23};
24
25use crate::domain::{
26 DomainError,
27 DomainResult,
28};
29
30const USER_AGENT: &str = concat!("pipedash/", env!("CARGO_PKG_VERSION"));
31const STATUS_TIMEOUT: Duration = Duration::from_secs(10);
32const STATUS_MAX_REDIRECTS: usize = 3;
33
34type ClientKey = (Option<String>, bool);
35
36pub struct HttpClientManager {
37 clients: RwLock<Arc<Clients>>,
38}
39
40struct Clients {
41 proxy: Option<Url>,
42 default_client: Arc<Client>,
43 by_origin: DashMap<ClientKey, Arc<Client>>,
44 status_client: OnceLock<Arc<Client>>,
45}
46
47impl Clients {
48 fn new(proxy: Option<Url>) -> DomainResult<Self> {
49 let default_client = HttpClientManager::build_client(false, proxy.as_ref())?;
50 Ok(Self {
51 proxy,
52 default_client: Arc::new(default_client),
53 by_origin: DashMap::new(),
54 status_client: OnceLock::new(),
55 })
56 }
57}
58
59impl HttpClientManager {
60 pub fn new() -> DomainResult<Self> {
61 Ok(Self {
62 clients: RwLock::new(Arc::new(Clients::new(None)?)),
63 })
64 }
65
66 pub fn set_proxy(&self, proxy_url: &str) -> DomainResult<bool> {
67 let proxy = parse_proxy_url(proxy_url)?;
68 if self.current().proxy == proxy {
69 return Ok(false);
70 }
71
72 let clients = Arc::new(Clients::new(proxy)?);
73 *self.clients.write().unwrap_or_else(PoisonError::into_inner) = clients;
74 Ok(true)
75 }
76
77 fn current(&self) -> Arc<Clients> {
78 Arc::clone(&self.clients.read().unwrap_or_else(PoisonError::into_inner))
79 }
80
81 pub fn client_for(&self, options: &ClientOptions) -> DomainResult<Arc<Client>> {
82 let clients = self.current();
83 if *options == ClientOptions::default() {
84 return Ok(Arc::clone(&clients.default_client));
85 }
86
87 let key = (
88 options.origin.as_ref().map(normalize_origin),
89 options.accept_invalid_certs,
90 );
91 let entry = clients.by_origin.entry(key).or_try_insert_with(|| {
92 Self::build_client(options.accept_invalid_certs, clients.proxy.as_ref()).map(Arc::new)
93 })?;
94 Ok(Arc::clone(entry.value()))
95 }
96
97 pub fn status_client(&self) -> DomainResult<Arc<Client>> {
98 let clients = self.current();
99 if let Some(client) = clients.status_client.get() {
100 return Ok(Arc::clone(client));
101 }
102 let client = Arc::new(Self::build_status_client(clients.proxy.as_ref())?);
103 Ok(Arc::clone(clients.status_client.get_or_init(|| client)))
104 }
105
106 fn base_builder(proxy: Option<&Url>) -> DomainResult<ClientBuilder> {
107 let _ = rustls::crypto::ring::default_provider().install_default();
108 let builder = Client::builder()
109 .tls_backend_rustls()
110 .https_only(cfg!(target_os = "ios"))
111 .user_agent(USER_AGENT);
112 let Some(proxy) = proxy else {
113 return Ok(builder);
114 };
115
116 let proxy = Proxy::all(proxy.clone())
117 .map_err(|e| DomainError::InvalidConfig(format!("Failed to use the proxy URL: {e}")))?
118 .no_proxy(NoProxy::from_env());
119 Ok(builder.proxy(proxy))
120 }
121
122 fn build_client(accept_invalid_certs: bool, proxy: Option<&Url>) -> DomainResult<Client> {
123 let pool_size = std::env::var("PIPEDASH_HTTP_POOL_SIZE")
124 .ok()
125 .and_then(|s| s.parse().ok())
126 .unwrap_or(10);
127
128 Self::base_builder(proxy)?
129 .tls_danger_accept_invalid_certs(accept_invalid_certs)
130 .pool_max_idle_per_host(pool_size)
131 .pool_idle_timeout(Duration::from_secs(90))
132 .tcp_keepalive(Duration::from_secs(60))
133 .timeout(Duration::from_secs(30))
134 .connect_timeout(Duration::from_secs(10))
135 .build()
136 .map_err(|e| DomainError::InternalError(format!("Failed to create HTTP client: {}", e)))
137 }
138
139 fn build_status_client(proxy: Option<&Url>) -> DomainResult<Client> {
140 let policy = redirect::Policy::custom(|attempt| {
141 if attempt.previous().len() > STATUS_MAX_REDIRECTS {
142 attempt.error("too many redirects")
143 } else if is_safe_redirect_target(attempt.url()) {
144 attempt.follow()
145 } else {
146 attempt.error("redirect target refused")
147 }
148 });
149
150 Self::base_builder(proxy)?
151 .redirect(policy)
152 .timeout(STATUS_TIMEOUT)
153 .connect_timeout(STATUS_TIMEOUT)
154 .build()
155 .map_err(|e| {
156 DomainError::InternalError(format!("Failed to create status HTTP client: {}", e))
157 })
158 }
159}
160
161pub fn parse_proxy_url(value: &str) -> DomainResult<Option<Url>> {
162 let value = value.trim();
163 if value.is_empty() {
164 return Ok(None);
165 }
166
167 let url = Url::parse(value)
168 .ok()
169 .filter(|url| matches!(url.scheme(), "http" | "https") && url.host_str().is_some())
170 .ok_or_else(|| {
171 DomainError::InvalidConfig(
172 "Proxy URL must look like http://proxy.example.com:3128 or https://...".to_string(),
173 )
174 })?;
175 if !url.username().is_empty() || url.password().is_some() {
176 return Err(DomainError::InvalidConfig(
177 "Proxy URL can't contain a username or password. Pipedash supports proxies without \
178 authentication only."
179 .to_string(),
180 ));
181 }
182
183 Ok(Some(url))
184}
185
186fn normalize_origin(url: &Url) -> String {
187 format!(
188 "{}://{}:{}",
189 url.scheme().to_ascii_lowercase(),
190 url.host_str().unwrap_or_default().to_ascii_lowercase(),
191 url.port_or_known_default().unwrap_or_default()
192 )
193}
194
195fn is_safe_redirect_target(url: &Url) -> bool {
196 if url.scheme() != "https" {
197 return false;
198 }
199 let Some(host) = url.host_str() else {
200 return false;
201 };
202 match host
203 .trim_start_matches('[')
204 .trim_end_matches(']')
205 .parse::<IpAddr>()
206 {
207 Ok(ip) => !is_internal_ip(ip),
208 Err(_) => true,
209 }
210}
211
212fn is_internal_ip(ip: IpAddr) -> bool {
213 match ip {
214 IpAddr::V4(v4) => is_internal_v4(v4),
215 IpAddr::V6(v6) => v6
216 .to_ipv4_mapped()
217 .map_or_else(|| is_internal_v6(v6), is_internal_v4),
218 }
219}
220
221fn is_internal_v4(ip: Ipv4Addr) -> bool {
222 ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_unspecified()
223}
224
225fn is_internal_v6(ip: Ipv6Addr) -> bool {
226 ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local() || ip.is_unspecified()
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 fn options(origin: &str, accept_invalid_certs: bool) -> ClientOptions {
234 ClientOptions {
235 origin: Some(Url::parse(origin).unwrap()),
236 accept_invalid_certs,
237 }
238 }
239
240 static HTTPS_CERTIFICATE: std::sync::LazyLock<rcgen::CertifiedKey<rcgen::KeyPair>> =
241 std::sync::LazyLock::new(|| {
242 let now = std::time::SystemTime::now();
243 let mut params =
244 rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()])
245 .unwrap();
246 params.not_before = (now - Duration::from_secs(60)).into();
247 params.not_after = (now + Duration::from_secs(86400)).into();
248 params.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature];
249 params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth];
250 let signing_key = rcgen::KeyPair::generate().unwrap();
251 let cert = params.self_signed(&signing_key).unwrap();
252 rcgen::CertifiedKey { cert, signing_key }
253 });
254
255 fn https_only_client() -> Client {
256 HttpClientManager::base_builder(None)
257 .unwrap()
258 .https_only(true)
259 .no_proxy()
260 .add_root_certificate(
261 reqwest::Certificate::from_der(HTTPS_CERTIFICATE.cert.der()).unwrap(),
262 )
263 .timeout(Duration::from_secs(2))
264 .build()
265 .unwrap()
266 }
267
268 async fn tls_stub(response: String) -> (String, tokio::task::JoinHandle<String>) {
269 use std::io::{
270 Read,
271 Write,
272 };
273
274 let _ = rustls::crypto::ring::default_provider().install_default();
275 let certificate = &*HTTPS_CERTIFICATE;
276 let cert = certificate.cert.der().clone();
277 let key =
278 rustls::pki_types::PrivateKeyDer::Pkcs8(certificate.signing_key.serialize_der().into());
279 let config = Arc::new(
280 rustls::ServerConfig::builder()
281 .with_no_client_auth()
282 .with_single_cert(vec![cert], key)
283 .unwrap(),
284 );
285 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
286 let url = format!(
287 "https://127.0.0.1:{}",
288 listener.local_addr().unwrap().port()
289 );
290 let server = tokio::spawn(async move {
291 let (stream, _) = tokio::time::timeout(Duration::from_secs(2), listener.accept())
292 .await
293 .unwrap()
294 .unwrap();
295 let stream = stream.into_std().unwrap();
296 stream.set_nonblocking(false).unwrap();
297 stream
298 .set_read_timeout(Some(Duration::from_secs(2)))
299 .unwrap();
300 stream
301 .set_write_timeout(Some(Duration::from_secs(2)))
302 .unwrap();
303 tokio::task::spawn_blocking(move || {
304 let connection = rustls::ServerConnection::new(config).unwrap();
305 let mut stream = rustls::StreamOwned::new(connection, stream);
306 let mut request = Vec::new();
307 let mut buffer = [0; 4096];
308 while !request.windows(4).any(|part| part == b"\r\n\r\n") {
309 let count = stream.read(&mut buffer).unwrap();
310 assert!(count > 0 && request.len() + count <= 8192);
311 request.extend_from_slice(&buffer[..count]);
312 }
313 stream.write_all(response.as_bytes()).unwrap();
314 stream.flush().unwrap();
315 String::from_utf8(request).unwrap()
316 })
317 .await
318 .unwrap()
319 });
320 (url, server)
321 }
322
323 #[tokio::test]
324 async fn https_only_rejects_http_before_sending_credentials() {
325 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
326 let error = https_only_client()
327 .get(format!("http://{}", listener.local_addr().unwrap()))
328 .header("Authorization", "token fixture")
329 .send()
330 .await
331 .unwrap_err();
332
333 assert!(error.is_builder(), "{error}");
334 assert!(
335 tokio::time::timeout(Duration::from_millis(50), listener.accept())
336 .await
337 .is_err()
338 );
339 }
340
341 #[tokio::test]
342 async fn https_only_allows_https_with_credentials() {
343 let (url, server) = tls_stub(
344 "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok".to_string(),
345 )
346 .await;
347 let response = https_only_client()
348 .get(url)
349 .header("Authorization", "token fixture")
350 .send()
351 .await
352 .unwrap();
353
354 assert_eq!(response.text().await.unwrap(), "ok");
355 assert!(server
356 .await
357 .unwrap()
358 .to_ascii_lowercase()
359 .contains("authorization: token fixture"));
360 }
361
362 #[tokio::test]
363 async fn https_only_rejects_http_downgrade_redirect_before_traffic() {
364 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
365 let (url, server) = tls_stub(format!(
366 "HTTP/1.1 302 Found\r\nLocation: http://{}/token\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
367 listener.local_addr().unwrap()
368 ))
369 .await;
370 let error = https_only_client()
371 .get(url)
372 .header("Authorization", "token fixture")
373 .send()
374 .await
375 .unwrap_err();
376
377 assert!(error.is_redirect(), "{error}");
378 assert!(server
379 .await
380 .unwrap()
381 .to_ascii_lowercase()
382 .contains("authorization: token fixture"));
383 assert!(
384 tokio::time::timeout(Duration::from_millis(50), listener.accept())
385 .await
386 .is_err()
387 );
388 }
389
390 #[cfg(target_os = "ios")]
391 #[tokio::test]
392 async fn ios_shared_clients_reject_http_in_every_cache_and_proxy_configuration() {
393 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
394 let url = format!("http://{}", listener.local_addr().unwrap());
395 let manager = HttpClientManager::new().unwrap();
396 for proxy in ["", url.as_str()] {
397 manager.set_proxy(proxy).unwrap();
398 let clients = [
399 manager.client_for(&ClientOptions::default()).unwrap(),
400 manager.client_for(&options(&url, false)).unwrap(),
401 manager.client_for(&options(&url, true)).unwrap(),
402 manager.status_client().unwrap(),
403 ];
404 for client in clients {
405 let error = client
406 .get(&url)
407 .header("Authorization", "token fixture")
408 .send()
409 .await
410 .unwrap_err();
411 assert!(error.is_builder(), "{error}");
412 }
413 }
414 assert!(
415 tokio::time::timeout(Duration::from_millis(50), listener.accept())
416 .await
417 .is_err()
418 );
419 }
420
421 #[test]
422 fn default_options_share_the_default_client() {
423 let manager = HttpClientManager::new().unwrap();
424 let a = manager.client_for(&ClientOptions::default()).unwrap();
425 let b = manager.client_for(&ClientOptions::default()).unwrap();
426 assert!(Arc::ptr_eq(&a, &b));
427 }
428
429 #[test]
430 fn equivalent_origins_share_one_client() {
431 let manager = HttpClientManager::new().unwrap();
432 let a = manager.client_for(&options("https://h", false)).unwrap();
433 let b = manager
434 .client_for(&options("https://H:443/", false))
435 .unwrap();
436 assert!(Arc::ptr_eq(&a, &b));
437 assert!(!Arc::ptr_eq(&a, &manager.current().default_client));
438 }
439
440 #[test]
441 fn insecure_and_secure_clients_differ_for_same_origin() {
442 let manager = HttpClientManager::new().unwrap();
443 let secure = manager
444 .client_for(&options("https://argo.local", false))
445 .unwrap();
446 let insecure = manager
447 .client_for(&options("https://argo.local", true))
448 .unwrap();
449 assert!(!Arc::ptr_eq(&secure, &insecure));
450 }
451
452 #[test]
453 fn different_origins_get_different_clients() {
454 let manager = HttpClientManager::new().unwrap();
455 let a = manager
456 .client_for(&options("https://a.example", false))
457 .unwrap();
458 let b = manager
459 .client_for(&options("https://a.example:8443", false))
460 .unwrap();
461 assert!(!Arc::ptr_eq(&a, &b));
462 }
463
464 #[test]
465 fn status_client_is_cached_and_separate() {
466 let manager = HttpClientManager::new().unwrap();
467 let a = manager.status_client().unwrap();
468 let b = manager.status_client().unwrap();
469 assert!(Arc::ptr_eq(&a, &b));
470 let insecure = manager
471 .client_for(&options("https://a.example", true))
472 .unwrap();
473 assert!(!Arc::ptr_eq(&a, &insecure));
474 assert!(!Arc::ptr_eq(&a, &manager.current().default_client));
475 }
476
477 #[test]
478 fn redirect_targets_must_be_public_https() {
479 for allowed in [
480 "https://www.githubstatus.com/api/v2/summary.json",
481 "https://8.8.8.8/",
482 ] {
483 assert!(
484 is_safe_redirect_target(&Url::parse(allowed).unwrap()),
485 "{allowed}"
486 );
487 }
488 for refused in [
489 "http://www.githubstatus.com/",
490 "http://127.0.0.1:8765/",
491 "https://127.0.0.1/",
492 "https://10.1.2.3/",
493 "https://192.168.0.1/",
494 "https://169.254.169.254/",
495 "https://0.0.0.0/",
496 "https://[::1]/",
497 "https://[fe80::1]/",
498 "https://[fd00::1]/",
499 "https://[::ffff:127.0.0.1]/",
500 ] {
501 assert!(
502 !is_safe_redirect_target(&Url::parse(refused).unwrap()),
503 "{refused}"
504 );
505 }
506 }
507
508 #[cfg(not(target_os = "ios"))]
509 async fn proxy_stub() -> (String, Arc<std::sync::Mutex<Vec<String>>>) {
510 use tokio::io::{
511 AsyncReadExt,
512 AsyncWriteExt,
513 };
514
515 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
516 let url = format!("http://{}", listener.local_addr().unwrap());
517 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
518 let log = Arc::clone(&seen);
519 tokio::spawn(async move {
520 loop {
521 let (mut stream, _) = listener.accept().await.unwrap();
522 let mut buf = vec![0; 4096];
523 let n = stream.read(&mut buf).await.unwrap();
524 let head = String::from_utf8_lossy(&buf[..n]);
525 let line = head.lines().next().unwrap_or_default().to_string();
526 log.lock().unwrap().push(line);
527 let _ = stream
528 .write_all(
529 b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
530 )
531 .await;
532 }
533 });
534 (url, seen)
535 }
536
537 #[cfg(not(target_os = "ios"))]
538 #[tokio::test]
539 async fn every_client_goes_through_the_proxy_until_it_is_cleared() {
540 let (proxy, seen) = proxy_stub().await;
541 let manager = HttpClientManager::new().unwrap();
542
543 assert!(manager.set_proxy(&format!(" {proxy} ")).unwrap());
544 assert!(!manager.set_proxy(&proxy).unwrap());
545 let clients = [
546 manager.client_for(&ClientOptions::default()).unwrap(),
547 manager
548 .client_for(&options("http://argo.pipedash.test", true))
549 .unwrap(),
550 manager.status_client().unwrap(),
551 ];
552 for (i, client) in clients.iter().enumerate() {
553 let body = client
554 .get(format!("http://host{i}.pipedash.test/api"))
555 .send()
556 .await
557 .unwrap()
558 .text()
559 .await
560 .unwrap();
561 assert_eq!(body, "ok");
562 }
563
564 assert!(manager.set_proxy("").unwrap());
565 let direct = manager
566 .client_for(&ClientOptions::default())
567 .unwrap()
568 .get("http://127.0.0.1:9/never-proxied")
569 .send()
570 .await;
571
572 assert!(direct.is_err());
573 assert_eq!(
574 *seen.lock().unwrap(),
575 [
576 "GET http://host0.pipedash.test/api HTTP/1.1",
577 "GET http://host1.pipedash.test/api HTTP/1.1",
578 "GET http://host2.pipedash.test/api HTTP/1.1",
579 ]
580 );
581 }
582
583 #[test]
584 fn proxy_url_accepts_http_and_https_without_credentials() {
585 assert_eq!(parse_proxy_url(" ").unwrap(), None);
586 assert_eq!(
587 parse_proxy_url(" http://proxy.corp:3128 ")
588 .unwrap()
589 .unwrap()
590 .as_str(),
591 "http://proxy.corp:3128/"
592 );
593 assert!(parse_proxy_url("https://proxy.corp").unwrap().is_some());
594
595 for refused in [
596 "proxy.corp:3128",
597 "socks5://proxy.corp:1080",
598 "http://",
599 "not a url",
600 "http://alice@proxy.corp:3128",
601 "http://alice:secret@proxy.corp:3128",
602 ] {
603 let error = parse_proxy_url(refused).unwrap_err().to_string();
604 assert!(!error.contains("secret"), "{error}");
605 assert!(matches!(
606 parse_proxy_url(refused),
607 Err(DomainError::InvalidConfig(_))
608 ));
609 }
610 }
611}