Skip to main content

didbot_tls/
http_client.rs

1//! An `instant_acme::HttpClient` over `reqwest`, so this crate never enables
2//! instant-acme's `hyper-rustls` feature.
3//!
4//! That feature is the obvious way to get a working HTTP client for ACME,
5//! and it is declined on purpose: it always turns on
6//! `rustls-platform-verifier`, which pulls in `aws-lc-rs` regardless of
7//! whether `ring` is also selected -- `cargo tree -i aws-lc-rs` shows it as
8//! soon as `hyper-rustls` is enabled. This workspace already has exactly one
9//! crypto backend, `ring`, reached through `rustls-webpki`; a second one
10//! sitting beside it is the problem the jacquard pin in the workspace
11//! `Cargo.toml` warns about for the same reason. `reqwest` is already a
12//! workspace dependency on `ring` via its own `rustls-tls` feature, so this
13//! module is a thin adapter rather than a new HTTP stack.
14
15use std::error::Error as StdError;
16use std::future::Future;
17use std::pin::Pin;
18
19use bytes::Bytes;
20use http::Request;
21use http_body_util::{BodyExt, Full};
22use instant_acme::{BodyWrapper, BytesResponse, Error as AcmeError, HttpClient};
23
24/// Wraps a `reqwest::Client` to satisfy `instant_acme::HttpClient`.
25pub struct ReqwestAcmeClient(reqwest::Client);
26
27impl ReqwestAcmeClient {
28    /// A client built from [`didbot_http::client`], so a stalled directory
29    /// fetch cannot hang [`crate::manager::CertificateManager::bootstrap`]
30    /// forever.
31    pub fn new() -> Self {
32        Self(didbot_http::client())
33    }
34}
35
36impl Default for ReqwestAcmeClient {
37    fn default() -> Self {
38        Self::new()
39    }
40}
41
42fn other(err: impl StdError + Send + Sync + 'static) -> AcmeError {
43    AcmeError::Other(Box::new(err))
44}
45
46impl HttpClient for ReqwestAcmeClient {
47    fn request(
48        &self,
49        req: Request<BodyWrapper<Bytes>>,
50    ) -> Pin<Box<dyn Future<Output = Result<BytesResponse, AcmeError>> + Send>> {
51        let client = self.0.clone();
52        Box::pin(async move {
53            let (parts, body) = req.into_parts();
54            // `BodyWrapper<Bytes>` implements `http_body::Body` directly, so
55            // it can be drained with `BodyExt::collect` without instant-acme
56            // needing to expose its private field to us.
57            let body_bytes = body
58                .collect()
59                .await
60                .expect("BodyWrapper<Bytes>'s body collection cannot fail: its Error is Infallible")
61                .to_bytes();
62
63            let url: reqwest::Url = parts
64                .uri
65                .to_string()
66                .parse()
67                .map_err(|err: url::ParseError| other(err))?;
68
69            let mut builder = client.request(parts.method, url);
70            builder = builder.headers(parts.headers);
71            if !body_bytes.is_empty() {
72                builder = builder.body(body_bytes);
73            }
74
75            let response = builder.send().await.map_err(other)?;
76            let status = response.status();
77            let headers = response.headers().clone();
78            let response_bytes = Bytes::from(
79                didbot_http::read_bounded(response, didbot_http::MAX_JSON_BODY)
80                    .await
81                    .map_err(other)?,
82            );
83
84            let mut http_response = http::Response::builder().status(status);
85            if let Some(response_headers) = http_response.headers_mut() {
86                *response_headers = headers;
87            }
88            let http_response = http_response
89                .body(Full::new(response_bytes))
90                .map_err(other)?;
91
92            Ok(BytesResponse::from(http_response))
93        })
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use instant_acme::{Account, NewAccount};
100
101    use super::*;
102
103    /// A directory that accepts the connection and never answers costs
104    /// bootstrap's first ACME request exactly `didbot_http::DEFAULT_TIMEOUT`,
105    /// then an error.
106    #[tokio::test(start_paused = true)]
107    async fn a_directory_that_never_answers_fails_after_the_client_timeout() {
108        let addr = didbot_http::test_support::hung_server();
109        let started = tokio::time::Instant::now();
110        let registering = Account::builder_with_http(Box::new(ReqwestAcmeClient::new())).create(
111            &NewAccount {
112                contact: &[],
113                terms_of_service_agreed: true,
114                only_return_existing: false,
115            },
116            format!("http://{addr}/directory"),
117            None,
118        );
119        let result = tokio::time::timeout(didbot_http::DEFAULT_TIMEOUT * 2, registering)
120            .await
121            .expect("the client's own timeout fires first");
122        assert!(result.is_err(), "nothing ever answers");
123        assert_eq!(started.elapsed(), didbot_http::DEFAULT_TIMEOUT);
124    }
125}