Skip to main content

stygian_proxy/
health.rs

1//! Async background health checker for proxy liveness verification.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use rand::RngExt;
8
9use tokio::sync::RwLock;
10use tokio::task::{JoinHandle, JoinSet};
11use tokio_util::sync::CancellationToken;
12use uuid::Uuid;
13
14use crate::storage::ProxyStoragePort;
15use crate::types::ProxyConfig;
16
17/// Shared health map type.
18/// `true` = proxy is currently considered healthy.
19pub type HealthMap = Arc<RwLock<HashMap<Uuid, bool>>>;
20
21/// Continuously verifies proxy liveness and updates the shared [`HealthMap`].
22///
23/// Run one check cycle with [`check_once`](HealthChecker::check_once) or launch
24/// a background task with [`spawn`](HealthChecker::spawn).
25///
26/// When the `tls-profiled` feature is enabled you can supply a
27/// [`ProfiledRequester`](crate::http_client::ProfiledRequester) via
28/// [`HealthChecker::with_profiled_client`] so health-check GET requests carry a
29/// browser TLS fingerprint.
30#[derive(Clone)]
31pub struct HealthChecker {
32    config: ProxyConfig,
33    storage: Arc<dyn ProxyStoragePort>,
34    health_map: HealthMap,
35    /// Optional TLS-profiled HTTP client.  When `None` a vanilla
36    /// `reqwest::Client` is built per check cycle.
37    #[cfg(feature = "tls-profiled")]
38    profiled: Option<crate::http_client::ProfiledRequester>,
39}
40
41impl HealthChecker {
42    /// Access the shared health map (read it to filter candidates).
43    #[must_use]
44    pub const fn health_map(&self) -> &HealthMap {
45        &self.health_map
46    }
47
48    /// Create a new checker.
49    ///
50    /// `health_map` should be the **same** `Arc` held by the `ProxyManager` so
51    /// that selection decisions always see up-to-date health information.
52    pub fn new(
53        config: ProxyConfig,
54        storage: Arc<dyn ProxyStoragePort>,
55        health_map: HealthMap,
56    ) -> Self {
57        Self {
58            config,
59            storage,
60            health_map,
61            #[cfg(feature = "tls-profiled")]
62            profiled: None,
63        }
64    }
65
66    /// Attach a TLS-profiled client so that health-check requests carry a
67    /// browser fingerprint instead of a default `reqwest` TLS handshake.
68    ///
69    /// Only available when the `tls-profiled` feature is enabled.
70    ///
71    /// # Example
72    ///
73    /// ```no_run
74    /// use std::sync::Arc;
75    /// use stygian_proxy::{
76    ///     HealthChecker,
77    ///     ProxyConfig,
78    ///     http_client::{ProfiledRequestMode, ProfiledRequester},
79    /// };
80    /// use stygian_proxy::storage::MemoryProxyStore;
81    ///
82    /// # fn run() -> Result<(), Box<dyn std::error::Error>> {
83    /// let storage = Arc::new(MemoryProxyStore::default());
84    /// let health_map = stygian_proxy::health::HealthMap::default();
85    /// let requester = ProfiledRequester::chrome_mode(ProfiledRequestMode::Preset)?;
86    /// let checker = HealthChecker::new(ProxyConfig::default(), storage, health_map)
87    ///     .with_profiled_client(requester);
88    /// # Ok(())
89    /// # }
90    /// ```
91    #[cfg(feature = "tls-profiled")]
92    #[must_use]
93    pub fn with_profiled_client(
94        mut self,
95        requester: crate::http_client::ProfiledRequester,
96    ) -> Self {
97        self.profiled = Some(requester);
98        self
99    }
100
101    /// Build and attach a profile-mode-based requester.
102    ///
103    /// Uses Chrome 131 as the baseline browser identity and applies `mode`
104    /// to TLS control mapping.
105    ///
106    /// Only available when the `tls-profiled` feature is enabled.
107    ///
108    /// # Errors
109    ///
110    /// Returns [`crate::error::ProxyError::ConfigError`] if the profiled
111    /// requester cannot be constructed.
112    #[cfg(feature = "tls-profiled")]
113    pub fn with_profiled_mode(
114        self,
115        mode: crate::types::ProfiledRequestMode,
116    ) -> crate::error::ProxyResult<Self> {
117        let requester = crate::http_client::ProfiledRequester::chrome_mode(mode)
118            .map_err(|e| crate::error::ProxyError::ConfigError(e.to_string()))?;
119        Ok(self.with_profiled_client(requester))
120    }
121
122    /// Spawn an infinite background task that checks proxies on a jittered
123    /// sleep-based schedule derived from `config.health_check_interval`.
124    ///
125    /// Each cycle sleeps for `jitter_duration(interval, jitter_pct)` before
126    /// running a probe pass.  Cancel `token` to stop the task gracefully.
127    #[must_use]
128    pub fn spawn(self, token: CancellationToken) -> JoinHandle<()> {
129        tokio::spawn(async move {
130            loop {
131                let sleep_dur = jitter_duration(
132                    self.config.health_check_interval,
133                    self.config.health_check_jitter_pct,
134                );
135                tokio::select! {
136                    () = token.cancelled() => {
137                        tracing::info!("health checker: shutdown requested");
138                        break;
139                    }
140                    () = tokio::time::sleep(sleep_dur) => {
141                        self.check_all().await;
142                    }
143                }
144            }
145            tracing::info!("health checker: stopped");
146        })
147    }
148
149    /// Run one full check cycle synchronously (useful for tests).
150    pub async fn check_once(&self) {
151        self.check_all().await;
152    }
153
154    async fn check_all(&self) {
155        let records = match self.storage.list().await {
156            Ok(r) => r,
157            Err(e) => {
158                tracing::error!("health checker: storage list failed: {e}");
159                return;
160            }
161        };
162
163        let health_url = self.config.health_check_url.clone();
164        let timeout = self.config.health_check_timeout;
165
166        let mut set: JoinSet<(Uuid, Result<u64, String>)> = JoinSet::new();
167        for record in records {
168            let proxy_url = record.proxy.url.clone();
169            let username = record.proxy.username.clone();
170            let password = record.proxy.password.clone();
171            let id = record.id;
172            let check_url = health_url.clone();
173
174            // When the `tls-profiled` feature is enabled, build a fresh profiled
175            // client per proxy that routes through that proxy's URL so health
176            // checks present a browser TLS fingerprint for proxy-routed requests.
177            #[cfg(feature = "tls-profiled")]
178            let routed_proxy_url =
179                proxy_url_with_auth(&proxy_url, username.as_deref(), password.as_deref());
180
181            #[cfg(feature = "tls-profiled")]
182            let preset_client: Option<reqwest::Client> = self.profiled.as_ref().and_then(|p| {
183                crate::http_client::ProfiledRequester::from_profile(
184                    p.profile(),
185                    Some(&routed_proxy_url),
186                )
187                .map(crate::http_client::ProfiledRequester::into_client)
188                .map_err(|e| {
189                    tracing::warn!(
190                        error = %e,
191                        proxy = %routed_proxy_url,
192                        "tls-profiled health-check client build failed; falling back to vanilla"
193                    );
194                })
195                .ok()
196            });
197
198            #[cfg(not(feature = "tls-profiled"))]
199            let preset_client: Option<reqwest::Client> = None;
200
201            set.spawn(async move {
202                let result = do_check(
203                    &proxy_url,
204                    username.as_deref(),
205                    password.as_deref(),
206                    &check_url,
207                    timeout,
208                    preset_client,
209                )
210                .await;
211                (id, result)
212            });
213        }
214
215        let mut updates: Vec<(Uuid, bool, u64)> = Vec::new();
216        while let Some(task_result) = set.join_next().await {
217            match task_result {
218                Ok((id, Ok(latency_ms))) => updates.push((id, true, latency_ms)),
219                Ok((id, Err(e))) => {
220                    tracing::warn!(proxy = %id, error = %e, "health check failed");
221                    updates.push((id, false, 0));
222                }
223                Err(join_err) => {
224                    tracing::error!("health check task panicked: {join_err}");
225                }
226            }
227        }
228
229        let total = u32::try_from(updates.len()).unwrap_or(u32::MAX);
230        let healthy_count =
231            u32::try_from(updates.iter().filter(|(_, h, _)| *h).count()).unwrap_or(u32::MAX);
232
233        {
234            let mut map = self.health_map.write().await;
235            for (id, healthy, _) in &updates {
236                map.insert(*id, *healthy);
237            }
238        }
239
240        for (id, success, latency) in updates {
241            if let Err(e) = self.storage.update_metrics(id, success, latency).await {
242                tracing::warn!("health checker: metrics update failed for {id}: {e}");
243            }
244        }
245
246        tracing::info!(
247            total,
248            healthy = healthy_count,
249            unhealthy = total - healthy_count,
250            "health check cycle complete"
251        );
252    }
253}
254
255/// Apply a random jitter factor to `base`.
256///
257/// `jitter_pct` is clamped to `[0.0, 0.99]`.  A value of `0.20` produces a
258/// sleep window uniformly distributed in `[base × 0.80, base × 1.20)`.
259fn jitter_duration(base: Duration, jitter_pct: f32) -> Duration {
260    if jitter_pct <= 0.0 {
261        return base;
262    }
263    let pct = jitter_pct.clamp(0.0, 0.99);
264    // random::<f32>() ∈ [0.0, 1.0) → factor ∈ [1 − pct, 1 + pct)
265    let factor = rand::rng()
266        .random::<f32>()
267        .mul_add(2.0_f32, -1.0_f32)
268        .mul_add(pct, 1.0_f32);
269    base.mul_f32(factor.max(0.01))
270}
271
272#[cfg(any(test, feature = "tls-profiled"))]
273fn proxy_url_with_auth(proxy_url: &str, username: Option<&str>, password: Option<&str>) -> String {
274    let (Some(user), Some(pass)) = (username, password) else {
275        return proxy_url.to_string();
276    };
277
278    let Ok(mut url) = reqwest::Url::parse(proxy_url) else {
279        return proxy_url.to_string();
280    };
281
282    if url.username().is_empty() && url.set_username(user).is_err() {
283        return proxy_url.to_string();
284    }
285    if url.password().is_none() && url.set_password(Some(pass)).is_err() {
286        return proxy_url.to_string();
287    }
288
289    url.to_string()
290}
291
292async fn do_check(
293    proxy_url: &str,
294    username: Option<&str>,
295    password: Option<&str>,
296    health_url: &str,
297    timeout: std::time::Duration,
298    preset_client: Option<reqwest::Client>,
299) -> Result<u64, String> {
300    // Use the pre-built profiled client (already includes proxy routing) when
301    // available; otherwise build a vanilla client with per-proxy routing and
302    // optional basic-auth credentials.
303    let client = if let Some(c) = preset_client {
304        c
305    } else {
306        let mut proxy = reqwest::Proxy::all(proxy_url).map_err(|e| e.to_string())?;
307        if let (Some(user), Some(pass)) = (username, password) {
308            proxy = proxy.basic_auth(user, pass);
309        }
310        reqwest::Client::builder()
311            .proxy(proxy)
312            .timeout(timeout)
313            .build()
314            .map_err(|e| e.to_string())?
315    };
316
317    let start = Instant::now();
318    client
319        .get(health_url)
320        .timeout(timeout)
321        .send()
322        .await
323        .map_err(|e| e.to_string())?
324        .error_for_status()
325        .map_err(|e| e.to_string())?;
326    Ok(start.elapsed().as_millis().try_into().unwrap_or(u64::MAX))
327}
328
329// ─────────────────────────────────────────────────────────────────────────────
330// Tests
331// ─────────────────────────────────────────────────────────────────────────────
332
333#[cfg(test)]
334mod tests {
335    use std::time::Duration;
336
337    use wiremock::matchers::method;
338    use wiremock::{Mock, MockServer, ResponseTemplate};
339
340    use super::*;
341    use crate::storage::MemoryProxyStore;
342    use crate::types::{Proxy, ProxyType};
343
344    fn make_proxy(url: &str) -> Proxy {
345        Proxy {
346            url: url.into(),
347            proxy_type: ProxyType::Http,
348            username: None,
349            password: None,
350            weight: 1,
351            tags: vec![],
352            capabilities: crate::types::ProxyCapabilities::default(),
353            ip_class: crate::types::IpClass::Unknown,
354            target_compatibility: crate::types::TargetVendorCompatibility::default(),
355        }
356    }
357    #[test]
358    fn proxy_url_with_auth_injects_credentials() {
359        let proxy_url = proxy_url_with_auth(
360            "http://proxy.example.com:8080",
361            Some("alice"),
362            Some("s3cr3t"),
363        );
364        assert!(proxy_url.starts_with("http://alice:s3cr3t@proxy.example.com:8080"));
365    }
366
367    #[cfg(feature = "tls-profiled")]
368    #[test]
369    fn proxy_url_with_auth_leaves_existing_credentials_untouched() {
370        let proxy_url = proxy_url_with_auth(
371            "http://already:present@proxy.example.com:8080",
372            Some("alice"),
373            Some("s3cr3t"),
374        );
375        assert!(proxy_url.starts_with("http://already:present@proxy.example.com:8080"));
376    }
377
378    #[tokio::test]
379    async fn healthy_and_unhealthy_proxies() -> crate::error::ProxyResult<()> {
380        // Mock server acts as both the HTTP proxy and the health-check target.
381        // reqwest sends the GET in absolute-form; wiremock responds 200.
382        let server = MockServer::start().await;
383        Mock::given(method("GET"))
384            .respond_with(ResponseTemplate::new(200))
385            .mount(&server)
386            .await;
387
388        let storage = Arc::new(MemoryProxyStore::default());
389        // Proxy 1: URL points to the mock server → health check will succeed.
390        storage.add(make_proxy(&server.uri())).await?;
391        // Proxy 2: invalid address → health check will fail.
392        storage.add(make_proxy("http://192.0.2.1:9999")).await?;
393
394        let health_map: HealthMap = Arc::new(RwLock::new(HashMap::new()));
395        let config = ProxyConfig {
396            health_check_url: format!("{}/", server.uri()),
397            health_check_interval: Duration::from_hours(1),
398            health_check_timeout: Duration::from_secs(2),
399            ..ProxyConfig::default()
400        };
401        let checker = HealthChecker::new(config, storage.clone(), health_map.clone());
402        checker.check_once().await;
403
404        let map = health_map.read().await;
405        let healthy = map.values().filter(|&&v| v).count();
406        let unhealthy = map.values().filter(|&&v| !v).count();
407        drop(map);
408        assert_eq!(healthy, 1, "expected 1 healthy proxy");
409        assert_eq!(unhealthy, 1, "expected 1 unhealthy proxy");
410        Ok(())
411    }
412
413    #[tokio::test]
414    async fn graceful_shutdown() {
415        let storage = Arc::new(MemoryProxyStore::default());
416        let health_map: HealthMap = Arc::new(RwLock::new(HashMap::new()));
417        let config = ProxyConfig {
418            health_check_interval: Duration::from_hours(1),
419            ..ProxyConfig::default()
420        };
421        let token = CancellationToken::new();
422        let checker = HealthChecker::new(config, storage, health_map);
423        let handle = checker.spawn(token.clone());
424
425        token.cancel();
426        let result = tokio::time::timeout(Duration::from_secs(1), handle).await;
427        assert!(
428            result.is_ok(),
429            "task should exit within 1s after cancellation"
430        );
431    }
432}