Skip to main content

pipedash_core/infrastructure/
http_client.rs

1use 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}