1use std::time::Duration;
37
38use async_trait::async_trait;
39use futures::future::join_all;
40use reqwest::Client;
41use serde::Deserialize;
42use tracing::{debug, warn};
43
44use crate::{
45 Proxy, ProxyManager, ProxyType,
46 error::{ProxyError, ProxyResult},
47};
48
49#[async_trait]
83pub trait ProxyFetcher: Send + Sync {
84 async fn fetch(&self) -> ProxyResult<Vec<Proxy>>;
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
108#[non_exhaustive]
109pub enum FreeListSource {
110 TheSpeedXHttp,
112 #[cfg(feature = "socks")]
113 TheSpeedXSocks4,
115 #[cfg(feature = "socks")]
116 TheSpeedXSocks5,
118 ClarketmHttp,
120 OpenProxyListHttp,
122 Custom {
124 url: String,
126 proxy_type: ProxyType,
128 },
129}
130
131impl FreeListSource {
132 const fn url(&self) -> &str {
133 match self {
134 Self::TheSpeedXHttp => {
135 "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/http.txt"
136 }
137 #[cfg(feature = "socks")]
138 Self::TheSpeedXSocks4 => {
139 "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/socks4.txt"
140 }
141 #[cfg(feature = "socks")]
142 Self::TheSpeedXSocks5 => {
143 "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/socks5.txt"
144 }
145 Self::ClarketmHttp => {
146 "https://raw.githubusercontent.com/clarketm/proxy-list/master/proxy-list-raw.txt"
147 }
148 Self::OpenProxyListHttp => "https://openproxylist.xyz/http.txt",
149 Self::Custom { url, .. } => url.as_str(),
150 }
151 }
152
153 const fn proxy_type(&self) -> ProxyType {
154 match self {
155 Self::TheSpeedXHttp | Self::ClarketmHttp | Self::OpenProxyListHttp => ProxyType::Http,
156 #[cfg(feature = "socks")]
157 Self::TheSpeedXSocks4 => ProxyType::Socks4,
158 #[cfg(feature = "socks")]
159 Self::TheSpeedXSocks5 => ProxyType::Socks5,
160 Self::Custom { proxy_type, .. } => *proxy_type,
161 }
162 }
163}
164
165pub struct FreeListFetcher {
187 sources: Vec<FreeListSource>,
188 client: Client,
189 tags: Vec<String>,
190}
191
192impl FreeListFetcher {
193 pub fn new(sources: Vec<FreeListSource>) -> Self {
203 let client = Client::builder()
204 .timeout(Duration::from_secs(10))
205 .build()
206 .unwrap_or_else(|e| {
207 warn!("Failed to build HTTP client with 10 s timeout (TLS backend issue?): {e}; falling back to default client with per-request timeout enforcement");
208 Client::default()
209 });
210 Self {
211 sources,
212 client,
213 tags: vec!["free-list".into()],
214 }
215 }
216
217 #[cfg(feature = "tls-profiled")]
237 #[must_use]
238 pub fn with_profiled_client(
239 mut self,
240 requester: crate::http_client::ProfiledRequester,
241 ) -> Self {
242 self.client = requester.client().clone();
243 drop(requester);
244 self
245 }
246
247 #[cfg(feature = "tls-profiled")]
259 pub fn with_profiled_mode(
260 self,
261 mode: crate::types::ProfiledRequestMode,
262 ) -> crate::error::ProxyResult<Self> {
263 let requester = crate::http_client::ProfiledRequester::chrome_mode(mode)
264 .map_err(|e| crate::error::ProxyError::ConfigError(e.to_string()))?;
265 Ok(self.with_profiled_client(requester))
266 }
267
268 #[must_use]
278 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
279 self.tags.extend(tags);
280 self
281 }
282
283 fn parse_host_port_line(line: &str) -> Option<(String, u16)> {
285 let line = line.trim();
286 if line.is_empty() || line.starts_with('#') {
287 return None;
288 }
289
290 let (host, port_str) = if line.starts_with('[') {
291 let end = line.find(']')?;
292 let host = line.get(..=end)?.trim();
293 let remainder = line.get(end + 1..)?.trim();
294 let (_, port_str) = remainder.rsplit_once(':')?;
295 (host, port_str.trim())
296 } else {
297 let (host, port_str) = line.rsplit_once(':')?;
298 let host = host.trim();
299 if host.contains(':') {
300 return None;
301 }
302 (host, port_str.trim())
303 };
304
305 if host.is_empty() || host == "[]" {
306 return None;
307 }
308
309 let port = port_str.parse::<u16>().ok()?;
310 if port == 0 {
311 return None;
312 }
313
314 Some((host.to_string(), port))
315 }
316
317 async fn fetch_source(&self, source: &FreeListSource) -> Vec<Proxy> {
319 let url = source.url();
320 let proxy_type = source.proxy_type();
321
322 let body = match self
323 .client
324 .get(url)
325 .timeout(Duration::from_secs(10))
326 .send()
327 .await
328 {
329 Ok(resp) if resp.status().is_success() => match resp.text().await {
330 Ok(t) => t,
331 Err(e) => {
332 warn!("Failed to read body from {url}: {e}");
333 return vec![];
334 }
335 },
336 Ok(resp) => {
337 warn!(
338 "Non-success status {} fetching proxy list from {url}",
339 resp.status()
340 );
341 return vec![];
342 }
343 Err(e) => {
344 warn!("Failed to fetch proxy list from {url}: {e}");
345 return vec![];
346 }
347 };
348
349 let proxies: Vec<Proxy> = body
350 .lines()
351 .filter_map(|line| {
352 let (host, port) = Self::parse_host_port_line(line)?;
353 let scheme = match proxy_type {
354 ProxyType::Http => "http",
355 ProxyType::Https => "https",
356 #[cfg(feature = "socks")]
357 ProxyType::Socks4 => "socks4",
358 #[cfg(feature = "socks")]
359 ProxyType::Socks5 => "socks5",
360 };
361 Some(Proxy {
362 url: format!("{scheme}://{host}:{port}"),
363 proxy_type,
364 username: None,
365 password: None,
366 weight: 1,
367 tags: self.tags.clone(),
368 capabilities: crate::types::ProxyCapabilities::default(),
369 })
370 })
371 .collect();
372
373 debug!(source = url, count = proxies.len(), "Fetched proxy list");
374 proxies
375 }
376}
377
378#[async_trait]
379impl ProxyFetcher for FreeListFetcher {
380 async fn fetch(&self) -> ProxyResult<Vec<Proxy>> {
381 if self.sources.is_empty() {
382 return Err(ProxyError::ConfigError(
383 "no sources configured for FreeListFetcher".into(),
384 ));
385 }
386
387 let results = join_all(self.sources.iter().map(|s| self.fetch_source(s))).await;
389 let all: Vec<Proxy> = results.into_iter().flatten().collect();
390
391 if all.is_empty() {
392 return Err(ProxyError::FetchFailed {
393 origin: self
394 .sources
395 .iter()
396 .map(FreeListSource::url)
397 .collect::<Vec<_>>()
398 .join(", "),
399 message: "all sources returned empty or failed".into(),
400 });
401 }
402
403 Ok(all)
404 }
405}
406
407pub struct FreeApiProxiesFetcher {
432 endpoint: String,
433 client: Client,
434 tags: Vec<String>,
435 limit: Option<u32>,
437 protocol_filter: Option<String>,
439 country_filter: Option<String>,
441}
442
443#[derive(Debug, Deserialize)]
444#[serde(untagged)]
445enum FreeApiProxiesResponse {
446 List(Vec<FreeApiProxyRecord>),
447 Data { data: Vec<FreeApiProxyRecord> },
448 Results { results: Vec<FreeApiProxyRecord> },
449}
450
451impl FreeApiProxiesResponse {
452 fn into_records(self) -> Vec<FreeApiProxyRecord> {
453 match self {
454 Self::List(records)
455 | Self::Data { data: records }
456 | Self::Results { results: records } => records,
457 }
458 }
459}
460
461#[derive(Debug, Deserialize)]
462struct FreeApiProxyRecord {
463 #[serde(default, alias = "ip", alias = "host")]
464 address_host: String,
465 #[serde(default)]
466 port: Option<u16>,
467 #[serde(default, alias = "proxy", alias = "address")]
468 address: Option<String>,
469 #[serde(default, alias = "protocol", alias = "type", alias = "proxy_type")]
470 protocol: Option<String>,
471 #[serde(default)]
472 username: Option<String>,
473 #[serde(default)]
474 password: Option<String>,
475 #[serde(default, alias = "countryCode", alias = "country_code")]
476 country_code: Option<String>,
477}
478
479impl FreeApiProxiesFetcher {
480 const DEFAULT_ENDPOINT: &str = "https://freeapiproxies.azurewebsites.net/";
481
482 #[must_use]
484 pub fn new() -> Self {
485 Self::with_endpoint(Self::DEFAULT_ENDPOINT)
486 }
487
488 #[must_use]
490 pub fn with_endpoint(endpoint: impl Into<String>) -> Self {
491 let client = Client::builder()
492 .timeout(Duration::from_secs(10))
493 .build()
494 .unwrap_or_else(|e| {
495 warn!("Failed to build HTTP client with 10 s timeout (TLS backend issue?): {e}; falling back to default client with per-request timeout enforcement");
496 Client::default()
497 });
498
499 Self {
500 endpoint: endpoint.into(),
501 client,
502 tags: vec!["freeapiproxies".into()],
503 limit: None,
504 protocol_filter: None,
505 country_filter: None,
506 }
507 }
508
509 #[must_use]
511 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
512 self.tags.extend(tags);
513 self
514 }
515
516 #[must_use]
527 pub const fn with_limit(mut self, limit: u32) -> Self {
528 self.limit = Some(limit);
529 self
530 }
531
532 #[must_use]
544 pub fn with_protocol_filter(mut self, protocol: impl Into<String>) -> Self {
545 self.protocol_filter = Some(protocol.into());
546 self
547 }
548
549 #[must_use]
560 pub fn with_country_filter(mut self, country_code: impl Into<String>) -> Self {
561 self.country_filter = Some(country_code.into().to_ascii_uppercase());
562 self
563 }
564
565 fn request_url(&self) -> String {
567 let mut params: Vec<(&str, String)> = Vec::new();
568 if let Some(limit) = self.limit {
569 params.push(("limit", limit.to_string()));
570 }
571 if let Some(ref protocol) = self.protocol_filter {
572 params.push(("protocol", protocol.clone()));
573 }
574 if let Some(ref country) = self.country_filter {
575 params.push(("country", country.clone()));
576 }
577 if params.is_empty() {
578 return self.endpoint.clone();
579 }
580 let qs = params
581 .iter()
582 .enumerate()
583 .fold(String::new(), |mut acc, (i, (k, v))| {
584 use std::fmt::Write as _;
585 let sep = if i == 0 { "?" } else { "&" };
586 let _ = write!(acc, "{sep}{k}={v}");
587 acc
588 });
589 format!("{}{qs}", self.endpoint)
590 }
591
592 fn protocol_to_proxy_type(protocol: Option<&str>) -> Option<ProxyType> {
593 let normalized = protocol.map(str::trim).map(str::to_ascii_lowercase);
594 match normalized.as_deref() {
595 None | Some("" | "http") => Some(ProxyType::Http),
596 Some("https") => Some(ProxyType::Https),
597 #[cfg(feature = "socks")]
598 Some("socks" | "socks5") => Some(ProxyType::Socks5),
599 #[cfg(feature = "socks")]
600 Some("socks4") => Some(ProxyType::Socks4),
601 _ => None,
602 }
603 }
604
605 fn parse_address(record: &FreeApiProxyRecord) -> Option<(String, u16)> {
606 if let Some(address) = record.address.as_deref() {
607 if let Some((host, port)) = FreeListFetcher::parse_host_port_line(address) {
608 return Some((host, port));
609 }
610
611 if let Ok(url) = reqwest::Url::parse(address)
612 && let Some(port) = url.port_or_known_default()
613 {
614 return Some((url.host_str()?.to_string(), port));
615 }
616 }
617
618 let host = record.address_host.trim();
619 let port = record.port?;
620 if host.is_empty() || port == 0 {
621 return None;
622 }
623 Some((host.to_string(), port))
624 }
625
626 fn record_to_proxy(&self, record: FreeApiProxyRecord) -> Option<Proxy> {
627 let proxy_type = Self::protocol_to_proxy_type(record.protocol.as_deref())?;
628 let (host, port) = Self::parse_address(&record)?;
629
630 let scheme = match proxy_type {
631 ProxyType::Http => "http",
632 ProxyType::Https => "https",
633 #[cfg(feature = "socks")]
634 ProxyType::Socks4 => "socks4",
635 #[cfg(feature = "socks")]
636 ProxyType::Socks5 => "socks5",
637 };
638
639 let mut tags = self.tags.clone();
640 if let Some(country_code) = record.country_code.as_deref()
641 && !country_code.trim().is_empty()
642 {
643 tags.push(format!(
644 "country:{}",
645 country_code.trim().to_ascii_uppercase()
646 ));
647 }
648
649 Some(Proxy {
650 url: format!("{scheme}://{host}:{port}"),
651 proxy_type,
652 username: record.username.filter(|v| !v.trim().is_empty()),
653 password: record.password.filter(|v| !v.trim().is_empty()),
654 weight: 1,
655 tags,
656 capabilities: crate::types::ProxyCapabilities::default(),
657 })
658 }
659
660 fn parse_payload(&self, body: &str) -> ProxyResult<Vec<Proxy>> {
661 let response: FreeApiProxiesResponse =
662 serde_json::from_str(body).map_err(|e| ProxyError::FetchFailed {
663 origin: self.endpoint.clone(),
664 message: format!("invalid freeapiproxies json payload: {e}"),
665 })?;
666
667 let proxies: Vec<Proxy> = response
668 .into_records()
669 .into_iter()
670 .filter_map(|record| self.record_to_proxy(record))
671 .collect();
672
673 if proxies.is_empty() {
674 return Err(ProxyError::FetchFailed {
675 origin: self.endpoint.clone(),
676 message: "freeapiproxies payload contained no usable proxies".into(),
677 });
678 }
679
680 Ok(proxies)
681 }
682}
683
684impl Default for FreeApiProxiesFetcher {
685 fn default() -> Self {
686 Self::new()
687 }
688}
689
690#[async_trait]
691impl ProxyFetcher for FreeApiProxiesFetcher {
692 async fn fetch(&self) -> ProxyResult<Vec<Proxy>> {
693 let url = self.request_url();
694 let body = self
695 .client
696 .get(&url)
697 .timeout(Duration::from_secs(10))
698 .send()
699 .await
700 .map_err(|e| ProxyError::FetchFailed {
701 origin: url.clone(),
702 message: e.to_string(),
703 })?
704 .error_for_status()
705 .map_err(|e| ProxyError::FetchFailed {
706 origin: url.clone(),
707 message: e.to_string(),
708 })?
709 .text()
710 .await
711 .map_err(|e| ProxyError::FetchFailed {
712 origin: url.clone(),
713 message: e.to_string(),
714 })?;
715
716 self.parse_payload(&body)
717 }
718}
719
720pub async fn load_from_fetcher(
750 manager: &ProxyManager,
751 fetcher: &dyn ProxyFetcher,
752) -> ProxyResult<usize> {
753 let proxies = fetcher.fetch().await?;
754 let total = proxies.len();
755 let mut loaded = 0usize;
756
757 for proxy in proxies {
758 match manager.add_proxy(proxy).await {
759 Ok(_) => loaded += 1,
760 Err(e) => warn!("Skipped proxy during load: {e}"),
761 }
762 }
763
764 debug!(total, loaded, "Proxy list loaded into manager");
765 Ok(loaded)
766}
767
768#[cfg(test)]
771mod tests {
772 use super::*;
773
774 #[test]
775 fn free_api_proxies_fetcher_request_url_no_params() {
776 let f = FreeApiProxiesFetcher::with_endpoint("https://example.test/api");
777 assert_eq!(f.request_url(), "https://example.test/api");
778 }
779
780 #[test]
781 fn free_api_proxies_fetcher_request_url_with_params() {
782 let f = FreeApiProxiesFetcher::with_endpoint("https://example.test/api")
783 .with_limit(50)
784 .with_protocol_filter("http")
785 .with_country_filter("us");
786 let url = f.request_url();
787 assert!(url.contains("limit=50"), "expected limit param in {url}");
788 assert!(
789 url.contains("protocol=http"),
790 "expected protocol param in {url}"
791 );
792 assert!(
793 url.contains("country=US"),
794 "expected country uppercased in {url}"
795 );
796 assert!(url.starts_with("https://example.test/api?"), "missing ?");
797 }
798
799 #[test]
800 fn free_api_proxies_fetcher_country_filter_uppercased() {
801 let f = FreeApiProxiesFetcher::new().with_country_filter("de");
802 assert_eq!(f.country_filter.as_deref(), Some("DE"));
803 }
804
805 #[test]
808 #[ignore = "requires live network access to freeapiproxies.azurewebsites.net"]
809 fn free_api_proxies_fetcher_live_fetch() -> std::result::Result<(), Box<dyn std::error::Error>>
810 {
811 let fetcher = FreeApiProxiesFetcher::new().with_limit(20);
812 let rt = tokio::runtime::Builder::new_current_thread()
813 .enable_all()
814 .build()
815 .map_err(|e| std::io::Error::other(format!("failed to build runtime for test: {e}")))?;
816 let proxies = rt.block_on(fetcher.fetch())?;
817 assert!(
818 !proxies.is_empty(),
819 "expected at least one proxy from live endpoint"
820 );
821 for proxy in &proxies {
822 assert!(
823 proxy.url.starts_with("http://")
824 || proxy.url.starts_with("https://")
825 || proxy.url.starts_with("socks4://")
826 || proxy.url.starts_with("socks5://"),
827 "unexpected proxy url scheme: {}",
828 proxy.url
829 );
830 }
831 Ok(())
832 }
833
834 #[test]
835 fn free_list_source_url_is_nonempty() {
836 #[cfg(not(feature = "socks"))]
837 let sources = vec![
838 FreeListSource::TheSpeedXHttp,
839 FreeListSource::ClarketmHttp,
840 FreeListSource::OpenProxyListHttp,
841 FreeListSource::Custom {
842 url: "https://example.com/proxies.txt".into(),
843 proxy_type: ProxyType::Http,
844 },
845 ];
846 #[cfg(feature = "socks")]
847 let sources = {
848 let mut s = vec![
849 FreeListSource::TheSpeedXHttp,
850 FreeListSource::ClarketmHttp,
851 FreeListSource::OpenProxyListHttp,
852 FreeListSource::Custom {
853 url: "https://example.com/proxies.txt".into(),
854 proxy_type: ProxyType::Http,
855 },
856 ];
857 s.extend([
858 FreeListSource::TheSpeedXSocks4,
859 FreeListSource::TheSpeedXSocks5,
860 ]);
861 s
862 };
863 for src in &sources {
864 assert!(
865 !src.url().is_empty(),
866 "FreeListSource::{src:?} has empty URL"
867 );
868 }
869 }
870
871 #[test]
872 fn free_list_source_proxy_types() {
873 assert_eq!(FreeListSource::TheSpeedXHttp.proxy_type(), ProxyType::Http);
874 #[cfg(feature = "socks")]
875 assert_eq!(
876 FreeListSource::TheSpeedXSocks4.proxy_type(),
877 ProxyType::Socks4
878 );
879 #[cfg(feature = "socks")]
880 assert_eq!(
881 FreeListSource::TheSpeedXSocks5.proxy_type(),
882 ProxyType::Socks5
883 );
884 assert_eq!(FreeListSource::ClarketmHttp.proxy_type(), ProxyType::Http);
885 }
886
887 #[test]
888 fn free_api_proxies_fetcher_parses_array_payload() -> crate::error::ProxyResult<()> {
889 let fetcher = FreeApiProxiesFetcher::with_endpoint("https://example.test/freeapi");
890 let body = r#"
891[
892 {"host":"1.2.3.4","port":8080,"protocol":"http","countryCode":"us"},
893 {"address":"5.6.7.8:8443","protocol":"https"}
894]
895"#;
896
897 let proxies = fetcher.parse_payload(body)?;
898 assert_eq!(proxies.len(), 2);
899 assert_eq!(
900 proxies.first().map(|proxy| proxy.url.as_str()),
901 Some("http://1.2.3.4:8080")
902 );
903 assert_eq!(
904 proxies.get(1).map(|proxy| proxy.url.as_str()),
905 Some("https://5.6.7.8:8443")
906 );
907 Ok(())
908 }
909
910 #[test]
911 fn free_api_proxies_fetcher_parses_wrapped_results_payload() -> crate::error::ProxyResult<()> {
912 let fetcher = FreeApiProxiesFetcher::with_endpoint("https://example.test/freeapi");
913 let body = r#"
914{
915 "results": [
916 {"ip":"9.9.9.9","port":3128,"type":"http"}
917 ]
918}
919"#;
920
921 let proxies = fetcher.parse_payload(body)?;
922 assert_eq!(proxies.len(), 1);
923 assert_eq!(
924 proxies.first().map(|proxy| proxy.url.as_str()),
925 Some("http://9.9.9.9:3128")
926 );
927 Ok(())
928 }
929
930 #[test]
931 fn free_list_fetcher_parse_valid_lines() {
932 let fetcher = FreeListFetcher::new(vec![]);
933 let text = "1.2.3.4:8080\n# comment\n\nbad-line\n5.6.7.8:3128\n[2001:db8::1]:8081\n";
935 let parsed: Vec<Proxy> = text
936 .lines()
937 .filter_map(|line| {
938 let (host, port) = FreeListFetcher::parse_host_port_line(line)?;
939 Some(Proxy {
940 url: format!("http://{host}:{port}"),
941 proxy_type: ProxyType::Http,
942 username: None,
943 password: None,
944 weight: 1,
945 tags: fetcher.tags.clone(),
946 capabilities: crate::types::ProxyCapabilities::default(),
947 })
948 })
949 .collect();
950
951 assert_eq!(parsed.len(), 3);
952 assert_eq!(
953 parsed.first().map(|proxy| proxy.url.as_str()),
954 Some("http://1.2.3.4:8080")
955 );
956 assert_eq!(
957 parsed.get(1).map(|proxy| proxy.url.as_str()),
958 Some("http://5.6.7.8:3128")
959 );
960 assert_eq!(
961 parsed.get(2).map(|proxy| proxy.url.as_str()),
962 Some("http://[2001:db8::1]:8081")
963 );
964 }
965
966 #[test]
967 fn free_list_fetcher_with_tags_extends() {
968 let f = FreeListFetcher::new(vec![]).with_tags(vec!["custom".into()]);
969 assert!(f.tags.contains(&"free-list".to_string()));
970 assert!(f.tags.contains(&"custom".to_string()));
971 }
972
973 #[test]
974 fn free_list_fetcher_skips_invalid_port() {
975 assert!(FreeListFetcher::parse_host_port_line("1.2.3.4:notaport").is_none());
976 assert!(FreeListFetcher::parse_host_port_line("1.2.3.4:0").is_none());
977 assert!(FreeListFetcher::parse_host_port_line(":8080").is_none());
978 assert!(FreeListFetcher::parse_host_port_line("2001:db8::1:8080").is_none());
979 }
980
981 #[test]
982 fn free_list_fetcher_empty_sources_is_config_error()
983 -> std::result::Result<(), Box<dyn std::error::Error>> {
984 let fetcher = FreeListFetcher::new(vec![]);
985 let rt = tokio::runtime::Builder::new_current_thread()
986 .enable_time()
987 .build()
988 .map_err(|e| std::io::Error::other(format!("failed to build runtime for test: {e}")))?;
989 let err = rt
990 .block_on(fetcher.fetch())
991 .err()
992 .ok_or_else(|| std::io::Error::other("empty sources should fail"))?;
993 match err {
994 ProxyError::ConfigError(msg) => {
995 assert!(msg.contains("no sources configured"));
996 }
997 other => {
998 return Err(
999 std::io::Error::other(format!("unexpected error variant: {other}")).into(),
1000 );
1001 }
1002 }
1003 Ok(())
1004 }
1005
1006 #[test]
1007 fn proxy_error_fetch_failed_display() {
1008 let e = ProxyError::FetchFailed {
1009 origin: "https://example.com".into(),
1010 message: "timed out".into(),
1011 };
1012 assert!(e.to_string().contains("https://example.com"));
1013 assert!(e.to_string().contains("timed out"));
1014 }
1015}