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