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
21static ENV_VAR: LazyLock<Regex> = LazyLock::new(|| Regex::new("\\$\\{([A-Z0-9_]+)\\}").unwrap());
23
24#[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#[derive(Clone, Debug)]
64pub struct HttpClient {
65 client: Client,
66 middleware: ClientWithMiddleware,
67}
68
69impl HttpClient {
70 pub fn create_downloader(&self) -> HttpDownloader {
72 HttpDownloader {
73 client: self.clone(),
74 headers: FxHashMap::default(),
75 }
76 }
77
78 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 pub fn as_inner(&self) -> &Client {
92 &self.client
93 }
94
95 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 pub fn expand_env_vars(&self, value: &str) -> String {
116 ENV_VAR
117 .replace_all(value, |caps: ®ex::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#[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 pub allow_invalid_certs: bool,
139
140 pub cache_dir: Option<PathBuf>,
142
143 pub proxies: Vec<String>,
146
147 pub retry_count: Option<u32>,
149
150 pub secure_proxies: Vec<String>,
152
153 pub root_cert: Option<PathBuf>,
155}
156
157#[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#[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 cache_options: Some(CacheOptions {
300 cache_heuristic: 0.025,
301 ..Default::default()
302 }),
303 max_ttl: Some(Duration::from_secs(604800)), ..Default::default()
305 },
306 }));
307 }
308
309 Ok(middleware_builder)
310}
311
312pub fn create_http_client() -> Result<HttpClient, WarpgateHttpClientError> {
314 create_http_client_with_options(&HttpOptions::default())
315}
316
317#[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
341pub struct NetrcMiddleware {
344 nrc: Netrc,
345}
346
347impl NetrcMiddleware {
348 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}