Skip to main content

scatter_proxy/
config.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::time::Duration;
4
5/// Default free SOCKS5 proxy sources used when no custom sources are configured.
6/// These are fetched from the scatter-proxy Codeberg Pages and popular community lists.
7pub const DEFAULT_PROXY_SOURCES: &[&str] = &[
8    // scatter-proxy's own curated list (Codeberg Pages mirror)
9    "https://letllmrun.codeberg.page/scatter-proxy/socks5.txt",
10];
11
12/// Main configuration for ScatterProxy.
13#[derive(Clone)]
14pub struct ScatterProxyConfig {
15    /// URLs of proxy sources (line-delimited `ip:port` or `socks5://ip:port`).
16    /// When empty, [`DEFAULT_PROXY_SOURCES`] are used automatically.
17    pub sources: Vec<String>,
18    /// How often to re-fetch proxy sources (default: 10 min).
19    pub source_refresh_interval: Duration,
20    /// Per-(proxy, host) rate-limiting configuration.
21    pub rate_limit: RateLimitConfig,
22    /// Timeout for a single proxy connection attempt (default: 8s).
23    pub proxy_timeout: Duration,
24    /// Number of concurrent proxy paths raced per request (default: 3).
25    pub max_concurrent_per_request: usize,
26    /// Global in-flight concurrency limit (default: 100).
27    pub max_inflight: usize,
28    /// Maximum number of pending tasks in the pool (default: 1000).
29    pub task_pool_capacity: usize,
30    /// Sliding window size for health tracking (default: 30).
31    pub health_window: usize,
32    /// Base cooldown duration after consecutive failures (default: 30s).
33    pub cooldown_base: Duration,
34    /// Maximum cooldown duration (default: 300s).
35    pub cooldown_max: Duration,
36    /// Number of consecutive failures before entering cooldown (default: 3).
37    pub cooldown_consecutive_fails: usize,
38    /// Minimum samples required before a proxy can be evicted (default: 30).
39    pub eviction_min_samples: usize,
40    /// Optional file path for persisting proxy state as JSON.
41    pub state_file: Option<PathBuf>,
42    /// How often to save state to disk (default: 5 min).
43    pub state_save_interval: Duration,
44    /// How often to log the metrics summary line (default: 30s).
45    pub metrics_log_interval: Duration,
46    /// Whether to prefer remote DNS resolution through the SOCKS5 proxy (default: true).
47    pub prefer_remote_dns: bool,
48    /// Optional label included in metrics log lines (default: None).
49    ///
50    /// When set, every periodic metrics log line is prefixed with `[name]`.
51    /// Useful when running multiple `ScatterProxy` instances (e.g. one per host
52    /// via [`ScatterProxyRouter`]) to tell them apart in logs.
53    ///
54    /// [`ScatterProxyRouter`]: crate::ScatterProxyRouter
55    pub name: Option<String>,
56    /// Maximum cumulative requests per proxy node before it is voluntarily retired.
57    ///
58    /// After reaching this limit the proxy transitions to `Retired` and is excluded
59    /// from scheduling.  `Retired` nodes are automatically reset when they appear
60    /// again in a source refresh.  `None` disables the limit (default).
61    pub max_requests_per_proxy: Option<u32>,
62    /// Minimum interval between WAF challenge solve attempts on the same proxy node.
63    ///
64    /// Prevents hammering the resolver on a hot proxy.  Default: 60 seconds.
65    pub challenge_cooldown: Duration,
66}
67
68impl Default for ScatterProxyConfig {
69    fn default() -> Self {
70        Self {
71            sources: Vec::new(),
72            source_refresh_interval: Duration::from_secs(600),
73            rate_limit: RateLimitConfig::default(),
74            proxy_timeout: Duration::from_secs(8),
75            max_concurrent_per_request: 3,
76            max_inflight: 100,
77            task_pool_capacity: 1000,
78            health_window: 30,
79            cooldown_base: Duration::from_secs(30),
80            cooldown_max: Duration::from_secs(300),
81            cooldown_consecutive_fails: 3,
82            eviction_min_samples: 30,
83            state_file: None,
84            state_save_interval: Duration::from_secs(300),
85            metrics_log_interval: Duration::from_secs(30),
86            prefer_remote_dns: true,
87            name: None,
88            max_requests_per_proxy: None,
89            challenge_cooldown: Duration::from_secs(60),
90        }
91    }
92}
93
94/// Per-(proxy, host) rate-limiting configuration.
95#[derive(Clone)]
96pub struct RateLimitConfig {
97    /// Default minimum interval between requests through the same proxy to the same host (default: 500ms).
98    pub default_interval: Duration,
99    /// Per-host overrides for the minimum interval.
100    pub host_overrides: HashMap<String, Duration>,
101}
102
103impl Default for RateLimitConfig {
104    fn default() -> Self {
105        Self {
106            default_interval: Duration::from_millis(500),
107            host_overrides: HashMap::new(),
108        }
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn test_scatter_proxy_config_defaults() {
118        let cfg = ScatterProxyConfig::default();
119
120        assert!(cfg.sources.is_empty());
121        assert_eq!(cfg.source_refresh_interval, Duration::from_secs(600));
122        assert_eq!(cfg.proxy_timeout, Duration::from_secs(8));
123        assert_eq!(cfg.max_concurrent_per_request, 3);
124        assert_eq!(cfg.max_inflight, 100);
125        assert_eq!(cfg.task_pool_capacity, 1000);
126        assert_eq!(cfg.health_window, 30);
127        assert_eq!(cfg.cooldown_base, Duration::from_secs(30));
128        assert_eq!(cfg.cooldown_max, Duration::from_secs(300));
129        assert_eq!(cfg.cooldown_consecutive_fails, 3);
130        assert_eq!(cfg.eviction_min_samples, 30);
131        assert!(cfg.state_file.is_none());
132        assert_eq!(cfg.state_save_interval, Duration::from_secs(300));
133        assert_eq!(cfg.metrics_log_interval, Duration::from_secs(30));
134        assert!(cfg.prefer_remote_dns);
135        assert!(cfg.max_requests_per_proxy.is_none());
136        assert_eq!(cfg.challenge_cooldown, Duration::from_secs(60));
137    }
138
139    #[test]
140    fn test_rate_limit_config_defaults() {
141        let rl = RateLimitConfig::default();
142
143        assert_eq!(rl.default_interval, Duration::from_millis(500));
144        assert!(rl.host_overrides.is_empty());
145    }
146
147    #[test]
148    fn test_rate_limit_config_nested_in_scatter_proxy_config() {
149        let cfg = ScatterProxyConfig::default();
150
151        assert_eq!(cfg.rate_limit.default_interval, Duration::from_millis(500));
152        assert!(cfg.rate_limit.host_overrides.is_empty());
153    }
154
155    #[test]
156    fn test_config_can_be_customised() {
157        let cfg = ScatterProxyConfig {
158            sources: vec!["https://example.com/proxies.txt".into()],
159            max_concurrent_per_request: 5,
160            max_inflight: 200,
161            state_file: Some(PathBuf::from("/tmp/scatter.json")),
162            prefer_remote_dns: false,
163            max_requests_per_proxy: Some(100),
164            challenge_cooldown: Duration::from_secs(30),
165            rate_limit: RateLimitConfig {
166                default_interval: Duration::from_millis(250),
167                host_overrides: {
168                    let mut m = HashMap::new();
169                    m.insert("slow.example.com".into(), Duration::from_secs(2));
170                    m
171                },
172            },
173            ..ScatterProxyConfig::default()
174        };
175
176        assert_eq!(cfg.sources.len(), 1);
177        assert_eq!(cfg.max_concurrent_per_request, 5);
178        assert_eq!(cfg.max_inflight, 200);
179        assert_eq!(cfg.state_file, Some(PathBuf::from("/tmp/scatter.json")));
180        assert!(!cfg.prefer_remote_dns);
181        assert_eq!(cfg.max_requests_per_proxy, Some(100));
182        assert_eq!(cfg.challenge_cooldown, Duration::from_secs(30));
183        assert_eq!(cfg.rate_limit.default_interval, Duration::from_millis(250));
184        assert_eq!(
185            cfg.rate_limit.host_overrides.get("slow.example.com"),
186            Some(&Duration::from_secs(2))
187        );
188        // fields that should still have defaults
189        assert_eq!(cfg.proxy_timeout, Duration::from_secs(8));
190        assert_eq!(cfg.health_window, 30);
191    }
192
193    #[test]
194    fn test_max_requests_per_proxy_default_is_none() {
195        let cfg = ScatterProxyConfig::default();
196        assert!(cfg.max_requests_per_proxy.is_none());
197    }
198
199    #[test]
200    fn test_challenge_cooldown_default_is_60s() {
201        let cfg = ScatterProxyConfig::default();
202        assert_eq!(cfg.challenge_cooldown, Duration::from_secs(60));
203    }
204
205    #[test]
206    fn test_cooldown_max_gte_cooldown_base() {
207        let cfg = ScatterProxyConfig::default();
208        assert!(cfg.cooldown_max >= cfg.cooldown_base);
209    }
210
211    #[test]
212    fn test_source_refresh_interval_is_10_minutes() {
213        let cfg = ScatterProxyConfig::default();
214        assert_eq!(cfg.source_refresh_interval.as_secs(), 10 * 60);
215    }
216
217    #[test]
218    fn test_state_save_interval_is_5_minutes() {
219        let cfg = ScatterProxyConfig::default();
220        assert_eq!(cfg.state_save_interval.as_secs(), 5 * 60);
221    }
222
223    #[test]
224    fn test_default_proxy_sources_not_empty() {
225        assert!(DEFAULT_PROXY_SOURCES
226            .iter()
227            .any(|source| source.contains("codeberg.page/scatter-proxy/socks5.txt")));
228        for source in DEFAULT_PROXY_SOURCES {
229            assert!(source.starts_with("https://"));
230        }
231    }
232}