Skip to main content

scatter_proxy/
lib.rs

1//! scatter-proxy — async request scheduler for unreliable SOCKS5 proxies.
2//!
3//! Multi-path race for maximum throughput.
4
5mod challenge;
6mod classifier;
7mod config;
8mod error;
9mod health;
10mod metrics;
11mod mutator;
12mod persist;
13mod proxy;
14mod rate_limit;
15mod router;
16mod scheduler;
17mod score;
18mod task;
19
20// Re-exports (public API surface)
21pub use challenge::ChallengeResolver;
22pub use classifier::{BodyClassifier, BodyVerdict, DefaultClassifier};
23pub use config::{RateLimitConfig, ScatterProxyConfig, DEFAULT_PROXY_SOURCES};
24pub use error::ScatterProxyError;
25pub use metrics::{PoolMetrics, ProxyHostStats};
26pub use mutator::RequestMutator;
27pub use proxy::{ProxyManager, ProxyState};
28pub use router::ScatterProxyRouter;
29pub use task::{ScatterResponse, TaskHandle};
30
31// Also re-export useful types for the classifier trait
32pub use http::HeaderMap;
33pub use http::StatusCode;
34
35use std::collections::HashMap;
36use std::sync::Arc;
37use std::time::Duration;
38use tokio::sync::{broadcast, Semaphore};
39use tokio::task::JoinHandle;
40use tracing::{debug, info, warn};
41
42/// The main entry point. Manages proxy pool, scheduling, and background tasks.
43#[allow(dead_code)]
44pub struct ScatterProxy {
45    config: Arc<ScatterProxyConfig>,
46    task_pool: Arc<task::TaskPool>,
47    health: Arc<health::HealthTracker>,
48    rate_limiter: Arc<rate_limit::RateLimiter>,
49    proxy_manager: Arc<proxy::ProxyManager>,
50    throughput: Arc<metrics::ThroughputTracker>,
51    semaphore: Arc<Semaphore>,
52    shutdown_tx: broadcast::Sender<()>,
53    // Background task handles
54    _scheduler_handle: JoinHandle<()>,
55    _persist_handle: Option<JoinHandle<()>>,
56    _metrics_handle: JoinHandle<()>,
57    _refresh_handle: JoinHandle<()>,
58}
59
60impl ScatterProxy {
61    /// Create a new `ScatterProxy` from config and a body classifier.
62    ///
63    /// This is an async constructor that fetches proxies, restores persisted
64    /// state, and spawns all background tasks (scheduler, persistence, metrics
65    /// logging, source refresh).
66    pub async fn new(
67        config: ScatterProxyConfig,
68        classifier: impl BodyClassifier,
69    ) -> Result<Self, ScatterProxyError> {
70        Self::new_with_extensions(
71            config,
72            classifier,
73            None::<NoOpResolver>,
74            None::<NoOpMutator>,
75        )
76        .await
77    }
78
79    /// Create a `ScatterProxy` with optional WAF challenge resolver and request mutator.
80    ///
81    /// Pass `None` for either extension to disable it; behaviour is then identical
82    /// to [`ScatterProxy::new`].
83    pub async fn new_with_extensions<C, R, M>(
84        config: ScatterProxyConfig,
85        classifier: C,
86        challenge_resolver: Option<R>,
87        request_mutator: Option<M>,
88    ) -> Result<Self, ScatterProxyError>
89    where
90        C: BodyClassifier,
91        R: ChallengeResolver,
92        M: RequestMutator,
93    {
94        let metrics_name = config.name.clone();
95        let config = Arc::new(config);
96
97        // Build all Arc-wrapped components from config.
98        let task_pool = Arc::new(task::TaskPool::new(config.task_pool_capacity));
99        let health = Arc::new(health::HealthTracker::new(config.health_window));
100        let rate_limiter = Arc::new(rate_limit::RateLimiter::new(&config.rate_limit));
101        let proxy_manager = Arc::new(proxy::ProxyManager::new(config.proxy_timeout));
102        let throughput = Arc::new(metrics::ThroughputTracker::new());
103        let semaphore = Arc::new(Semaphore::new(config.max_inflight));
104        let classifier: Arc<dyn BodyClassifier> = Arc::new(classifier);
105        let challenge_resolver: Option<Arc<dyn ChallengeResolver>> =
106            challenge_resolver.map(|r| Arc::new(r) as Arc<dyn ChallengeResolver>);
107        let request_mutator: Option<Arc<dyn RequestMutator>> =
108            request_mutator.map(|m| Arc::new(m) as Arc<dyn RequestMutator>);
109
110        // Determine sources: use configured sources, or fall back to defaults.
111        let effective_sources: Vec<String> = if config.sources.is_empty() {
112            info!("no custom sources configured, using default free proxy sources");
113            DEFAULT_PROXY_SOURCES
114                .iter()
115                .map(|s| s.to_string())
116                .collect()
117        } else {
118            config.sources.clone()
119        };
120
121        // Fetch proxies from sources.
122        let proxy_count = proxy_manager
123            .fetch_and_add(&effective_sources, config.prefer_remote_dns)
124            .await?;
125
126        // Restore persisted state if configured.
127        if let Some(ref state_path) = config.state_file {
128            match persist::load_state(state_path).await {
129                Ok(Some(persisted)) => {
130                    for (proxy_url, persisted_proxy) in &persisted.proxies {
131                        // Restore per-host health data.
132                        for (host, stats) in &persisted_proxy.hosts {
133                            health.restore(proxy_url, host, stats);
134                        }
135                        // Restore proxy state.
136                        match persisted_proxy.state.as_str() {
137                            "Active" => {
138                                proxy_manager.set_state(proxy_url, proxy::ProxyState::Active)
139                            }
140                            "Dead" => proxy_manager.set_state(proxy_url, proxy::ProxyState::Dead),
141                            "Retired" => {
142                                proxy_manager.set_state(proxy_url, proxy::ProxyState::Retired)
143                            }
144                            _ => {}
145                        }
146                        // Restore request count.
147                        if persisted_proxy.request_count > 0 {
148                            for _ in 0..persisted_proxy.request_count {
149                                proxy_manager.increment_request_count(proxy_url);
150                            }
151                        }
152                    }
153                    debug!(
154                        proxies = persisted.proxies.len(),
155                        "restored persisted state"
156                    );
157                }
158                Ok(None) => {
159                    debug!("no persisted state file found, starting fresh");
160                }
161                Err(e) => {
162                    warn!(error = %e, "failed to load persisted state, starting fresh");
163                }
164            }
165        }
166
167        info!(count = proxy_count, "loaded proxies from sources");
168
169        // Shutdown broadcast channel.
170        let (shutdown_tx, _) = broadcast::channel(1);
171
172        // ── Spawn background tasks ──────────────────────────────────────
173
174        // (a) Scheduler
175        let sched = scheduler::Scheduler::new(
176            Arc::clone(&config),
177            Arc::clone(&task_pool),
178            Arc::clone(&health),
179            Arc::clone(&rate_limiter),
180            Arc::clone(&proxy_manager),
181            Arc::clone(&classifier),
182            Arc::clone(&semaphore),
183            Arc::clone(&throughput),
184            challenge_resolver,
185            request_mutator,
186        );
187        let shutdown_rx_sched = shutdown_tx.subscribe();
188        let _scheduler_handle = tokio::spawn(async move {
189            sched.run(shutdown_rx_sched).await;
190        });
191
192        // (b) State persistence (only if state_file is configured)
193        let _persist_handle = if config.state_file.is_some() {
194            let persist_config = Arc::clone(&config);
195            let persist_health = Arc::clone(&health);
196            let persist_proxy_mgr = Arc::clone(&proxy_manager);
197            let mut shutdown_rx_persist = shutdown_tx.subscribe();
198            let interval = config.state_save_interval;
199
200            Some(tokio::spawn(async move {
201                loop {
202                    tokio::select! {
203                        _ = shutdown_rx_persist.recv() => {
204                            debug!("persist task received shutdown signal");
205                            break;
206                        }
207                        _ = tokio::time::sleep(interval) => {
208                            if let Some(ref path) = persist_config.state_file {
209                                let health_stats = persist_health.get_all_stats();
210                                let proxy_states = build_proxy_states(&persist_proxy_mgr);
211                                let request_counts = build_request_counts(&persist_proxy_mgr);
212                                if let Err(e) = persist::save_state(path, &health_stats, &proxy_states, &request_counts).await {
213                                    warn!(error = %e, "failed to save state");
214                                } else {
215                                    debug!("persisted state to disk");
216                                }
217                            }
218                        }
219                    }
220                }
221            }))
222        } else {
223            None
224        };
225
226        // (c) Metrics logging
227        let _metrics_config = Arc::clone(&config);
228        let metrics_task_pool = Arc::clone(&task_pool);
229        let metrics_health = Arc::clone(&health);
230        let metrics_proxy_mgr = Arc::clone(&proxy_manager);
231        let metrics_throughput = Arc::clone(&throughput);
232        let metrics_semaphore = Arc::clone(&semaphore);
233        let mut shutdown_rx_metrics = shutdown_tx.subscribe();
234        let metrics_interval = config.metrics_log_interval;
235        let max_inflight = config.max_inflight;
236
237        let _metrics_handle = tokio::spawn(async move {
238            let prefix = metrics_name
239                .as_deref()
240                .map_or_else(String::new, |n| format!("[{n}] "));
241            loop {
242                tokio::select! {
243                    _ = shutdown_rx_metrics.recv() => {
244                        debug!("metrics task received shutdown signal");
245                        break;
246                    }
247                    _ = tokio::time::sleep(metrics_interval) => {
248                        let tp = metrics_throughput.throughput(Duration::from_secs(10));
249                        let total_s = metrics_health.total_success();
250                        let total_f = metrics_health.total_fail();
251                        let total = total_s + total_f;
252                        let success_pct = if total > 0 {
253                            (total_s as f64 / total as f64) * 100.0
254                        } else {
255                            0.0
256                        };
257                        let (_, healthy, cooldown, dead, retired) = metrics_proxy_mgr.proxy_counts();
258                        let pending = metrics_task_pool.pending_count();
259                        let delayed = metrics_task_pool.delayed_count();
260                        let done = metrics_task_pool.completed_count();
261                        let failed = metrics_task_pool.failed_count();
262                        let requeued = metrics_task_pool.requeued_count();
263                        let zero_available = metrics_task_pool.zero_available_count();
264                        let skipped_no_permit = metrics_task_pool.skipped_no_permit_count();
265                        let skipped_rate_limit = metrics_task_pool.skipped_rate_limit_count();
266                        let skipped_cooldown = metrics_task_pool.skipped_cooldown_count();
267                        let dispatches = metrics_task_pool.dispatch_count();
268                        let inflight = max_inflight - metrics_semaphore.available_permits();
269                        info!(
270                            "{prefix}throughput={:.1}/s | success={:.0}% | pool: {} healthy / {} cooldown / {} dead / {} retired | tasks: {} pending {} delayed {} done {} failed requeued={} zero_avail={} dispatch={} skip(no_permit/rate/cooldown)={}/{}/{} | inflight={}",
271                            tp,
272                            success_pct,
273                            healthy,
274                            cooldown,
275                            dead,
276                            retired,
277                            pending,
278                            delayed,
279                            done,
280                            failed,
281                            requeued,
282                            zero_available,
283                            dispatches,
284                            skipped_no_permit,
285                            skipped_rate_limit,
286                            skipped_cooldown,
287                            inflight
288                        );
289                    }
290                }
291            }
292        });
293
294        // (d) Source refresh
295        let effective_sources = Arc::new(effective_sources);
296        let refresh_sources = Arc::clone(&effective_sources);
297        let refresh_config = Arc::clone(&config);
298        let refresh_proxy_mgr = Arc::clone(&proxy_manager);
299        let mut shutdown_rx_refresh = shutdown_tx.subscribe();
300        let refresh_interval = config.source_refresh_interval;
301
302        let _refresh_handle = tokio::spawn(async move {
303            loop {
304                tokio::select! {
305                    _ = shutdown_rx_refresh.recv() => {
306                        debug!("refresh task received shutdown signal");
307                        break;
308                    }
309                    _ = tokio::time::sleep(refresh_interval) => {
310                        match refresh_proxy_mgr
311                            .fetch_and_add(&refresh_sources, refresh_config.prefer_remote_dns)
312                            .await
313                        {
314                            Ok(count) => {
315                                // Reset retired proxies that appear in the refresh.
316                                let all = refresh_proxy_mgr.all_proxy_urls();
317                                for url in &all {
318                                    if matches!(refresh_proxy_mgr.get_state(url), ProxyState::Retired) {
319                                        refresh_proxy_mgr.reset_request_count(url);
320                                        debug!(proxy = %url, "retired proxy reset via source refresh");
321                                    }
322                                }
323                                debug!(new_count = count, "refreshed proxy sources");
324                            }
325                            Err(e) => {
326                                warn!(error = %e, "failed to refresh proxy sources");
327                            }
328                        }
329                    }
330                }
331            }
332        });
333
334        Ok(ScatterProxy {
335            config,
336            task_pool,
337            health,
338            rate_limiter,
339            proxy_manager,
340            throughput,
341            semaphore,
342            shutdown_tx,
343            _scheduler_handle,
344            _persist_handle,
345            _metrics_handle,
346            _refresh_handle,
347        })
348    }
349
350    /// Submit a single request for proxied execution.
351    ///
352    /// Blocks until the pool has capacity, then returns a [`TaskHandle`] whose
353    /// `.await` blocks until a successful proxied response is obtained.  The
354    /// scheduler retries internally forever — use [`TaskHandle::with_timeout`]
355    /// or wrap with `tokio::time::timeout` to impose a caller-side deadline.
356    pub async fn submit(&self, request: reqwest::Request) -> TaskHandle {
357        self.task_pool.submit(request).await
358    }
359
360    /// Non-blocking submit.  Returns `Err(PoolFull)` immediately when the pool
361    /// is at capacity instead of blocking.
362    pub fn try_submit(&self, request: reqwest::Request) -> Result<TaskHandle, ScatterProxyError> {
363        self.task_pool.try_submit(request)
364    }
365
366    /// Submit a batch of requests for proxied execution, blocking until the
367    /// pool has capacity for each request sequentially.
368    pub async fn submit_batch(&self, requests: Vec<reqwest::Request>) -> Vec<TaskHandle> {
369        self.task_pool.submit_batch(requests).await
370    }
371
372    /// Non-blocking atomic batch submit.  If the pool doesn't have enough
373    /// capacity for the entire batch, the whole batch is rejected.
374    pub fn try_submit_batch(
375        &self,
376        requests: Vec<reqwest::Request>,
377    ) -> Result<Vec<TaskHandle>, ScatterProxyError> {
378        self.task_pool.try_submit_batch(requests)
379    }
380
381    /// Submit with a deadline on the *submission* step itself.
382    ///
383    /// Blocks up to `timeout` waiting for pool capacity.  Returns
384    /// `Err(Timeout)` if no slot opens in time.  Once a slot is obtained the
385    /// returned `TaskHandle` blocks indefinitely (retry forever) until a
386    /// response arrives — use [`TaskHandle::with_timeout`] to bound that step.
387    pub async fn submit_timeout(
388        &self,
389        request: reqwest::Request,
390        timeout: Duration,
391    ) -> Result<TaskHandle, ScatterProxyError> {
392        self.task_pool.submit_timeout(request, timeout).await
393    }
394
395    /// Build a [`PoolMetrics`] snapshot from all internal components.
396    pub fn metrics(&self) -> PoolMetrics {
397        let (total, healthy, cooldown, dead, retired) = self.proxy_manager.proxy_counts();
398
399        let total_s = self.health.total_success();
400        let total_f = self.health.total_fail();
401        let total_requests = total_s + total_f;
402        let success_rate = if total_requests > 0 {
403            total_s as f64 / total_requests as f64
404        } else {
405            0.0
406        };
407
408        PoolMetrics {
409            total_proxies: total,
410            healthy_proxies: healthy,
411            cooldown_proxies: cooldown,
412            dead_proxies: dead,
413            retired_proxies: retired,
414
415            pending_tasks: self.task_pool.pending_count(),
416            delayed_tasks: self.task_pool.delayed_count(),
417            completed_tasks: self.task_pool.completed_count(),
418            failed_tasks: self.task_pool.failed_count(),
419
420            throughput_1s: self.throughput.throughput(Duration::from_secs(1)),
421            throughput_10s: self.throughput.throughput(Duration::from_secs(10)),
422            throughput_60s: self.throughput.throughput(Duration::from_secs(60)),
423
424            success_rate_1m: success_rate,
425            avg_latency_ms: self.health.avg_latency_ms(),
426
427            inflight: self.config.max_inflight - self.semaphore.available_permits(),
428            requeued_tasks: self.task_pool.requeued_count(),
429            zero_available_events: self.task_pool.zero_available_count(),
430            skipped_no_permit: self.task_pool.skipped_no_permit_count(),
431            skipped_rate_limit: self.task_pool.skipped_rate_limit_count(),
432            skipped_cooldown: self.task_pool.skipped_cooldown_count(),
433            dispatch_count: self.task_pool.dispatch_count(),
434        }
435    }
436
437    /// Gracefully shut down the proxy: signal all background tasks, persist
438    /// final state, and drop resources.
439    pub async fn shutdown(self) {
440        info!("initiating scatter-proxy shutdown");
441
442        // Signal all background tasks to stop.
443        let _ = self.shutdown_tx.send(());
444
445        // Final state save if configured.
446        if let Some(ref path) = self.config.state_file {
447            let health_stats = self.health.get_all_stats();
448            let proxy_states = build_proxy_states(&self.proxy_manager);
449            let request_counts = build_request_counts(&self.proxy_manager);
450            if let Err(e) =
451                persist::save_state(path, &health_stats, &proxy_states, &request_counts).await
452            {
453                warn!(error = %e, "failed to save final state during shutdown");
454            } else {
455                debug!("saved final state to disk");
456            }
457        }
458
459        info!("scatter-proxy shutdown complete");
460    }
461}
462
463/// Build a map of proxy URL → state string for persistence.
464fn build_proxy_states(proxy_manager: &proxy::ProxyManager) -> HashMap<String, String> {
465    let urls = proxy_manager.all_proxy_urls();
466    let mut states = HashMap::with_capacity(urls.len());
467    for url in urls {
468        let state = proxy_manager.get_state(&url);
469        let label = match state {
470            proxy::ProxyState::Active => "Active",
471            proxy::ProxyState::Dead => "Dead",
472            proxy::ProxyState::Retired => "Retired",
473            proxy::ProxyState::Unknown => "Unknown",
474        };
475        states.insert(url, label.to_string());
476    }
477    states
478}
479
480/// Build a map of proxy URL → cumulative request count for persistence.
481fn build_request_counts(proxy_manager: &proxy::ProxyManager) -> HashMap<String, u32> {
482    let urls = proxy_manager.all_proxy_urls();
483    let mut counts = HashMap::with_capacity(urls.len());
484    for url in urls {
485        let count = proxy_manager.request_count(&url);
486        if count > 0 {
487            counts.insert(url, count);
488        }
489    }
490    counts
491}
492
493// ── Router helper ────────────────────────────────────────────────────────────
494
495impl ScatterProxy {
496    /// Internal constructor that accepts a pre-built `Arc<dyn BodyClassifier>`.
497    ///
498    /// Used by [`ScatterProxyRouter`] to share a single classifier instance
499    /// across all per-host pools without requiring `Clone` on the classifier.
500    pub(crate) async fn new_arc(
501        config: ScatterProxyConfig,
502        classifier: Arc<dyn BodyClassifier>,
503    ) -> Result<Self, ScatterProxyError> {
504        Self::new_with_extensions(
505            config,
506            ArcClassifier(classifier),
507            None::<NoOpResolver>,
508            None::<NoOpMutator>,
509        )
510        .await
511    }
512}
513
514/// Wraps an `Arc<dyn BodyClassifier>` so it can be passed as a generic `C: BodyClassifier`.
515struct ArcClassifier(Arc<dyn BodyClassifier>);
516impl classifier::BodyClassifier for ArcClassifier {
517    fn classify(
518        &self,
519        status: http::StatusCode,
520        headers: &http::HeaderMap,
521        body: &[u8],
522    ) -> classifier::BodyVerdict {
523        self.0.classify(status, headers, body)
524    }
525}
526
527// ── Private no-op extension types used by `ScatterProxy::new` ────────────────
528
529struct NoOpResolver;
530impl ChallengeResolver for NoOpResolver {
531    fn resolve<'a>(
532        &'a self,
533        _proxy_url: &'a str,
534        _body: &'a [u8],
535    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send + 'a>> {
536        Box::pin(async { None })
537    }
538}
539
540struct NoOpMutator;
541impl RequestMutator for NoOpMutator {
542    fn mutate(&self, _proxy_url: &str, req: reqwest::Request) -> reqwest::Request {
543        req
544    }
545}