pipedash_core/infrastructure/
http_client.rs1use std::net::{
2 IpAddr,
3 Ipv4Addr,
4 Ipv6Addr,
5};
6use std::sync::{
7 Arc,
8 OnceLock,
9};
10use std::time::Duration;
11
12use dashmap::DashMap;
13use pipedash_plugin_api::ClientOptions;
14use reqwest::{
15 redirect,
16 Client,
17 ClientBuilder,
18 Url,
19};
20
21use crate::domain::{
22 DomainError,
23 DomainResult,
24};
25
26const USER_AGENT: &str = concat!("pipedash/", env!("CARGO_PKG_VERSION"));
27const STATUS_TIMEOUT: Duration = Duration::from_secs(10);
28const STATUS_MAX_REDIRECTS: usize = 3;
29
30type ClientKey = (Option<String>, bool);
31
32pub struct HttpClientManager {
33 default_client: Arc<Client>,
34 clients: DashMap<ClientKey, Arc<Client>>,
35 status_client: OnceLock<Arc<Client>>,
36}
37
38impl HttpClientManager {
39 pub fn new() -> DomainResult<Self> {
40 let default_client = Self::build_client(false)?;
41 Ok(Self {
42 default_client: Arc::new(default_client),
43 clients: DashMap::new(),
44 status_client: OnceLock::new(),
45 })
46 }
47
48 pub fn client_for(&self, options: &ClientOptions) -> DomainResult<Arc<Client>> {
49 if *options == ClientOptions::default() {
50 return Ok(Arc::clone(&self.default_client));
51 }
52
53 let key = (
54 options.origin.as_ref().map(normalize_origin),
55 options.accept_invalid_certs,
56 );
57 let entry = self.clients.entry(key).or_try_insert_with(|| {
58 Self::build_client(options.accept_invalid_certs).map(Arc::new)
59 })?;
60 Ok(Arc::clone(entry.value()))
61 }
62
63 pub fn status_client(&self) -> DomainResult<Arc<Client>> {
64 if let Some(client) = self.status_client.get() {
65 return Ok(Arc::clone(client));
66 }
67 let client = Arc::new(Self::build_status_client()?);
68 Ok(Arc::clone(self.status_client.get_or_init(|| client)))
69 }
70
71 fn base_builder() -> ClientBuilder {
72 let _ = rustls::crypto::ring::default_provider().install_default();
73 Client::builder()
74 .tls_backend_rustls()
75 .user_agent(USER_AGENT)
76 }
77
78 fn build_client(accept_invalid_certs: bool) -> DomainResult<Client> {
79 let pool_size = std::env::var("PIPEDASH_HTTP_POOL_SIZE")
80 .ok()
81 .and_then(|s| s.parse().ok())
82 .unwrap_or(10);
83
84 Self::base_builder()
85 .tls_danger_accept_invalid_certs(accept_invalid_certs)
86 .pool_max_idle_per_host(pool_size)
87 .pool_idle_timeout(Duration::from_secs(90))
88 .tcp_keepalive(Duration::from_secs(60))
89 .timeout(Duration::from_secs(30))
90 .connect_timeout(Duration::from_secs(10))
91 .build()
92 .map_err(|e| DomainError::InternalError(format!("Failed to create HTTP client: {}", e)))
93 }
94
95 fn build_status_client() -> DomainResult<Client> {
96 let policy = redirect::Policy::custom(|attempt| {
97 if attempt.previous().len() > STATUS_MAX_REDIRECTS {
98 attempt.error("too many redirects")
99 } else if is_safe_redirect_target(attempt.url()) {
100 attempt.follow()
101 } else {
102 attempt.error("redirect target refused")
103 }
104 });
105
106 Self::base_builder()
107 .redirect(policy)
108 .timeout(STATUS_TIMEOUT)
109 .connect_timeout(STATUS_TIMEOUT)
110 .build()
111 .map_err(|e| {
112 DomainError::InternalError(format!("Failed to create status HTTP client: {}", e))
113 })
114 }
115}
116
117fn normalize_origin(url: &Url) -> String {
118 format!(
119 "{}://{}:{}",
120 url.scheme().to_ascii_lowercase(),
121 url.host_str().unwrap_or_default().to_ascii_lowercase(),
122 url.port_or_known_default().unwrap_or_default()
123 )
124}
125
126fn is_safe_redirect_target(url: &Url) -> bool {
127 if url.scheme() != "https" {
128 return false;
129 }
130 let Some(host) = url.host_str() else {
131 return false;
132 };
133 match host
134 .trim_start_matches('[')
135 .trim_end_matches(']')
136 .parse::<IpAddr>()
137 {
138 Ok(ip) => !is_internal_ip(ip),
139 Err(_) => true,
140 }
141}
142
143fn is_internal_ip(ip: IpAddr) -> bool {
144 match ip {
145 IpAddr::V4(v4) => is_internal_v4(v4),
146 IpAddr::V6(v6) => v6
147 .to_ipv4_mapped()
148 .map_or_else(|| is_internal_v6(v6), is_internal_v4),
149 }
150}
151
152fn is_internal_v4(ip: Ipv4Addr) -> bool {
153 ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_unspecified()
154}
155
156fn is_internal_v6(ip: Ipv6Addr) -> bool {
157 ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local() || ip.is_unspecified()
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 fn options(origin: &str, accept_invalid_certs: bool) -> ClientOptions {
165 ClientOptions {
166 origin: Some(Url::parse(origin).unwrap()),
167 accept_invalid_certs,
168 }
169 }
170
171 #[test]
172 fn default_options_share_the_default_client() {
173 let manager = HttpClientManager::new().unwrap();
174 let a = manager.client_for(&ClientOptions::default()).unwrap();
175 let b = manager.client_for(&ClientOptions::default()).unwrap();
176 assert!(Arc::ptr_eq(&a, &b));
177 }
178
179 #[test]
180 fn equivalent_origins_share_one_client() {
181 let manager = HttpClientManager::new().unwrap();
182 let a = manager.client_for(&options("https://h", false)).unwrap();
183 let b = manager
184 .client_for(&options("https://H:443/", false))
185 .unwrap();
186 assert!(Arc::ptr_eq(&a, &b));
187 assert!(!Arc::ptr_eq(&a, &manager.default_client));
188 }
189
190 #[test]
191 fn insecure_and_secure_clients_differ_for_same_origin() {
192 let manager = HttpClientManager::new().unwrap();
193 let secure = manager
194 .client_for(&options("https://argo.local", false))
195 .unwrap();
196 let insecure = manager
197 .client_for(&options("https://argo.local", true))
198 .unwrap();
199 assert!(!Arc::ptr_eq(&secure, &insecure));
200 }
201
202 #[test]
203 fn different_origins_get_different_clients() {
204 let manager = HttpClientManager::new().unwrap();
205 let a = manager
206 .client_for(&options("https://a.example", false))
207 .unwrap();
208 let b = manager
209 .client_for(&options("https://a.example:8443", false))
210 .unwrap();
211 assert!(!Arc::ptr_eq(&a, &b));
212 }
213
214 #[test]
215 fn status_client_is_cached_and_separate() {
216 let manager = HttpClientManager::new().unwrap();
217 let a = manager.status_client().unwrap();
218 let b = manager.status_client().unwrap();
219 assert!(Arc::ptr_eq(&a, &b));
220 let insecure = manager
221 .client_for(&options("https://a.example", true))
222 .unwrap();
223 assert!(!Arc::ptr_eq(&a, &insecure));
224 assert!(!Arc::ptr_eq(&a, &manager.default_client));
225 }
226
227 #[test]
228 fn redirect_targets_must_be_public_https() {
229 for allowed in [
230 "https://www.githubstatus.com/api/v2/summary.json",
231 "https://8.8.8.8/",
232 ] {
233 assert!(
234 is_safe_redirect_target(&Url::parse(allowed).unwrap()),
235 "{allowed}"
236 );
237 }
238 for refused in [
239 "http://www.githubstatus.com/",
240 "http://127.0.0.1:8765/",
241 "https://127.0.0.1/",
242 "https://10.1.2.3/",
243 "https://192.168.0.1/",
244 "https://169.254.169.254/",
245 "https://0.0.0.0/",
246 "https://[::1]/",
247 "https://[fe80::1]/",
248 "https://[fd00::1]/",
249 "https://[::ffff:127.0.0.1]/",
250 ] {
251 assert!(
252 !is_safe_redirect_target(&Url::parse(refused).unwrap()),
253 "{refused}"
254 );
255 }
256 }
257}