saluki_io/net/client/http/
client.rs1#[cfg(unix)]
2use std::path::PathBuf;
3use std::{
4 future::Future,
5 pin::Pin,
6 sync::Arc,
7 task::{Context, Poll},
8 time::Duration,
9};
10
11use bytes::{Buf, Bytes};
12use http::{Request, Response, Uri};
13use http_body::{Body, Frame, SizeHint};
14use http_body_util::combinators::BoxBody;
15use hyper::body::Incoming;
16use hyper_http_proxy::Proxy;
17use hyper_util::{
18 client::legacy::{connect::capture_connection, Builder},
19 rt::{TokioExecutor, TokioTimer},
20};
21use pin_project::pin_project;
22use rustls::ClientConfig;
23use saluki_error::GenericError;
24use saluki_metrics::MetricsBuilder;
25use saluki_tls::{
26 ensure_client_config_fips_compliant, load_platform_root_certificates_inner, ClientTLSConfigBuilder,
27 TlsMinimumVersion,
28};
29use stringtheory::MetaString;
30use tokio_rustls::TlsConnector;
31use tower::{timeout::TimeoutLayer, util::BoxCloneService, BoxError, Service, ServiceBuilder, ServiceExt as _};
32
33use super::{
34 conn::{check_connection_state, HttpProtocol, HttpsCapableConnectorBuilder},
35 telemetry::HttpTransactionErrorTelemetry,
36 EndpointTelemetryLayer,
37};
38
39pub type ClientBody = BoxBody<Bytes, Box<dyn std::error::Error + Send + Sync>>;
44
45#[pin_project]
46struct ClientBodyAdapter<B> {
47 #[pin]
48 inner: B,
49 size_hint: SizeHint,
50}
51
52impl<B> Body for ClientBodyAdapter<B>
53where
54 B: Body,
55 B::Data: Buf,
56 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
57{
58 type Data = Bytes;
59 type Error = Box<dyn std::error::Error + Send + Sync>;
60
61 fn poll_frame(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
62 self.project().inner.poll_frame(cx).map(|maybe_frame| {
63 maybe_frame.map(|result| {
64 result
65 .map(|frame| frame.map_data(|mut data| data.copy_to_bytes(data.remaining())))
66 .map_err(Into::into)
67 })
68 })
69 }
70
71 fn size_hint(&self) -> SizeHint {
72 self.size_hint
73 }
74}
75
76#[derive(Clone)]
78pub struct HttpClient {
79 inner: BoxCloneService<Request<ClientBody>, Response<Incoming>, BoxError>,
80}
81
82impl HttpClient {
83 pub fn builder() -> HttpClientBuilder {
85 HttpClientBuilder::default()
86 }
87
88 pub async fn send<B>(&mut self, req: Request<B>) -> Result<Response<Incoming>, GenericError>
97 where
98 B: Body + Send + Sync + 'static,
99 B::Data: Buf + Send,
100 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
101 {
102 let mut req = req.map(into_client_body);
103 let captured_conn = capture_connection(&mut req);
104 let result = self
105 .inner
106 .ready()
107 .await
108 .map_err(GenericError::from_boxed)?
109 .call(req)
110 .await;
111
112 check_connection_state(captured_conn);
113
114 result.map_err(GenericError::from_boxed)
115 }
116}
117
118impl Service<Request<ClientBody>> for HttpClient {
119 type Response = Response<Incoming>;
120 type Error = BoxError;
121 type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
122
123 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
124 self.inner.poll_ready(cx)
125 }
126
127 fn call(&mut self, mut req: Request<ClientBody>) -> Self::Future {
128 let captured_conn = capture_connection(&mut req);
129 let fut = self.inner.call(req);
130
131 Box::pin(async move {
132 let result = fut.await;
133
134 check_connection_state(captured_conn);
135
136 result
137 })
138 }
139}
140
141pub fn into_client_body<B>(body: B) -> ClientBody
146where
147 B: Body + Send + Sync + 'static,
148 B::Data: Buf + Send,
149 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
150{
151 let size_hint = body.size_hint();
152 BoxBody::new(ClientBodyAdapter { inner: body, size_hint })
153}
154
155pub struct HttpClientBuilder {
170 connector_builder: HttpsCapableConnectorBuilder,
171 hyper_builder: Builder,
172 tls_builder: ClientTLSConfigBuilder,
173 client_tls_config: Option<ClientConfig>,
174 request_timeout: Option<Duration>,
175 endpoint_telemetry: Option<EndpointTelemetryLayer>,
176 proxies: Option<Vec<Proxy>>,
177}
178
179impl HttpClientBuilder {
180 pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
184 self.connector_builder = self.connector_builder.with_connect_timeout(timeout);
185 self
186 }
187
188 pub fn with_tls_handshake_timeout(mut self, timeout: Duration) -> Self {
195 self.connector_builder = self.connector_builder.with_tls_handshake_timeout(timeout);
196 self
197 }
198
199 pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
206 self.request_timeout = Some(timeout);
207 self
208 }
209
210 pub fn without_request_timeout(mut self) -> Self {
215 self.request_timeout = None;
216 self
217 }
218
219 pub fn with_http_protocol(mut self, protocol: HttpProtocol) -> Self {
223 self.connector_builder = self.connector_builder.with_http_protocol(protocol);
224 self
225 }
226
227 pub fn with_connection_age_limit<L>(mut self, limit: L) -> Self
234 where
235 L: Into<Option<Duration>>,
236 {
237 self.connector_builder = self.connector_builder.with_connection_age_limit(limit);
238 self
239 }
240
241 pub fn with_max_idle_conns_per_host(mut self, max: usize) -> Self {
245 self.hyper_builder.pool_max_idle_per_host(max);
246 self
247 }
248
249 pub fn with_idle_conn_timeout(mut self, timeout: Duration) -> Self {
256 self.hyper_builder.pool_idle_timeout(timeout);
257 self
258 }
259
260 pub fn with_proxies(mut self, proxies: Vec<Proxy>) -> Self {
264 self.proxies = Some(proxies);
265 self
266 }
267
268 pub fn with_endpoint_telemetry<F>(mut self, metrics_builder: MetricsBuilder, endpoint_name_fn: Option<F>) -> Self
272 where
273 F: Fn(&Uri) -> Option<MetaString> + Send + Sync + 'static,
274 {
275 let error_telemetry = HttpTransactionErrorTelemetry::from_builder(&metrics_builder);
276 self.connector_builder = self.connector_builder.with_error_telemetry(error_telemetry.clone());
277
278 let mut layer = EndpointTelemetryLayer::default()
279 .with_metrics_builder(metrics_builder)
280 .with_error_telemetry(error_telemetry);
281
282 if let Some(endpoint_name_fn) = endpoint_name_fn {
283 layer = layer.with_endpoint_name_fn(endpoint_name_fn);
284 }
285
286 self.endpoint_telemetry = Some(layer);
287 self
288 }
289
290 #[cfg(unix)]
298 pub fn with_unix_socket_path<P: Into<PathBuf>>(mut self, path: P) -> Self {
299 self.connector_builder = self.connector_builder.with_unix_socket_path(path);
300 self
301 }
302
303 pub fn with_tls_config<F>(mut self, f: F) -> Self
308 where
309 F: FnOnce(ClientTLSConfigBuilder) -> ClientTLSConfigBuilder,
310 {
311 self.tls_builder = f(self.tls_builder);
312 self
313 }
314
315 pub fn with_client_tls_config(mut self, config: ClientConfig) -> Self {
328 self.client_tls_config = Some(config);
329 self
330 }
331
332 pub fn with_min_tls_version(mut self, version: TlsMinimumVersion) -> Self {
340 self.tls_builder = self.tls_builder.with_min_tls_version(version);
341 self
342 }
343
344 pub fn with_hyper_config<F>(mut self, f: F) -> Self
349 where
350 F: FnOnce(&mut Builder),
351 {
352 f(&mut self.hyper_builder);
353 self
354 }
355
356 pub fn build(self) -> Result<HttpClient, GenericError> {
363 let tls_config = match self.client_tls_config {
364 Some(mut config) => {
365 ensure_client_config_fips_compliant(&config)?;
366 config.alpn_protocols.clear();
367 config
368 }
369 None => self.tls_builder.build()?,
370 };
371 let connector = self.connector_builder.build(tls_config)?;
372 let tls_handshake_timeout = connector.tls_handshake_timeout();
373 let proxy_tls_config = ClientTLSConfigBuilder::new()
376 .with_root_cert_store(load_platform_root_certificates_inner()?)
377 .build()?;
378 let mut proxy_connector = hyper_http_proxy::ProxyConnector::unsecured(connector);
379 proxy_connector.set_tls(Some(TlsConnector::from(Arc::new(proxy_tls_config))));
380 let proxy_tls_handshake_timeout = (!tls_handshake_timeout.is_zero()).then_some(tls_handshake_timeout);
383 proxy_connector.set_tls_handshake_timeout(proxy_tls_handshake_timeout);
384 if let Some(proxies) = &self.proxies {
385 for proxy in proxies {
386 proxy_connector.add_proxy(proxy.to_owned());
387 }
388 }
389 let client = self.hyper_builder.build(proxy_connector);
390
391 let inner = ServiceBuilder::new()
392 .option_layer(self.request_timeout.map(TimeoutLayer::new))
393 .option_layer(self.endpoint_telemetry)
394 .service(client.map_err(BoxError::from))
395 .boxed_clone();
396
397 Ok(HttpClient { inner })
398 }
399}
400
401impl Default for HttpClientBuilder {
402 fn default() -> Self {
403 let mut hyper_builder = Builder::new(TokioExecutor::new());
404 hyper_builder
405 .pool_timer(TokioTimer::new())
406 .pool_max_idle_per_host(5)
407 .pool_idle_timeout(Duration::from_secs(45));
408
409 Self {
410 connector_builder: HttpsCapableConnectorBuilder::default(),
411 hyper_builder,
412 tls_builder: ClientTLSConfigBuilder::new(),
413 client_tls_config: None,
414 request_timeout: Some(Duration::from_secs(20)),
415 endpoint_telemetry: None,
416 proxies: None,
417 }
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use std::{sync::Arc, time::Duration};
424
425 use http_body::Body as _;
426 use http_body_util::{Empty, Full};
427 use rustls::{server::WebPkiClientVerifier, ClientConfig, RootCertStore, ServerConfig};
428 use saluki_tls::test_util::SelfSignedCert;
429 use tokio::{
430 io::{AsyncReadExt as _, AsyncWriteExt as _},
431 net::TcpListener,
432 time::timeout,
433 };
434 use tokio_rustls::TlsAcceptor;
435
436 use super::*;
437
438 #[test]
439 fn into_client_body_preserves_exact_size_hint() {
440 let body = Full::new(Bytes::from_static(b"hello"));
441 let converted = into_client_body(body);
442
443 assert_eq!(Some(5), converted.size_hint().exact());
444 }
445
446 fn initialize_crypto_provider() {
447 let _ = saluki_tls::initialize_default_crypto_provider();
448 assert!(
449 rustls::crypto::CryptoProvider::get_default().is_some(),
450 "default crypto provider should be installed"
451 );
452 }
453
454 #[tokio::test]
455 async fn complete_tls_config_preserves_mtls_and_overrides_option_settings() {
456 initialize_crypto_provider();
457 let (server_config, client_config) = mutual_tls_configs();
458 let builder = HttpClient::builder()
459 .with_client_tls_config(client_config)
460 .with_tls_config(|builder| builder.with_root_cert_store(RootCertStore::empty()));
461
462 let _ = send_request_to_tls_server(builder, server_config)
463 .await
464 .expect("complete config should verify the server and present the client identity");
465 }
466
467 #[tokio::test]
468 async fn complete_tls_config_alpn_is_normalized_for_http1() {
469 initialize_crypto_provider();
470 let (mut server_config, mut client_config) = mutual_tls_configs();
471 server_config.alpn_protocols = vec![b"custom".to_vec(), b"http/1.1".to_vec()];
472 client_config.alpn_protocols = vec![b"custom".to_vec()];
473 let builder = HttpClient::builder().with_client_tls_config(client_config);
474
475 let negotiated_alpn = send_request_to_tls_server(builder, server_config)
476 .await
477 .expect("client should normalize ALPN and complete an HTTP/1.1 request");
478
479 assert_eq!(negotiated_alpn, None);
480 }
481
482 #[tokio::test]
483 async fn tls_handshake_timeout_fires_against_a_stalled_server() {
484 initialize_crypto_provider();
485 let server_cert = SelfSignedCert::localhost();
486
487 let listener = TcpListener::bind("127.0.0.1:0").await.expect("should bind listener");
488 let port = listener.local_addr().expect("should have local address").port();
489 let server_task = tokio::spawn(async move {
490 let (stream, _) = listener.accept().await.expect("server should accept a connection");
491 tokio::time::sleep(Duration::from_secs(30)).await;
494 drop(stream);
495 });
496
497 let mut client = HttpClient::builder()
498 .with_tls_config(|builder| builder.with_root_cert_store(root_store(&server_cert)))
499 .with_tls_handshake_timeout(Duration::from_millis(200))
500 .with_http_protocol(HttpProtocol::Http1)
501 .build()
502 .expect("client should build");
503 let request = Request::get(format!("https://localhost:{port}/"))
504 .body(Empty::<Bytes>::new())
505 .expect("request should build");
506
507 let error = timeout(Duration::from_secs(5), client.send(request))
508 .await
509 .expect("request should not hit the outer test timeout")
510 .expect_err("handshake should time out before completing");
511 assert!(
512 format!("{error:#}").contains("TLS handshake timed out"),
513 "expected a TLS handshake timeout, got: {error:#}"
514 );
515
516 server_task.abort();
517 }
518
519 #[tokio::test]
520 async fn tls_handshake_timeout_fires_against_a_stalled_proxy_tunnel() {
521 initialize_crypto_provider();
522
523 let listener = TcpListener::bind("127.0.0.1:0").await.expect("should bind listener");
524 let proxy_addr = listener.local_addr().expect("should have local address");
525 let proxy_task = tokio::spawn(async move {
526 let (mut stream, _) = listener.accept().await.expect("proxy should accept a connection");
527 let mut request = [0; 4096];
528 let bytes_read = stream
529 .read(&mut request)
530 .await
531 .expect("proxy should read the CONNECT request");
532 assert!(bytes_read > 0, "proxy should receive a CONNECT request");
533 stream
534 .write_all(b"HTTP/1.1 200 OK\r\n\r\n")
535 .await
536 .expect("proxy should write the CONNECT response");
537 tokio::time::sleep(Duration::from_secs(30)).await;
540 drop(stream);
541 });
542
543 let proxy_uri: Uri = format!("http://{proxy_addr}").parse().expect("proxy URI should parse");
544 let mut client = HttpClient::builder()
545 .with_proxies(vec![Proxy::new(hyper_http_proxy::Intercept::All, proxy_uri)])
546 .with_tls_config(|builder| builder.with_root_cert_store(RootCertStore::empty()))
547 .with_tls_handshake_timeout(Duration::from_millis(200))
548 .with_http_protocol(HttpProtocol::Http1)
549 .build()
550 .expect("client should build");
551 let request = Request::get("https://example.invalid/")
552 .body(Empty::<Bytes>::new())
553 .expect("request should build");
554
555 let error = timeout(Duration::from_secs(5), client.send(request))
556 .await
557 .expect("request should not hit the outer test timeout")
558 .expect_err("handshake should time out before completing");
559 assert!(
560 format!("{error:#}").contains("TLS handshake timed out"),
561 "expected a TLS handshake timeout, got: {error:#}"
562 );
563
564 proxy_task.abort();
565 }
566
567 #[tokio::test]
568 async fn zero_tls_handshake_timeout_disables_the_proxy_tunnel_deadline() {
569 initialize_crypto_provider();
570
571 let listener = TcpListener::bind("127.0.0.1:0").await.expect("should bind listener");
572 let proxy_addr = listener.local_addr().expect("should have local address");
573 let proxy_task = tokio::spawn(async move {
574 let (mut stream, _) = listener.accept().await.expect("proxy should accept a connection");
575 let mut request = [0; 4096];
576 let bytes_read = stream
577 .read(&mut request)
578 .await
579 .expect("proxy should read the CONNECT request");
580 assert!(bytes_read > 0, "proxy should receive a CONNECT request");
581 stream
582 .write_all(b"HTTP/1.1 200 OK\r\n\r\n")
583 .await
584 .expect("proxy should write the CONNECT response");
585 tokio::time::sleep(Duration::from_secs(30)).await;
588 drop(stream);
589 });
590
591 let proxy_uri: Uri = format!("http://{proxy_addr}").parse().expect("proxy URI should parse");
592 let mut client = HttpClient::builder()
593 .with_proxies(vec![Proxy::new(hyper_http_proxy::Intercept::All, proxy_uri)])
594 .with_tls_config(|builder| builder.with_root_cert_store(RootCertStore::empty()))
595 .with_tls_handshake_timeout(Duration::ZERO)
596 .with_http_protocol(HttpProtocol::Http1)
597 .build()
598 .expect("client should build");
599 let request = Request::get("https://example.invalid/")
600 .body(Empty::<Bytes>::new())
601 .expect("request should build");
602
603 let result = timeout(Duration::from_millis(300), client.send(request)).await;
606 assert!(
607 result.is_err(),
608 "a disabled timeout should not fail the proxy tunnel's TLS handshake, got: {result:?}"
609 );
610
611 proxy_task.abort();
612 }
613
614 fn mutual_tls_configs() -> (ServerConfig, ClientConfig) {
615 let server_cert = SelfSignedCert::localhost();
616 let client_cert = SelfSignedCert::new(["saluki-client"]);
617 let client_verifier = WebPkiClientVerifier::builder(Arc::new(root_store(&client_cert)))
618 .build()
619 .expect("client certificate verifier should build");
620 let server_config = ServerConfig::builder()
621 .with_client_cert_verifier(client_verifier)
622 .with_single_cert(server_cert.cert_chain(), server_cert.private_key())
623 .expect("server TLS config should build");
624 let client_config = ClientConfig::builder()
625 .with_root_certificates(root_store(&server_cert))
626 .with_client_auth_cert(client_cert.cert_chain(), client_cert.private_key())
627 .expect("client TLS config should build");
628
629 (server_config, client_config)
630 }
631
632 fn root_store(cert: &SelfSignedCert) -> RootCertStore {
633 let mut root_store = RootCertStore::empty();
634 root_store
635 .add(cert.cert_chain().pop().expect("certificate chain should not be empty"))
636 .expect("self-signed certificate should be a valid trust anchor");
637 root_store
638 }
639
640 async fn send_request_to_tls_server(
641 builder: HttpClientBuilder, server_config: ServerConfig,
642 ) -> Result<Option<Vec<u8>>, GenericError> {
643 let listener = TcpListener::bind("127.0.0.1:0").await?;
644 let port = listener.local_addr()?.port();
645 let server_task = tokio::spawn(async move {
646 let (stream, _) = listener.accept().await.expect("server should accept a connection");
647 let mut stream = TlsAcceptor::from(Arc::new(server_config))
648 .accept(stream)
649 .await
650 .expect("TLS handshake should succeed");
651 let negotiated_alpn = stream.get_ref().1.alpn_protocol().map(ToOwned::to_owned);
652 let mut request = [0; 4096];
653 let bytes_read = stream.read(&mut request).await.expect("server should read the request");
654 assert!(bytes_read > 0, "server should receive an HTTP request");
655 stream
656 .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
657 .await
658 .expect("server should write the response");
659 negotiated_alpn
660 });
661
662 let mut client = builder.with_http_protocol(HttpProtocol::Http1).build()?;
663 let request = Request::get(format!("https://localhost:{port}/"))
664 .body(Empty::<Bytes>::new())
665 .expect("request should build");
666 let response = timeout(Duration::from_secs(5), client.send(request))
667 .await
668 .map_err(|_| GenericError::msg("HTTP request timed out"))??;
669 assert_eq!(response.status(), http::StatusCode::OK);
670 Ok(timeout(Duration::from_secs(5), server_task)
671 .await
672 .expect("TLS server should finish")
673 .expect("TLS server task should not panic"))
674 }
675}