Skip to main content

warpgate/clients/
http.rs

1use super::WarpgateHttpClientError;
2use async_trait::async_trait;
3use core::ops::Deref;
4use netrc::Netrc;
5use regex::Regex;
6use reqwest::{Client, Response, Url};
7use reqwest_middleware::{ClientBuilder, ClientWithMiddleware, RequestBuilder, RequestInitialiser};
8use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
9use rustc_hash::FxHashMap;
10use serde::{Deserialize, Serialize};
11use starbase_utils::{
12    envx, fs,
13    net::{Downloader, NetError},
14};
15use std::env;
16use std::path::PathBuf;
17use std::sync::LazyLock;
18use std::time::Duration;
19use tracing::{debug, instrument, trace, warn};
20
21// ${ENV_VAR}
22static ENV_VAR: LazyLock<Regex> = LazyLock::new(|| Regex::new("\\$\\{([A-Z0-9_]+)\\}").unwrap());
23
24/// A downloader that uses our internal HTTP(S) client.
25#[derive(Clone, Debug)]
26pub struct HttpDownloader {
27    client: HttpClient,
28    headers: FxHashMap<String, String>,
29}
30
31#[async_trait]
32impl Downloader for HttpDownloader {
33    #[instrument(skip(self))]
34    async fn download(&self, url: Url) -> Result<Response, NetError> {
35        let url_string = url.to_string();
36
37        let mut request = self.client.get(url.clone());
38
39        if !self.headers.is_empty() {
40            for (key, value) in &self.headers {
41                request = request.header(key, self.client.expand_env_vars(value));
42            }
43        }
44
45        request.send().await.map_err(|error| match error {
46            reqwest_middleware::Error::Middleware(inner) => NetError::HttpUnknown {
47                error: format!("{inner}"),
48                url: url_string,
49            },
50            reqwest_middleware::Error::Reqwest(inner) => NetError::Http {
51                error: Box::new(inner),
52                url: url_string,
53            },
54        })
55    }
56}
57
58// `ClientWithMiddleware` doesn't allow access to their inner `Client`,
59// so we unfortunately need to keep a reference to both.
60// https://github.com/TrueLayer/reqwest-middleware/issues/203
61
62/// An HTTP(S) client with middleware that wraps [`reqwest::Client`].
63#[derive(Clone, Debug)]
64pub struct HttpClient {
65    client: Client,
66    middleware: ClientWithMiddleware,
67}
68
69impl HttpClient {
70    /// Create a downloader ([`HttpDownloader`]) that utilizes this client.
71    pub fn create_downloader(&self) -> HttpDownloader {
72        HttpDownloader {
73            client: self.clone(),
74            headers: FxHashMap::default(),
75        }
76    }
77
78    /// Create a downloader ([`HttpDownloader`]) that utilizes this client,
79    /// and injects the provided headers into each request.
80    pub fn create_downloader_with_headers(
81        &self,
82        headers: FxHashMap<String, String>,
83    ) -> HttpDownloader {
84        HttpDownloader {
85            client: self.clone(),
86            headers,
87        }
88    }
89
90    /// Return the inner [`reqwest::Client`].
91    pub fn as_inner(&self) -> &Client {
92        &self.client
93    }
94
95    /// Map a [`reqwest_middleware::Error`] for the provided URL
96    /// into a [`WarpgateHttpClientError`].
97    pub fn map_error(url: String, error: reqwest_middleware::Error) -> WarpgateHttpClientError {
98        match error {
99            reqwest_middleware::Error::Middleware(inner) => {
100                WarpgateHttpClientError::HttpMiddleware {
101                    error: format!("{inner}"),
102                    url,
103                }
104            }
105            reqwest_middleware::Error::Reqwest(inner) => WarpgateHttpClientError::Http {
106                error: Box::new(inner),
107                url,
108            },
109        }
110    }
111
112    /// Expand all environment variables (`${VAR}`) in the provided value.
113    /// If the environment variable is not set, it will be replaced with the
114    /// original `${VAR}` string.
115    pub fn expand_env_vars(&self, value: &str) -> String {
116        ENV_VAR
117            .replace_all(value, |caps: &regex::Captures| {
118                env::var(&caps[1]).unwrap_or_else(|_| caps[0].to_string())
119            })
120            .to_string()
121    }
122}
123
124impl Deref for HttpClient {
125    type Target = ClientWithMiddleware;
126
127    fn deref(&self) -> &Self::Target {
128        &self.middleware
129    }
130}
131
132/// Configures the HTTP(S) client used for making requests.
133#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
134#[serde(default, rename_all = "kebab-case")]
135#[cfg_attr(feature = "schematic", derive(schematic::Schematic))]
136pub struct HttpOptions {
137    /// Allow invalid certificates. This is dangerous and should only be used as a last resort!
138    pub allow_invalid_certs: bool,
139
140    /// Absolute path to a directory in which to cache GET and HEAD requests.
141    pub cache_dir: Option<PathBuf>,
142
143    /// A list of proxy URLs that all requests should pass through. URLs that start with
144    /// `http:` will handle insecure requests, while `https:` will handle secure requests.
145    pub proxies: Vec<String>,
146
147    /// Maximum number of times to retry a request if it fails due to a transient error.
148    pub retry_count: Option<u32>,
149
150    /// A list of proxy URLs that all `https:` requests should pass through.
151    pub secure_proxies: Vec<String>,
152
153    /// Absolute path to the root certificate. Supports `.pem` and `.der` files.
154    pub root_cert: Option<PathBuf>,
155}
156
157/// Build a [`reqwest::ClientBuilder`] with the provided options.
158#[instrument]
159pub fn build_http_client(
160    options: &HttpOptions,
161) -> Result<reqwest::ClientBuilder, WarpgateHttpClientError> {
162    let mut client_builder = reqwest::Client::builder()
163        .user_agent(format!("warpgate@{}", env!("CARGO_PKG_VERSION")))
164        .use_rustls_tls();
165
166    if !envx::bool_var("WARPGATE_HTTP_NO_TIMEOUTS") {
167        client_builder = client_builder
168            .read_timeout(Duration::from_mins(5))
169            .connect_timeout(Duration::from_mins(1));
170    }
171
172    if options.allow_invalid_certs {
173        trace!("Allowing invalid certificates (I hope you know what you're doing!)");
174
175        client_builder = client_builder.danger_accept_invalid_certs(true);
176    }
177
178    if let Some(root_cert) = &options.root_cert {
179        trace!(root_cert = ?root_cert, "Adding user provided root certificate");
180
181        match root_cert.extension().and_then(|ext| ext.to_str()) {
182            Some("der" | "DER") => {
183                client_builder = client_builder.add_root_certificate(
184                    reqwest::Certificate::from_der(&fs::read_file_bytes(root_cert)?).map_err(
185                        |error| WarpgateHttpClientError::InvalidCert {
186                            path: root_cert.to_path_buf(),
187                            error: Box::new(error),
188                        },
189                    )?,
190                )
191            }
192            Some("pem" | "PEM") => {
193                client_builder = client_builder.add_root_certificate(
194                    reqwest::Certificate::from_pem(&fs::read_file_bytes(root_cert)?).map_err(
195                        |error| WarpgateHttpClientError::InvalidCert {
196                            path: root_cert.to_path_buf(),
197                            error: Box::new(error),
198                        },
199                    )?,
200                )
201            }
202            _ => {
203                warn!(
204                    root_cert = ?root_cert,
205                    "Invalid root certificate type, must be a DER or PEM file",
206                );
207            }
208        };
209    }
210
211    let mut insecure_proxies = vec![];
212    let mut secure_proxies = options.secure_proxies.iter().collect::<Vec<_>>();
213
214    for proxy in &options.proxies {
215        if proxy.starts_with("https:") || (proxy.starts_with("http:") && proxy.contains(":443")) {
216            secure_proxies.push(proxy);
217        } else if proxy.starts_with("http:") {
218            insecure_proxies.push(proxy);
219        } else {
220            warn!(proxy, "Invalid proxy, only http or https URLs allowed");
221        };
222    }
223
224    if !insecure_proxies.is_empty() {
225        trace!(proxies = ?insecure_proxies, "Adding insecure proxies to client");
226
227        for proxy in insecure_proxies {
228            client_builder =
229                client_builder.proxy(reqwest::Proxy::http(proxy).map_err(|error| {
230                    WarpgateHttpClientError::InvalidProxy {
231                        url: proxy.to_owned(),
232                        error: Box::new(error),
233                    }
234                })?);
235        }
236    }
237
238    if !secure_proxies.is_empty() {
239        trace!(proxies = ?secure_proxies, "Adding secure proxies to client");
240
241        for proxy in secure_proxies {
242            client_builder =
243                client_builder.proxy(reqwest::Proxy::https(proxy).map_err(|error| {
244                    WarpgateHttpClientError::InvalidProxy {
245                        url: proxy.to_owned(),
246                        error: Box::new(error),
247                    }
248                })?);
249        }
250    }
251
252    Ok(client_builder)
253}
254
255/// Build a [`reqwest_middleware::ClientBuilder`] with the provided options.
256#[instrument]
257pub fn build_http_middleware(
258    client: Client,
259    options: &HttpOptions,
260) -> Result<ClientBuilder, WarpgateHttpClientError> {
261    let mut middleware_builder = ClientBuilder::new(client);
262
263    trace!("Adding retry support");
264
265    middleware_builder = middleware_builder.with(RetryTransientMiddleware::new_with_policy(
266        ExponentialBackoff::builder().build_with_max_retries(options.retry_count.unwrap_or(3)),
267    ));
268
269    match NetrcMiddleware::new() {
270        Ok(netrc) => {
271            trace!("Adding .netrc support");
272
273            middleware_builder = middleware_builder.with_init(netrc);
274        }
275        Err(error) => {
276            if matches!(error, netrc::Error::Parsing { .. }) {
277                warn!("Failed to initialize .netrc support: {error}");
278            }
279        }
280    };
281
282    if let Some(cache_dir) = &options.cache_dir
283        && !envx::is_docker()
284    {
285        use http_cache_reqwest::{
286            CACacheManager, Cache, CacheMode, CacheOptions, HttpCache, HttpCacheOptions,
287        };
288
289        trace!("Adding GET and HEAD request caching");
290
291        middleware_builder = middleware_builder.with(Cache(HttpCache {
292            manager: CACacheManager {
293                path: cache_dir.to_owned(),
294                remove_opts: Default::default(),
295            },
296            mode: CacheMode::Default,
297            options: HttpCacheOptions {
298                // https://github.com/kornelski/rusty-http-cache-semantics
299                cache_options: Some(CacheOptions {
300                    cache_heuristic: 0.025,
301                    ..Default::default()
302                }),
303                max_ttl: Some(Duration::from_secs(604800)), // 7 days
304                ..Default::default()
305            },
306        }));
307    }
308
309    Ok(middleware_builder)
310}
311
312/// Create an HTTP(S) client that'll be used for downloading files.
313pub fn create_http_client() -> Result<HttpClient, WarpgateHttpClientError> {
314    create_http_client_with_options(&HttpOptions::default())
315}
316
317/// Create an HTTP(S) client with the provided options, that'll be
318/// used for downloading files.
319#[instrument]
320pub fn create_http_client_with_options(
321    options: &HttpOptions,
322) -> Result<HttpClient, WarpgateHttpClientError> {
323    debug!("Creating HTTP client");
324
325    let client =
326        build_http_client(options)?
327            .build()
328            .map_err(|error| WarpgateHttpClientError::Client {
329                error: Box::new(error),
330            })?;
331
332    trace!("Applying middleware to client");
333
334    let middleware = build_http_middleware(client.clone(), options)?.build();
335
336    debug!("Created HTTP client");
337
338    Ok(HttpClient { client, middleware })
339}
340
341/// HTTP client middleware that applies credentials from
342/// the user's `.netrc` file to each request.
343pub struct NetrcMiddleware {
344    nrc: Netrc,
345}
346
347impl NetrcMiddleware {
348    /// Create a new middleware instance by loading the `.netrc` file.
349    pub fn new() -> netrc::Result<Self> {
350        Netrc::new().map(|nrc| NetrcMiddleware { nrc })
351    }
352}
353
354impl RequestInitialiser for NetrcMiddleware {
355    fn init(&self, req: RequestBuilder) -> RequestBuilder {
356        match req.try_clone() {
357            Some(nr) => nr
358                .try_clone()
359                .unwrap()
360                .build()
361                .ok()
362                .and_then(|r| {
363                    r.url()
364                        .host_str()
365                        .and_then(|host| {
366                            self.nrc
367                                .hosts
368                                .get(host)
369                                .or_else(|| self.nrc.hosts.get("default"))
370                        })
371                        .map(|auth| {
372                            nr.basic_auth(
373                                &auth.login,
374                                if auth.password.is_empty() {
375                                    None
376                                } else {
377                                    Some(&auth.password)
378                                },
379                            )
380                        })
381                })
382                .unwrap_or(req),
383            None => req,
384        }
385    }
386}