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 ProxyType::CdnEdge => "https",
361 };
362 Some(Proxy {
363 url: format!("{scheme}://{host}:{port}"),
364 proxy_type,
365 username: None,
366 password: None,
367 weight: 1,
368 tags: self.tags.clone(),
369 capabilities: crate::types::ProxyCapabilities {
370 is_cdn_edge: matches!(proxy_type, ProxyType::CdnEdge),
371 ..Default::default()
372 },
373 })
374 })
375 .collect();
376
377 debug!(source = url, count = proxies.len(), "Fetched proxy list");
378 proxies
379 }
380}
381
382#[async_trait]
383impl ProxyFetcher for FreeListFetcher {
384 async fn fetch(&self) -> ProxyResult<Vec<Proxy>> {
385 if self.sources.is_empty() {
386 return Err(ProxyError::ConfigError(
387 "no sources configured for FreeListFetcher".into(),
388 ));
389 }
390
391 let results = join_all(self.sources.iter().map(|s| self.fetch_source(s))).await;
393 let all: Vec<Proxy> = results.into_iter().flatten().collect();
394
395 if all.is_empty() {
396 return Err(ProxyError::FetchFailed {
397 origin: self
398 .sources
399 .iter()
400 .map(FreeListSource::url)
401 .collect::<Vec<_>>()
402 .join(", "),
403 message: "all sources returned empty or failed".into(),
404 });
405 }
406
407 Ok(all)
408 }
409}
410
411pub struct FreeApiProxiesFetcher {
436 endpoint: String,
437 client: Client,
438 tags: Vec<String>,
439 limit: Option<u32>,
441 protocol_filter: Option<String>,
443 country_filter: Option<String>,
445}
446
447#[derive(Debug, Deserialize)]
448#[serde(untagged)]
449enum FreeApiProxiesResponse {
450 List(Vec<FreeApiProxyRecord>),
451 Data { data: Vec<FreeApiProxyRecord> },
452 Results { results: Vec<FreeApiProxyRecord> },
453}
454
455impl FreeApiProxiesResponse {
456 fn into_records(self) -> Vec<FreeApiProxyRecord> {
457 match self {
458 Self::List(records)
459 | Self::Data { data: records }
460 | Self::Results { results: records } => records,
461 }
462 }
463}
464
465#[derive(Debug, Deserialize)]
466struct FreeApiProxyRecord {
467 #[serde(default, alias = "ip", alias = "host")]
468 address_host: String,
469 #[serde(default)]
470 port: Option<u16>,
471 #[serde(default, alias = "proxy", alias = "address")]
472 address: Option<String>,
473 #[serde(default, alias = "protocol", alias = "type", alias = "proxy_type")]
474 protocol: Option<String>,
475 #[serde(default)]
476 username: Option<String>,
477 #[serde(default)]
478 password: Option<String>,
479 #[serde(default, alias = "countryCode", alias = "country_code")]
480 country_code: Option<String>,
481}
482
483impl FreeApiProxiesFetcher {
484 const DEFAULT_ENDPOINT: &str = "https://freeapiproxies.azurewebsites.net/";
485
486 #[must_use]
488 pub fn new() -> Self {
489 Self::with_endpoint(Self::DEFAULT_ENDPOINT)
490 }
491
492 #[must_use]
494 pub fn with_endpoint(endpoint: impl Into<String>) -> Self {
495 let client = Client::builder()
496 .timeout(Duration::from_secs(10))
497 .build()
498 .unwrap_or_else(|e| {
499 warn!("Failed to build HTTP client with 10 s timeout (TLS backend issue?): {e}; falling back to default client with per-request timeout enforcement");
500 Client::default()
501 });
502
503 Self {
504 endpoint: endpoint.into(),
505 client,
506 tags: vec!["freeapiproxies".into()],
507 limit: None,
508 protocol_filter: None,
509 country_filter: None,
510 }
511 }
512
513 #[must_use]
515 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
516 self.tags.extend(tags);
517 self
518 }
519
520 #[must_use]
531 pub const fn with_limit(mut self, limit: u32) -> Self {
532 self.limit = Some(limit);
533 self
534 }
535
536 #[must_use]
548 pub fn with_protocol_filter(mut self, protocol: impl Into<String>) -> Self {
549 self.protocol_filter = Some(protocol.into());
550 self
551 }
552
553 #[must_use]
564 pub fn with_country_filter(mut self, country_code: impl Into<String>) -> Self {
565 self.country_filter = Some(country_code.into().to_ascii_uppercase());
566 self
567 }
568
569 fn request_url(&self) -> String {
571 let mut params: Vec<(&str, String)> = Vec::new();
572 if let Some(limit) = self.limit {
573 params.push(("limit", limit.to_string()));
574 }
575 if let Some(ref protocol) = self.protocol_filter {
576 params.push(("protocol", protocol.clone()));
577 }
578 if let Some(ref country) = self.country_filter {
579 params.push(("country", country.clone()));
580 }
581 if params.is_empty() {
582 return self.endpoint.clone();
583 }
584 let qs = params
585 .iter()
586 .enumerate()
587 .fold(String::new(), |mut acc, (i, (k, v))| {
588 use std::fmt::Write as _;
589 let sep = if i == 0 { "?" } else { "&" };
590 let _ = write!(acc, "{sep}{k}={v}");
591 acc
592 });
593 format!("{}{qs}", self.endpoint)
594 }
595
596 fn protocol_to_proxy_type(protocol: Option<&str>) -> Option<ProxyType> {
597 let normalized = protocol.map(str::trim).map(str::to_ascii_lowercase);
598 match normalized.as_deref() {
599 None | Some("" | "http") => Some(ProxyType::Http),
600 Some("https") => Some(ProxyType::Https),
601 Some("cdn" | "cdn_edge") => Some(ProxyType::CdnEdge),
602 #[cfg(feature = "socks")]
603 Some("socks" | "socks5") => Some(ProxyType::Socks5),
604 #[cfg(feature = "socks")]
605 Some("socks4") => Some(ProxyType::Socks4),
606 _ => None,
607 }
608 }
609
610 fn parse_address(record: &FreeApiProxyRecord) -> Option<(String, u16)> {
611 if let Some(address) = record.address.as_deref() {
612 if let Some((host, port)) = FreeListFetcher::parse_host_port_line(address) {
613 return Some((host, port));
614 }
615
616 if let Ok(url) = reqwest::Url::parse(address)
617 && let Some(port) = url.port_or_known_default()
618 {
619 return Some((url.host_str()?.to_string(), port));
620 }
621 }
622
623 let host = record.address_host.trim();
624 let port = record.port?;
625 if host.is_empty() || port == 0 {
626 return None;
627 }
628 Some((host.to_string(), port))
629 }
630
631 fn record_to_proxy(&self, record: FreeApiProxyRecord) -> Option<Proxy> {
632 let proxy_type = Self::protocol_to_proxy_type(record.protocol.as_deref())?;
633 let (host, port) = Self::parse_address(&record)?;
634
635 let scheme = match proxy_type {
636 ProxyType::Http => "http",
637 ProxyType::Https => "https",
638 #[cfg(feature = "socks")]
639 ProxyType::Socks4 => "socks4",
640 #[cfg(feature = "socks")]
641 ProxyType::Socks5 => "socks5",
642 ProxyType::CdnEdge => "https",
643 };
644
645 let mut tags = self.tags.clone();
646 if let Some(country_code) = record.country_code.as_deref()
647 && !country_code.trim().is_empty()
648 {
649 tags.push(format!(
650 "country:{}",
651 country_code.trim().to_ascii_uppercase()
652 ));
653 }
654
655 Some(Proxy {
656 url: format!("{scheme}://{host}:{port}"),
657 proxy_type,
658 username: record.username.filter(|v| !v.trim().is_empty()),
659 password: record.password.filter(|v| !v.trim().is_empty()),
660 weight: 1,
661 tags,
662 capabilities: crate::types::ProxyCapabilities {
663 is_cdn_edge: matches!(proxy_type, ProxyType::CdnEdge),
664 ..Default::default()
665 },
666 })
667 }
668
669 fn parse_payload(&self, body: &str) -> ProxyResult<Vec<Proxy>> {
670 let response: FreeApiProxiesResponse =
671 serde_json::from_str(body).map_err(|e| ProxyError::FetchFailed {
672 origin: self.endpoint.clone(),
673 message: format!("invalid freeapiproxies json payload: {e}"),
674 })?;
675
676 let proxies: Vec<Proxy> = response
677 .into_records()
678 .into_iter()
679 .filter_map(|record| self.record_to_proxy(record))
680 .collect();
681
682 if proxies.is_empty() {
683 return Err(ProxyError::FetchFailed {
684 origin: self.endpoint.clone(),
685 message: "freeapiproxies payload contained no usable proxies".into(),
686 });
687 }
688
689 Ok(proxies)
690 }
691}
692
693impl Default for FreeApiProxiesFetcher {
694 fn default() -> Self {
695 Self::new()
696 }
697}
698
699#[async_trait]
700impl ProxyFetcher for FreeApiProxiesFetcher {
701 async fn fetch(&self) -> ProxyResult<Vec<Proxy>> {
702 let url = self.request_url();
703 let body = self
704 .client
705 .get(&url)
706 .timeout(Duration::from_secs(10))
707 .send()
708 .await
709 .map_err(|e| ProxyError::FetchFailed {
710 origin: url.clone(),
711 message: e.to_string(),
712 })?
713 .error_for_status()
714 .map_err(|e| ProxyError::FetchFailed {
715 origin: url.clone(),
716 message: e.to_string(),
717 })?
718 .text()
719 .await
720 .map_err(|e| ProxyError::FetchFailed {
721 origin: url.clone(),
722 message: e.to_string(),
723 })?;
724
725 self.parse_payload(&body)
726 }
727}
728
729pub async fn load_from_fetcher(
759 manager: &ProxyManager,
760 fetcher: &dyn ProxyFetcher,
761) -> ProxyResult<usize> {
762 let proxies = fetcher.fetch().await?;
763 let total = proxies.len();
764 let mut loaded = 0usize;
765
766 for proxy in proxies {
767 match manager.add_proxy(proxy).await {
768 Ok(_) => loaded += 1,
769 Err(e) => warn!("Skipped proxy during load: {e}"),
770 }
771 }
772
773 debug!(total, loaded, "Proxy list loaded into manager");
774 Ok(loaded)
775}
776
777#[cfg(feature = "dns-fetcher")]
810pub struct DnsTxtFetcher {
811 zone: String,
812 tags: Vec<String>,
813}
814
815#[cfg(feature = "dns-fetcher")]
816impl DnsTxtFetcher {
817 pub fn new(zone: impl Into<String>) -> Self {
821 Self {
822 zone: zone.into(),
823 tags: vec!["dns-txt".into()],
824 }
825 }
826
827 #[must_use]
829 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
830 self.tags.extend(tags);
831 self
832 }
833
834 fn parse_record(&self, record: &str) -> Option<Proxy> {
836 let record = record.trim();
837 if record.is_empty() || record.starts_with('#') {
838 return None;
839 }
840 let (host, port, remainder) = if let Some(rest) = record.strip_prefix('[') {
842 let end = rest.find(']')?;
843 let host = format!("[{}]", rest.get(..end)?);
844 let after = rest.get(end + 1..).unwrap_or("").trim_start_matches(':');
845 let colon = after.find(':').unwrap_or(after.len());
846 let port: u16 = after.get(..colon)?.trim().parse().ok()?;
847 let rem = after.get(colon + 1..).unwrap_or("");
848 (host, port, rem)
849 } else {
850 let first = record.find(':')?;
851 let host = record.get(..first)?.trim().to_string();
852 let rest = record.get(first + 1..)?;
853 let second = rest.find(':').unwrap_or(rest.len());
854 let port: u16 = rest.get(..second)?.trim().parse().ok()?;
855 let rem = rest.get(second + 1..).unwrap_or("");
856 (host, port, rem)
857 };
858 if host.is_empty() || port == 0 {
859 return None;
860 }
861 let parts: Vec<&str> = remainder.splitn(3, ':').collect();
862 let type_str = parts.first().map_or("http", |s| s.trim());
863 match type_str.to_ascii_lowercase().as_str() {
864 "cdn_edge" | "cdn" => {
865 let provider = parts
866 .get(1)
867 .copied()
868 .map(str::trim)
869 .filter(|s| !s.is_empty())
870 .map(str::to_string);
871 Some(Proxy {
872 url: format!("https://{host}:{port}"),
873 proxy_type: ProxyType::CdnEdge,
874 username: None,
875 password: None,
876 weight: 1,
877 tags: self.tags.clone(),
878 capabilities: crate::types::ProxyCapabilities {
879 is_cdn_edge: true,
880 cdn_provider: provider,
881 ..Default::default()
882 },
883 })
884 }
885 type_str => {
886 let proxy_type = match type_str {
887 "https" => ProxyType::Https,
888 #[cfg(feature = "socks")]
889 "socks5" | "socks" => ProxyType::Socks5,
890 #[cfg(feature = "socks")]
891 "socks4" => ProxyType::Socks4,
892 _ => ProxyType::Http,
893 };
894 let scheme = match proxy_type {
895 ProxyType::Http => "http",
896 ProxyType::Https => "https",
897 #[cfg(feature = "socks")]
898 ProxyType::Socks4 => "socks4",
899 #[cfg(feature = "socks")]
900 ProxyType::Socks5 => "socks5",
901 ProxyType::CdnEdge => "https",
902 };
903 let username = parts
904 .get(1)
905 .copied()
906 .map(str::trim)
907 .filter(|s| !s.is_empty())
908 .map(str::to_string);
909 let password = parts
910 .get(2)
911 .copied()
912 .map(str::trim)
913 .filter(|s| !s.is_empty())
914 .map(str::to_string);
915 Some(Proxy {
916 url: format!("{scheme}://{host}:{port}"),
917 proxy_type,
918 username,
919 password,
920 weight: 1,
921 tags: self.tags.clone(),
922 capabilities: crate::types::ProxyCapabilities::default(),
923 })
924 }
925 }
926 }
927}
928
929#[cfg(feature = "dns-fetcher")]
930#[async_trait]
931impl ProxyFetcher for DnsTxtFetcher {
932 async fn fetch(&self) -> ProxyResult<Vec<Proxy>> {
933 use hickory_resolver::TokioAsyncResolver;
934 use hickory_resolver::config::{ResolverConfig, ResolverOpts};
935
936 let resolver =
937 TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default());
938
939 let lookup =
940 resolver
941 .txt_lookup(self.zone.as_str())
942 .await
943 .map_err(|e| ProxyError::FetchFailed {
944 origin: self.zone.clone(),
945 message: format!("DNS TXT lookup failed for '{}': {e}", self.zone),
946 })?;
947
948 let mut proxies: Vec<Proxy> = Vec::new();
949 for txt in lookup.iter() {
950 let record_str: String = txt
952 .txt_data()
953 .iter()
954 .filter_map(|bytes| std::str::from_utf8(bytes).ok())
955 .collect::<Vec<_>>()
956 .join("");
957 if let Some(proxy) = self.parse_record(&record_str) {
958 proxies.push(proxy);
959 }
960 }
961
962 if proxies.is_empty() {
963 return Err(ProxyError::FetchFailed {
964 origin: self.zone.clone(),
965 message: format!(
966 "no valid proxy records found in DNS TXT for '{}'",
967 self.zone
968 ),
969 });
970 }
971
972 debug!(
973 zone = %self.zone,
974 count = proxies.len(),
975 "fetched proxy list from DNS TXT",
976 );
977 Ok(proxies)
978 }
979}
980
981#[cfg(test)]
984mod tests {
985 use super::*;
986
987 #[cfg(feature = "dns-fetcher")]
990 #[expect(clippy::unwrap_used, reason = "test assertions on Option")]
991 mod dns_txt {
992 use super::*;
993
994 fn fetcher() -> DnsTxtFetcher {
995 DnsTxtFetcher::new("proxies.example.com")
996 }
997
998 #[test]
999 fn parse_http_host_port() {
1000 let proxy = fetcher().parse_record("10.0.1.5:8080").unwrap();
1001 assert_eq!(proxy.url, "http://10.0.1.5:8080");
1002 assert_eq!(proxy.proxy_type, ProxyType::Http);
1003 assert!(proxy.username.is_none());
1004 assert!(proxy.password.is_none());
1005 }
1006
1007 #[test]
1008 fn parse_https_record() {
1009 let proxy = fetcher().parse_record("10.0.1.5:443:https").unwrap();
1010 assert_eq!(proxy.url, "https://10.0.1.5:443");
1011 assert_eq!(proxy.proxy_type, ProxyType::Https);
1012 }
1013
1014 #[test]
1015 fn parse_cdn_edge_with_provider() {
1016 let proxy = fetcher()
1017 .parse_record("edge.cdn.example.com:443:cdn_edge:cloudflare")
1018 .unwrap();
1019 assert_eq!(proxy.url, "https://edge.cdn.example.com:443");
1020 assert_eq!(proxy.proxy_type, ProxyType::CdnEdge);
1021 assert!(proxy.capabilities.is_cdn_edge);
1022 assert_eq!(
1023 proxy.capabilities.cdn_provider.as_deref(),
1024 Some("cloudflare")
1025 );
1026 }
1027
1028 #[test]
1029 fn parse_cdn_edge_without_provider() {
1030 let proxy = fetcher()
1031 .parse_record("cdn.example.com:443:cdn_edge")
1032 .unwrap();
1033 assert!(proxy.capabilities.is_cdn_edge);
1034 assert!(proxy.capabilities.cdn_provider.is_none());
1035 }
1036
1037 #[test]
1038 fn parse_auth_fields() {
1039 let proxy = fetcher()
1040 .parse_record("10.0.0.1:3128:http:alice:secret")
1041 .unwrap();
1042 assert_eq!(proxy.username.as_deref(), Some("alice"));
1043 assert_eq!(proxy.password.as_deref(), Some("secret"));
1044 }
1045
1046 #[test]
1047 fn parse_ipv6_bracketed() {
1048 let proxy = fetcher().parse_record("[::1]:8080").unwrap();
1049 assert_eq!(proxy.url, "http://[::1]:8080");
1050 }
1051
1052 #[test]
1053 fn parse_empty_record_returns_none() {
1054 assert!(fetcher().parse_record("").is_none());
1055 assert!(fetcher().parse_record(" ").is_none());
1056 }
1057
1058 #[test]
1059 fn parse_comment_record_returns_none() {
1060 assert!(fetcher().parse_record("# comment line").is_none());
1061 }
1062
1063 #[test]
1064 fn parse_invalid_port_returns_none() {
1065 assert!(fetcher().parse_record("10.0.0.1:notaport").is_none());
1066 }
1067 }
1068
1069 #[test]
1070 fn free_api_proxies_fetcher_request_url_no_params() {
1071 let f = FreeApiProxiesFetcher::with_endpoint("https://example.test/api");
1072 assert_eq!(f.request_url(), "https://example.test/api");
1073 }
1074
1075 #[test]
1076 fn free_api_proxies_fetcher_request_url_with_params() {
1077 let f = FreeApiProxiesFetcher::with_endpoint("https://example.test/api")
1078 .with_limit(50)
1079 .with_protocol_filter("http")
1080 .with_country_filter("us");
1081 let url = f.request_url();
1082 assert!(url.contains("limit=50"), "expected limit param in {url}");
1083 assert!(
1084 url.contains("protocol=http"),
1085 "expected protocol param in {url}"
1086 );
1087 assert!(
1088 url.contains("country=US"),
1089 "expected country uppercased in {url}"
1090 );
1091 assert!(url.starts_with("https://example.test/api?"), "missing ?");
1092 }
1093
1094 #[test]
1095 fn free_api_proxies_fetcher_country_filter_uppercased() {
1096 let f = FreeApiProxiesFetcher::new().with_country_filter("de");
1097 assert_eq!(f.country_filter.as_deref(), Some("DE"));
1098 }
1099
1100 #[test]
1103 #[ignore = "requires live network access to freeapiproxies.azurewebsites.net"]
1104 fn free_api_proxies_fetcher_live_fetch() -> std::result::Result<(), Box<dyn std::error::Error>>
1105 {
1106 let fetcher = FreeApiProxiesFetcher::new().with_limit(20);
1107 let rt = tokio::runtime::Builder::new_current_thread()
1108 .enable_all()
1109 .build()
1110 .map_err(|e| std::io::Error::other(format!("failed to build runtime for test: {e}")))?;
1111 let proxies = rt.block_on(fetcher.fetch())?;
1112 assert!(
1113 !proxies.is_empty(),
1114 "expected at least one proxy from live endpoint"
1115 );
1116 for proxy in &proxies {
1117 assert!(
1118 proxy.url.starts_with("http://")
1119 || proxy.url.starts_with("https://")
1120 || proxy.url.starts_with("socks4://")
1121 || proxy.url.starts_with("socks5://"),
1122 "unexpected proxy url scheme: {}",
1123 proxy.url
1124 );
1125 }
1126 Ok(())
1127 }
1128
1129 #[test]
1130 fn free_list_source_url_is_nonempty() {
1131 #[cfg(not(feature = "socks"))]
1132 let sources = vec![
1133 FreeListSource::TheSpeedXHttp,
1134 FreeListSource::ClarketmHttp,
1135 FreeListSource::OpenProxyListHttp,
1136 FreeListSource::Custom {
1137 url: "https://example.com/proxies.txt".into(),
1138 proxy_type: ProxyType::Http,
1139 },
1140 ];
1141 #[cfg(feature = "socks")]
1142 let sources = {
1143 let mut s = vec![
1144 FreeListSource::TheSpeedXHttp,
1145 FreeListSource::ClarketmHttp,
1146 FreeListSource::OpenProxyListHttp,
1147 FreeListSource::Custom {
1148 url: "https://example.com/proxies.txt".into(),
1149 proxy_type: ProxyType::Http,
1150 },
1151 ];
1152 s.extend([
1153 FreeListSource::TheSpeedXSocks4,
1154 FreeListSource::TheSpeedXSocks5,
1155 ]);
1156 s
1157 };
1158 for src in &sources {
1159 assert!(
1160 !src.url().is_empty(),
1161 "FreeListSource::{src:?} has empty URL"
1162 );
1163 }
1164 }
1165
1166 #[test]
1167 fn free_list_source_proxy_types() {
1168 assert_eq!(FreeListSource::TheSpeedXHttp.proxy_type(), ProxyType::Http);
1169 #[cfg(feature = "socks")]
1170 assert_eq!(
1171 FreeListSource::TheSpeedXSocks4.proxy_type(),
1172 ProxyType::Socks4
1173 );
1174 #[cfg(feature = "socks")]
1175 assert_eq!(
1176 FreeListSource::TheSpeedXSocks5.proxy_type(),
1177 ProxyType::Socks5
1178 );
1179 assert_eq!(FreeListSource::ClarketmHttp.proxy_type(), ProxyType::Http);
1180 }
1181
1182 #[test]
1183 fn free_api_proxies_fetcher_parses_array_payload() -> crate::error::ProxyResult<()> {
1184 let fetcher = FreeApiProxiesFetcher::with_endpoint("https://example.test/freeapi");
1185 let body = r#"
1186[
1187 {"host":"1.2.3.4","port":8080,"protocol":"http","countryCode":"us"},
1188 {"address":"5.6.7.8:8443","protocol":"https"}
1189]
1190"#;
1191
1192 let proxies = fetcher.parse_payload(body)?;
1193 assert_eq!(proxies.len(), 2);
1194 assert_eq!(
1195 proxies.first().map(|proxy| proxy.url.as_str()),
1196 Some("http://1.2.3.4:8080")
1197 );
1198 assert_eq!(
1199 proxies.get(1).map(|proxy| proxy.url.as_str()),
1200 Some("https://5.6.7.8:8443")
1201 );
1202 Ok(())
1203 }
1204
1205 #[test]
1206 fn free_api_proxies_fetcher_parses_wrapped_results_payload() -> crate::error::ProxyResult<()> {
1207 let fetcher = FreeApiProxiesFetcher::with_endpoint("https://example.test/freeapi");
1208 let body = r#"
1209{
1210 "results": [
1211 {"ip":"9.9.9.9","port":3128,"type":"http"}
1212 ]
1213}
1214"#;
1215
1216 let proxies = fetcher.parse_payload(body)?;
1217 assert_eq!(proxies.len(), 1);
1218 assert_eq!(
1219 proxies.first().map(|proxy| proxy.url.as_str()),
1220 Some("http://9.9.9.9:3128")
1221 );
1222 Ok(())
1223 }
1224
1225 #[test]
1226 fn free_list_fetcher_parse_valid_lines() {
1227 let fetcher = FreeListFetcher::new(vec![]);
1228 let text = "1.2.3.4:8080\n# comment\n\nbad-line\n5.6.7.8:3128\n[2001:db8::1]:8081\n";
1230 let parsed: Vec<Proxy> = text
1231 .lines()
1232 .filter_map(|line| {
1233 let (host, port) = FreeListFetcher::parse_host_port_line(line)?;
1234 Some(Proxy {
1235 url: format!("http://{host}:{port}"),
1236 proxy_type: ProxyType::Http,
1237 username: None,
1238 password: None,
1239 weight: 1,
1240 tags: fetcher.tags.clone(),
1241 capabilities: crate::types::ProxyCapabilities::default(),
1242 })
1243 })
1244 .collect();
1245
1246 assert_eq!(parsed.len(), 3);
1247 assert_eq!(
1248 parsed.first().map(|proxy| proxy.url.as_str()),
1249 Some("http://1.2.3.4:8080")
1250 );
1251 assert_eq!(
1252 parsed.get(1).map(|proxy| proxy.url.as_str()),
1253 Some("http://5.6.7.8:3128")
1254 );
1255 assert_eq!(
1256 parsed.get(2).map(|proxy| proxy.url.as_str()),
1257 Some("http://[2001:db8::1]:8081")
1258 );
1259 }
1260
1261 #[test]
1262 fn free_list_fetcher_with_tags_extends() {
1263 let f = FreeListFetcher::new(vec![]).with_tags(vec!["custom".into()]);
1264 assert!(f.tags.contains(&"free-list".to_string()));
1265 assert!(f.tags.contains(&"custom".to_string()));
1266 }
1267
1268 #[test]
1269 fn free_list_fetcher_skips_invalid_port() {
1270 assert!(FreeListFetcher::parse_host_port_line("1.2.3.4:notaport").is_none());
1271 assert!(FreeListFetcher::parse_host_port_line("1.2.3.4:0").is_none());
1272 assert!(FreeListFetcher::parse_host_port_line(":8080").is_none());
1273 assert!(FreeListFetcher::parse_host_port_line("2001:db8::1:8080").is_none());
1274 }
1275
1276 #[test]
1277 fn free_list_fetcher_empty_sources_is_config_error()
1278 -> std::result::Result<(), Box<dyn std::error::Error>> {
1279 let fetcher = FreeListFetcher::new(vec![]);
1280 let rt = tokio::runtime::Builder::new_current_thread()
1281 .enable_time()
1282 .build()
1283 .map_err(|e| std::io::Error::other(format!("failed to build runtime for test: {e}")))?;
1284 let err = rt
1285 .block_on(fetcher.fetch())
1286 .err()
1287 .ok_or_else(|| std::io::Error::other("empty sources should fail"))?;
1288 match err {
1289 ProxyError::ConfigError(msg) => {
1290 assert!(msg.contains("no sources configured"));
1291 }
1292 other => {
1293 return Err(
1294 std::io::Error::other(format!("unexpected error variant: {other}")).into(),
1295 );
1296 }
1297 }
1298 Ok(())
1299 }
1300
1301 #[test]
1302 fn proxy_error_fetch_failed_display() {
1303 let e = ProxyError::FetchFailed {
1304 origin: "https://example.com".into(),
1305 message: "timed out".into(),
1306 };
1307 assert!(e.to_string().contains("https://example.com"));
1308 assert!(e.to_string().contains("timed out"));
1309 }
1310}