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