Skip to main content

kube_client/client/
builder.rs

1use bytes::Bytes;
2use http::{Request, Response, header::HeaderMap};
3use hyper::{
4    body::Incoming,
5    rt::{Read, Write},
6};
7use hyper_timeout::TimeoutConnector;
8
9use hyper_util::{
10    client::legacy::connect::{Connection, HttpConnector},
11    rt::TokioExecutor,
12};
13
14use jiff::Timestamp;
15use std::{pin::Pin, time::Duration};
16use tower::{BoxError, Layer, Service, ServiceBuilder, ServiceExt as _, retry::RetryLayer, util::BoxService};
17use tower_http::{ServiceExt as _, classify::ServerErrorsFailureClass, trace::TraceLayer};
18use tracing::Span;
19
20use super::body::Body;
21use crate::{Client, Config, Error, Result, client::{ConfigExt, retry::RetryPolicy}};
22
23/// HTTP body of a dynamic backing type.
24///
25/// The suggested implementation type is [`crate::client::Body`].
26pub type DynBody = dyn http_body::Body<Data = Bytes, Error = BoxError> + Send + Unpin;
27
28/// Builder for [`Client`] instances with customized [tower](`Service`) middleware.
29pub struct ClientBuilder<Svc> {
30    service: Svc,
31    default_ns: String,
32    valid_until: Option<Timestamp>,
33}
34
35impl<Svc> ClientBuilder<Svc> {
36    /// Construct a [`ClientBuilder`] from scratch with a fully custom [`Service`] stack.
37    ///
38    /// This method is only intended for advanced use cases, most users will want to use [`ClientBuilder::try_from`] instead,
39    /// which provides a default stack as a starting point.
40    pub fn new(service: Svc, default_namespace: impl Into<String>) -> Self
41    where
42        Svc: Service<Request<Body>>,
43    {
44        Self {
45            service,
46            default_ns: default_namespace.into(),
47            valid_until: None,
48        }
49    }
50
51    /// Add a [`Layer`] to the current [`Service`] stack.
52    pub fn with_layer<L: Layer<Svc>>(self, layer: &L) -> ClientBuilder<L::Service> {
53        let Self {
54            service: stack,
55            default_ns,
56            valid_until,
57        } = self;
58        ClientBuilder {
59            service: layer.layer(stack),
60            default_ns,
61            valid_until,
62        }
63    }
64
65    /// Sets an expiration timestamp for the client.
66    pub fn with_valid_until(self, valid_until: Option<Timestamp>) -> Self {
67        ClientBuilder {
68            service: self.service,
69            default_ns: self.default_ns,
70            valid_until,
71        }
72    }
73
74    /// Build a [`Client`] instance with the current [`Service`] stack.
75    pub fn build<B>(self) -> Client
76    where
77        Svc: Service<Request<Body>, Response = Response<B>> + Send + 'static,
78        Svc::Future: Send + 'static,
79        Svc::Error: Into<BoxError>,
80        B: http_body::Body<Data = bytes::Bytes> + Send + 'static,
81        B::Error: Into<BoxError>,
82    {
83        Client::new(self.service, self.default_ns).with_valid_until(self.valid_until)
84    }
85}
86
87pub type GenericService = BoxService<Request<Body>, Response<Box<DynBody>>, BoxError>;
88
89/// Builds a `Basic` auth header value from a URI userinfo component.
90///
91/// `http::Uri` never decodes percent-escapes, so credentials containing
92/// characters that had to be percent-encoded in the proxy URL (e.g. `@`,
93/// `:`, `%`) are decoded here first — otherwise they'd reach the proxy
94/// still escaped, which any proxy that isn't also skipping decoding will
95/// reject as wrong credentials.
96#[cfg(feature = "http-proxy")]
97fn proxy_basic_auth_value(userinfo: &str) -> String {
98    use base64::Engine;
99
100    format!(
101        "Basic {}",
102        base64::engine::general_purpose::STANDARD
103            .encode(percent_encoding::percent_decode_str(userinfo).collect::<Vec<u8>>())
104    )
105}
106
107#[cfg(feature = "http-proxy")]
108fn with_proxy_basic_auth<C>(
109    proxy_url: &http::Uri,
110    mut connector: hyper_util::client::legacy::connect::proxy::Tunnel<C>,
111) -> hyper_util::client::legacy::connect::proxy::Tunnel<C> {
112    if let Some(authority) = proxy_url.authority()
113        && let Some((userinfo, _)) = authority.as_str().split_once('@')
114    {
115        use http::HeaderValue;
116
117        if let Ok(header) = HeaderValue::from_str(&proxy_basic_auth_value(userinfo)) {
118            connector = connector.with_auth(header);
119        }
120    }
121
122    connector
123}
124
125#[cfg(all(test, feature = "http-proxy"))]
126mod proxy_auth_tests {
127    use super::proxy_basic_auth_value;
128    use base64::Engine;
129
130    fn basic_auth(plain: &str) -> String {
131        format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(plain))
132    }
133
134    #[test]
135    fn decodes_percent_escapes_before_basic_auth_encoding() {
136        // password containing '@' and ':' had to be percent-encoded in the proxy URL
137        assert_eq!(
138            proxy_basic_auth_value("user:pa%40ss%3Aword"),
139            basic_auth("user:pa@ss:word")
140        );
141    }
142
143    #[test]
144    fn leaves_plain_userinfo_unchanged() {
145        assert_eq!(proxy_basic_auth_value("user:pass"), basic_auth("user:pass"));
146    }
147
148    #[test]
149    fn passes_through_invalid_escape_with_sign_prefix() {
150        // a leading '+' after '%' is not a valid percent-escape and must not be
151        // treated as one (a naive hex parser like `u8::from_str_radix` would
152        // otherwise accept "+A" as 0x0A instead of passing "%+A" through)
153        assert_eq!(
154            proxy_basic_auth_value("user:pass%+A"),
155            basic_auth("user:pass%+A")
156        );
157    }
158}
159
160impl TryFrom<Config> for ClientBuilder<GenericService> {
161    type Error = Error;
162
163    /// Builds a default [`ClientBuilder`] stack from a given configuration.
164    ///
165    /// By default, the client builder builds an HTTP client that relies on a
166    /// [`TokioExecutor`] to spawn background tasks. To install an executor
167    /// that propagates [`tracing`] spans, use
168    /// `<ClientBuilder<Svc> as TryFrom<(Config, E)>>::try_from()`.
169    ///
170    /// # Examples
171    ///
172    /// ```no_run
173    /// use kube_client::{client::ClientBuilder, config::Config};
174    ///
175    /// #[tokio::main]
176    /// async fn main() {
177    ///     let config = Config::infer().await.unwrap();
178    ///     let builder = ClientBuilder::try_from(config).unwrap();
179    ///     let client = builder.build();
180    /// }
181    /// ```
182    fn try_from(config: Config) -> Result<Self> {
183        let executor = TokioExecutor::new();
184        Self::try_from((config, executor))
185    }
186}
187
188impl<E> TryFrom<(Config, E)> for ClientBuilder<GenericService>
189where
190    E: hyper::rt::Executor<Pin<Box<dyn Future<Output = ()> + Send>>> + Send + Sync + Clone + 'static,
191{
192    type Error = Error;
193
194    /// Builds a default [`ClientBuilder`] stack from a given configuration.
195    ///
196    /// # Examples
197    ///
198    /// Install a tracing executor that propagates [`tracing`] spans to
199    /// spawned tasks.
200    ///
201    /// ```no_run
202    /// # #[cfg(feature = "hyper-util-tracing")]
203    /// # {
204    /// use hyper_util::rt::{CurrentSpanExecutor, TokioExecutor};
205    /// use kube_client::{client::ClientBuilder, config::Config};
206    ///
207    /// #[tokio::main]
208    /// async fn main() {
209    ///     let config = Config::infer().await.unwrap();
210    ///     let executor = CurrentSpanExecutor::new(TokioExecutor::new());
211    ///     let builder = ClientBuilder::try_from((config, executor)).unwrap();
212    ///     let client = builder.build();
213    /// }
214    /// # }
215    /// ```
216    fn try_from((config, executor): (Config, E)) -> Result<Self> {
217        let mut connector = HttpConnector::new();
218        connector.enforce_http(false);
219
220        #[cfg(all(feature = "aws-lc-rs", feature = "rustls-tls"))]
221        {
222            if rustls::crypto::CryptoProvider::get_default().is_none() {
223                // the only error here is if it's been initialized in between: we can ignore it
224                // since our semantic is only to set the default value if it does not exist.
225                let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
226            }
227        }
228
229        match config.proxy_url.as_ref() {
230            Some(proxy_url) if proxy_url.scheme_str() == Some("socks5") => {
231                #[cfg(feature = "socks5")]
232                {
233                    let connector = hyper_util::client::legacy::connect::proxy::SocksV5::new(
234                        proxy_url.clone(),
235                        connector,
236                    );
237                    make_generic_builder(executor, connector, config)
238                }
239
240                #[cfg(not(feature = "socks5"))]
241                Err(Error::ProxyProtocolDisabled {
242                    proxy_url: Box::new(proxy_url.clone()),
243                    protocol_feature: "kube/socks5",
244                })
245            }
246
247            Some(proxy_url) if proxy_url.scheme_str() == Some("http") => {
248                #[cfg(feature = "http-proxy")]
249                {
250                    let connector =
251                        hyper_util::client::legacy::connect::proxy::Tunnel::new(proxy_url.clone(), connector);
252                    let connector = with_proxy_basic_auth(proxy_url, connector);
253
254                    make_generic_builder(executor, connector, config)
255                }
256
257                #[cfg(not(feature = "http-proxy"))]
258                Err(Error::ProxyProtocolDisabled {
259                    proxy_url: Box::new(proxy_url.clone()),
260                    protocol_feature: "kube/http-proxy",
261                })
262            }
263
264            Some(proxy_url) if proxy_url.scheme_str() == Some("https") => {
265                #[cfg(all(feature = "http-proxy", any(feature = "rustls-tls", feature = "openssl-tls")))]
266                {
267                    #[cfg(feature = "rustls-tls")]
268                    let proxy_connector = config.rustls_https_connector_with_connector(connector)?;
269                    #[cfg(all(not(feature = "rustls-tls"), feature = "openssl-tls"))]
270                    let proxy_connector = config.openssl_https_connector_with_connector(connector)?;
271
272                    let connector =
273                        hyper_util::client::legacy::connect::proxy::Tunnel::new(proxy_url.clone(), proxy_connector);
274                    let connector = with_proxy_basic_auth(proxy_url, connector);
275
276                    make_generic_builder(executor, connector, config)
277                }
278
279                #[cfg(all(feature = "http-proxy", not(any(feature = "rustls-tls", feature = "openssl-tls"))))]
280                return Err(Error::TlsRequired);
281
282                #[cfg(not(feature = "http-proxy"))]
283                Err(Error::ProxyProtocolDisabled {
284                    proxy_url: Box::new(proxy_url.clone()),
285                    protocol_feature: "kube/http-proxy",
286                })
287            }
288
289            Some(proxy_url) => Err(Error::ProxyProtocolUnsupported {
290                proxy_url: Box::new(proxy_url.clone()),
291            }),
292
293            None => make_generic_builder(executor, connector, config),
294        }
295    }
296}
297
298/// Helper function for implementation of [`TryFrom<Config>`] for [`ClientBuilder`].
299/// Ignores [`Config::proxy_url`], which at this point is already handled.
300fn make_generic_builder<E, H>(
301    executor: E,
302    connector: H,
303    config: Config,
304) -> Result<ClientBuilder<GenericService>, Error>
305where
306    H: 'static + Clone + Send + Sync + Service<http::Uri>,
307    H::Response: 'static + Connection + Read + Write + Send + Unpin,
308    H::Future: 'static + Send,
309    H::Error: 'static + Send + Sync + std::error::Error,
310    E: hyper::rt::Executor<Pin<Box<dyn Future<Output = ()> + Send>>> + Send + Sync + Clone + 'static,
311{
312    let default_ns = config.default_namespace.clone();
313    let auth_layer = config.auth_layer()?;
314
315    let client: hyper_util::client::legacy::Client<_, Body> = {
316        // Current TLS feature precedence when more than one are set:
317        // 1. rustls-tls
318        // 2. openssl-tls
319        // Create a custom client to use something else.
320        // If TLS features are not enabled, http connector will be used.
321        #[cfg(feature = "rustls-tls")]
322        let connector = config.rustls_https_connector_with_connector(connector)?;
323        #[cfg(all(not(feature = "rustls-tls"), feature = "openssl-tls"))]
324        let connector = config.openssl_https_connector_with_connector(connector)?;
325        #[cfg(all(not(feature = "rustls-tls"), not(feature = "openssl-tls")))]
326        if config.cluster_url.scheme() == Some(&http::uri::Scheme::HTTPS) {
327            // no tls stack situation only works with http scheme
328            return Err(Error::TlsRequired);
329        }
330
331        let mut connector = TimeoutConnector::new(connector);
332
333        // Set the timeouts for the client
334        connector.set_connect_timeout(config.connect_timeout);
335        connector.set_read_timeout(config.read_timeout);
336        connector.set_write_timeout(config.write_timeout);
337        connector.set_reset_reader_on_write(config.reset_reader_on_write);
338
339        hyper_util::client::legacy::Builder::new(executor).build(connector)
340    };
341
342    let stack = ServiceBuilder::new().layer(config.base_uri_layer()).into_inner();
343    #[cfg(feature = "gzip")]
344    let stack = ServiceBuilder::new()
345        .layer(stack)
346        .layer(
347            tower_http::decompression::DecompressionLayer::new()
348                .no_br()
349                .no_deflate()
350                .no_zstd()
351                .gzip(!config.disable_compression),
352        )
353        .into_inner();
354
355    let service = ServiceBuilder::new()
356        .layer(stack)
357        .option_layer(config.default_retry.then_some(RetryLayer::new(RetryPolicy::server_retry())))
358        .option_layer(auth_layer)
359        .layer(config.extra_headers_layer()?)
360        .layer(
361            // Attribute names follow [Semantic Conventions].
362            // [Semantic Conventions]: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md
363            TraceLayer::new_for_http()
364                .make_span_with(|req: &Request<Body>| {
365                    tracing::debug_span!(
366                        "HTTP",
367                         http.method = %req.method(),
368                         http.url = %req.uri(),
369                         http.status_code = tracing::field::Empty,
370                         otel.name = req.extensions().get::<&'static str>().unwrap_or(&"HTTP"),
371                         otel.kind = "client",
372                         otel.status_code = tracing::field::Empty,
373                    )
374                })
375                .on_request(|_req: &Request<Body>, _span: &Span| {
376                    tracing::debug!("requesting");
377                })
378                .on_response(|res: &Response<Incoming>, _latency: Duration, span: &Span| {
379                    let status = res.status();
380                    span.record("http.status_code", status.as_u16());
381                    if status.is_client_error() || status.is_server_error() {
382                        span.record("otel.status_code", "ERROR");
383                    }
384                })
385                // Explicitly disable `on_body_chunk`. The default does nothing.
386                .on_body_chunk(())
387                .on_eos(|_: Option<&HeaderMap>, _duration: Duration, _span: &Span| {
388                    tracing::debug!("stream closed");
389                })
390                .on_failure(|ec: ServerErrorsFailureClass, _latency: Duration, span: &Span| {
391                    // Called when
392                    // - Calling the inner service errored
393                    // - Polling `Body` errored
394                    // - the response was classified as failure (5xx)
395                    // - End of stream was classified as failure
396                    span.record("otel.status_code", "ERROR");
397                    match ec {
398                        ServerErrorsFailureClass::StatusCode(status) => {
399                            span.record("http.status_code", status.as_u16());
400                            tracing::error!("failed with status {}", status)
401                        }
402                        ServerErrorsFailureClass::Error(err) => {
403                            tracing::error!("failed with error {}", err)
404                        }
405                    }
406                }),
407        )
408        .map_err(BoxError::from)
409        .service(client);
410
411    let (_, expiration) = config.exec_identity_pem();
412
413    let client = ClientBuilder::new(
414        service
415            .map_response_body(|body| {
416                Box::new(http_body_util::BodyExt::map_err(body, BoxError::from)) as Box<DynBody>
417            })
418            .boxed(),
419        default_ns,
420    )
421    .with_valid_until(expiration);
422
423    Ok(client)
424}
425
426#[cfg(test)]
427mod tests {
428    #[cfg(feature = "gzip")] use super::*;
429
430    #[cfg(all(feature = "gzip", feature = "rustls-tls"))]
431    #[tokio::test]
432    async fn test_no_accept_encoding_header_sent_when_compression_disabled()
433    -> Result<(), Box<dyn std::error::Error>> {
434        use http::Uri;
435        use std::net::SocketAddr;
436        use tokio::net::{TcpListener, TcpStream};
437
438        // make_generic_builder skips the provider install in TryFrom<Config>
439        #[cfg(feature = "aws-lc-rs")]
440        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
441
442        // setup a server that echoes back any encoding header value
443        let addr: SocketAddr = ([127, 0, 0, 1], 0).into();
444        let listener = TcpListener::bind(addr).await?;
445        let local_addr = listener.local_addr()?;
446        let uri: Uri = format!("http://{}", local_addr).parse()?;
447
448        tokio::spawn(async move {
449            use http_body_util::Full;
450            use hyper::{server::conn::http1, service::service_fn};
451            use hyper_util::rt::{TokioIo, TokioTimer};
452            use std::convert::Infallible;
453
454            loop {
455                let (tcp, _) = listener.accept().await.unwrap();
456                let io: TokioIo<TcpStream> = TokioIo::new(tcp);
457
458                tokio::spawn(async move {
459                    http1::Builder::new()
460                        .timer(TokioTimer::new())
461                        .serve_connection(
462                            io,
463                            service_fn(|req| async move {
464                                let response = req
465                                    .headers()
466                                    .get(http::header::ACCEPT_ENCODING)
467                                    .map(|b| Bytes::copy_from_slice(b.as_bytes()))
468                                    .unwrap_or_default();
469                                Ok::<_, Infallible>(Response::new(Full::new(response)))
470                            }),
471                        )
472                        .await
473                        .unwrap();
474                });
475            }
476        });
477
478        // confirm gzip echoed back with default config
479        let config = Config { ..Config::new(uri) };
480        let client =
481            make_generic_builder(TokioExecutor::new(), HttpConnector::new(), config.clone())?.build();
482        let response = client.request_text(http::Request::default()).await?;
483        assert_eq!(&response, "gzip");
484
485        // now disable and check empty string echoed back
486        let config = Config {
487            disable_compression: true,
488            ..config
489        };
490        let client = make_generic_builder(TokioExecutor::new(), HttpConnector::new(), config)?.build();
491        let response = client.request_text(http::Request::default()).await?;
492        assert_eq!(&response, "");
493
494        Ok(())
495    }
496}