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