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]
85pub trait ProxyFetcher: Send + Sync {
86 async fn fetch(&self) -> ProxyResult<Vec<Proxy>>;
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
110#[non_exhaustive]
111pub enum FreeListSource {
112 TheSpeedXHttp,
114 #[cfg(feature = "socks")]
115 TheSpeedXSocks4,
117 #[cfg(feature = "socks")]
118 TheSpeedXSocks5,
120 ClarketmHttp,
122 OpenProxyListHttp,
124 Custom {
126 url: String,
128 proxy_type: ProxyType,
130 },
131}
132
133impl FreeListSource {
134 const fn url(&self) -> &str {
135 match self {
136 Self::TheSpeedXHttp => {
137 "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/http.txt"
138 }
139 #[cfg(feature = "socks")]
140 Self::TheSpeedXSocks4 => {
141 "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/socks4.txt"
142 }
143 #[cfg(feature = "socks")]
144 Self::TheSpeedXSocks5 => {
145 "https://raw.githubusercontent.com/TheSpeedX/PROXY-List/master/socks5.txt"
146 }
147 Self::ClarketmHttp => {
148 "https://raw.githubusercontent.com/clarketm/proxy-list/master/proxy-list-raw.txt"
149 }
150 Self::OpenProxyListHttp => "https://openproxylist.xyz/http.txt",
151 Self::Custom { url, .. } => url.as_str(),
152 }
153 }
154
155 const fn proxy_type(&self) -> ProxyType {
156 match self {
157 Self::TheSpeedXHttp | Self::ClarketmHttp | Self::OpenProxyListHttp => ProxyType::Http,
158 #[cfg(feature = "socks")]
159 Self::TheSpeedXSocks4 => ProxyType::Socks4,
160 #[cfg(feature = "socks")]
161 Self::TheSpeedXSocks5 => ProxyType::Socks5,
162 Self::Custom { proxy_type, .. } => *proxy_type,
163 }
164 }
165}
166
167pub struct FreeListFetcher {
189 sources: Vec<FreeListSource>,
190 client: Client,
191 tags: Vec<String>,
192}
193
194impl FreeListFetcher {
195 #[must_use]
205 pub fn new(sources: Vec<FreeListSource>) -> Self {
206 let client = Client::builder()
207 .timeout(Duration::from_secs(10))
208 .build()
209 .unwrap_or_else(|e| {
210 warn!("Failed to build HTTP client with 10 s timeout (TLS backend issue?): {e}; falling back to default client with per-request timeout enforcement");
211 Client::default()
212 });
213 Self {
214 sources,
215 client,
216 tags: vec!["free-list".into()],
217 }
218 }
219
220 #[cfg(feature = "tls-profiled")]
240 #[must_use]
241 pub fn with_profiled_client(
242 mut self,
243 requester: crate::http_client::ProfiledRequester,
244 ) -> Self {
245 self.client = requester.client().clone();
246 drop(requester);
247 self
248 }
249
250 #[cfg(feature = "tls-profiled")]
262 pub fn with_profiled_mode(
263 self,
264 mode: crate::types::ProfiledRequestMode,
265 ) -> crate::error::ProxyResult<Self> {
266 let requester = crate::http_client::ProfiledRequester::chrome_mode(mode)
267 .map_err(|e| crate::error::ProxyError::ConfigError(e.to_string()))?;
268 Ok(self.with_profiled_client(requester))
269 }
270
271 #[must_use]
281 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
282 self.tags.extend(tags);
283 self
284 }
285
286 fn parse_host_port_line(line: &str) -> Option<(String, u16)> {
288 let line = line.trim();
289 if line.is_empty() || line.starts_with('#') {
290 return None;
291 }
292
293 let (host, port_str) = if line.starts_with('[') {
294 let end = line.find(']')?;
295 let host = line.get(..=end)?.trim();
296 let remainder = line.get(end + 1..)?.trim();
297 let (_, port_str) = remainder.rsplit_once(':')?;
298 (host, port_str.trim())
299 } else {
300 let (host, port_str) = line.rsplit_once(':')?;
301 let host = host.trim();
302 if host.contains(':') {
303 return None;
304 }
305 (host, port_str.trim())
306 };
307
308 if host.is_empty() || host == "[]" {
309 return None;
310 }
311
312 let port = port_str.parse::<u16>().ok()?;
313 if port == 0 {
314 return None;
315 }
316
317 Some((host.to_string(), port))
318 }
319
320 async fn fetch_source(&self, source: &FreeListSource) -> Vec<Proxy> {
322 let url = source.url();
323 let proxy_type = source.proxy_type();
324
325 let body = match self
326 .client
327 .get(url)
328 .timeout(Duration::from_secs(10))
329 .send()
330 .await
331 {
332 Ok(resp) if resp.status().is_success() => match resp.text().await {
333 Ok(t) => t,
334 Err(e) => {
335 warn!("Failed to read body from {url}: {e}");
336 return vec![];
337 }
338 },
339 Ok(resp) => {
340 warn!(
341 "Non-success status {} fetching proxy list from {url}",
342 resp.status()
343 );
344 return vec![];
345 }
346 Err(e) => {
347 warn!("Failed to fetch proxy list from {url}: {e}");
348 return vec![];
349 }
350 };
351
352 let proxies: Vec<Proxy> = body
353 .lines()
354 .filter_map(|line| {
355 let (host, port) = Self::parse_host_port_line(line)?;
356 let scheme = match proxy_type {
357 ProxyType::Http => "http",
358 ProxyType::Https => "https",
359 #[cfg(feature = "socks")]
360 ProxyType::Socks4 => "socks4",
361 #[cfg(feature = "socks")]
362 ProxyType::Socks5 => "socks5",
363 ProxyType::CdnEdge => "https",
364 };
365 Some(Proxy {
366 url: format!("{scheme}://{host}:{port}"),
367 proxy_type,
368 username: None,
369 password: None,
370 weight: 1,
371 tags: self.tags.clone(),
372 capabilities: crate::types::ProxyCapabilities {
373 is_cdn_edge: matches!(proxy_type, ProxyType::CdnEdge),
374 ..Default::default()
375 },
376 ip_class: crate::types::IpClass::Datacenter,
377 target_compatibility: crate::types::TargetVendorCompatibility::default_blocked(
378 ),
379 })
380 })
381 .collect();
382
383 debug!(source = url, count = proxies.len(), "Fetched proxy list");
384 proxies
385 }
386}
387
388#[async_trait]
389impl ProxyFetcher for FreeListFetcher {
390 async fn fetch(&self) -> ProxyResult<Vec<Proxy>> {
391 if self.sources.is_empty() {
392 return Err(ProxyError::ConfigError(
393 "no sources configured for FreeListFetcher".into(),
394 ));
395 }
396
397 let results = join_all(self.sources.iter().map(|s| self.fetch_source(s))).await;
399 let all: Vec<Proxy> = results.into_iter().flatten().collect();
400
401 if all.is_empty() {
402 return Err(ProxyError::FetchFailed {
403 origin: self
404 .sources
405 .iter()
406 .map(FreeListSource::url)
407 .collect::<Vec<_>>()
408 .join(", "),
409 message: "all sources returned empty or failed".into(),
410 });
411 }
412
413 Ok(all)
414 }
415}
416
417pub struct FreeApiProxiesFetcher {
442 endpoint: String,
443 client: Client,
444 tags: Vec<String>,
445 limit: Option<u32>,
447 protocol_filter: Option<String>,
449 country_filter: Option<String>,
451}
452
453#[derive(Debug, Deserialize)]
454#[serde(untagged)]
455enum FreeApiProxiesResponse {
456 List(Vec<FreeApiProxyRecord>),
457 Data { data: Vec<FreeApiProxyRecord> },
458 Results { results: Vec<FreeApiProxyRecord> },
459}
460
461impl FreeApiProxiesResponse {
462 fn into_records(self) -> Vec<FreeApiProxyRecord> {
463 match self {
464 Self::List(records)
465 | Self::Data { data: records }
466 | Self::Results { results: records } => records,
467 }
468 }
469}
470
471#[derive(Debug, Deserialize)]
472struct FreeApiProxyRecord {
473 #[serde(default, alias = "ip", alias = "host")]
474 address_host: String,
475 #[serde(default)]
476 port: Option<u16>,
477 #[serde(default, alias = "proxy", alias = "address")]
478 address: Option<String>,
479 #[serde(default, alias = "protocol", alias = "type", alias = "proxy_type")]
480 protocol: Option<String>,
481 #[serde(default)]
482 username: Option<String>,
483 #[serde(default)]
484 password: Option<String>,
485 #[serde(default, alias = "countryCode", alias = "country_code")]
486 country_code: Option<String>,
487}
488
489impl FreeApiProxiesFetcher {
490 const DEFAULT_ENDPOINT: &str = "https://freeapiproxies.azurewebsites.net/";
491
492 #[must_use]
494 pub fn new() -> Self {
495 Self::with_endpoint(Self::DEFAULT_ENDPOINT)
496 }
497
498 #[must_use]
500 pub fn with_endpoint(endpoint: impl Into<String>) -> Self {
501 let client = Client::builder()
502 .timeout(Duration::from_secs(10))
503 .build()
504 .unwrap_or_else(|e| {
505 warn!("Failed to build HTTP client with 10 s timeout (TLS backend issue?): {e}; falling back to default client with per-request timeout enforcement");
506 Client::default()
507 });
508
509 Self {
510 endpoint: endpoint.into(),
511 client,
512 tags: vec!["freeapiproxies".into()],
513 limit: None,
514 protocol_filter: None,
515 country_filter: None,
516 }
517 }
518
519 #[must_use]
521 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
522 self.tags.extend(tags);
523 self
524 }
525
526 #[must_use]
537 pub const fn with_limit(mut self, limit: u32) -> Self {
538 self.limit = Some(limit);
539 self
540 }
541
542 #[must_use]
554 pub fn with_protocol_filter(mut self, protocol: impl Into<String>) -> Self {
555 self.protocol_filter = Some(protocol.into());
556 self
557 }
558
559 #[must_use]
570 pub fn with_country_filter(mut self, country_code: impl Into<String>) -> Self {
571 self.country_filter = Some(country_code.into().to_ascii_uppercase());
572 self
573 }
574
575 fn request_url(&self) -> String {
577 let mut params: Vec<(&str, String)> = Vec::new();
578 if let Some(limit) = self.limit {
579 params.push(("limit", limit.to_string()));
580 }
581 if let Some(ref protocol) = self.protocol_filter {
582 params.push(("protocol", protocol.clone()));
583 }
584 if let Some(ref country) = self.country_filter {
585 params.push(("country", country.clone()));
586 }
587 if params.is_empty() {
588 return self.endpoint.clone();
589 }
590 let qs = params
591 .iter()
592 .enumerate()
593 .fold(String::new(), |mut acc, (i, (k, v))| {
594 use std::fmt::Write as _;
595 let sep = if i == 0 { "?" } else { "&" };
596 let _ = write!(acc, "{sep}{k}={v}");
597 acc
598 });
599 format!("{}{qs}", self.endpoint)
600 }
601
602 fn protocol_to_proxy_type(protocol: Option<&str>) -> Option<ProxyType> {
603 let normalized = protocol.map(str::trim).map(str::to_ascii_lowercase);
604 match normalized.as_deref() {
605 None | Some("" | "http") => Some(ProxyType::Http),
606 Some("https") => Some(ProxyType::Https),
607 Some("cdn" | "cdn_edge") => Some(ProxyType::CdnEdge),
608 #[cfg(feature = "socks")]
609 Some("socks" | "socks5") => Some(ProxyType::Socks5),
610 #[cfg(feature = "socks")]
611 Some("socks4") => Some(ProxyType::Socks4),
612 _ => None,
613 }
614 }
615
616 fn parse_address(record: &FreeApiProxyRecord) -> Option<(String, u16)> {
617 if let Some(address) = record.address.as_deref() {
618 if let Some((host, port)) = FreeListFetcher::parse_host_port_line(address) {
619 return Some((host, port));
620 }
621
622 if let Ok(url) = reqwest::Url::parse(address)
623 && let Some(port) = url.port_or_known_default()
624 {
625 return Some((url.host_str()?.to_string(), port));
626 }
627 }
628
629 let host = record.address_host.trim();
630 let port = record.port?;
631 if host.is_empty() || port == 0 {
632 return None;
633 }
634 Some((host.to_string(), port))
635 }
636
637 fn record_to_proxy(&self, record: FreeApiProxyRecord) -> Option<Proxy> {
638 let proxy_type = Self::protocol_to_proxy_type(record.protocol.as_deref())?;
639 let (host, port) = Self::parse_address(&record)?;
640
641 let scheme = match proxy_type {
642 ProxyType::Http => "http",
643 ProxyType::Https => "https",
644 #[cfg(feature = "socks")]
645 ProxyType::Socks4 => "socks4",
646 #[cfg(feature = "socks")]
647 ProxyType::Socks5 => "socks5",
648 ProxyType::CdnEdge => "https",
649 };
650
651 let mut tags = self.tags.clone();
652 if let Some(country_code) = record.country_code.as_deref()
653 && !country_code.trim().is_empty()
654 {
655 tags.push(format!(
656 "country:{}",
657 country_code.trim().to_ascii_uppercase()
658 ));
659 }
660
661 Some(Proxy {
662 url: format!("{scheme}://{host}:{port}"),
663 proxy_type,
664 username: record.username.filter(|v| !v.trim().is_empty()),
665 password: record.password.filter(|v| !v.trim().is_empty()),
666 weight: 1,
667 tags,
668 capabilities: crate::types::ProxyCapabilities {
669 is_cdn_edge: matches!(proxy_type, ProxyType::CdnEdge),
670 ..Default::default()
671 },
672 ip_class: crate::types::IpClass::Datacenter,
673 target_compatibility: crate::types::TargetVendorCompatibility::default_blocked(),
674 })
675 }
676
677 fn parse_payload(&self, body: &str) -> ProxyResult<Vec<Proxy>> {
678 let response: FreeApiProxiesResponse =
679 serde_json::from_str(body).map_err(|e| ProxyError::FetchFailed {
680 origin: self.endpoint.clone(),
681 message: format!("invalid freeapiproxies json payload: {e}"),
682 })?;
683
684 let proxies: Vec<Proxy> = response
685 .into_records()
686 .into_iter()
687 .filter_map(|record| self.record_to_proxy(record))
688 .collect();
689
690 if proxies.is_empty() {
691 return Err(ProxyError::FetchFailed {
692 origin: self.endpoint.clone(),
693 message: "freeapiproxies payload contained no usable proxies".into(),
694 });
695 }
696
697 Ok(proxies)
698 }
699}
700
701impl Default for FreeApiProxiesFetcher {
702 fn default() -> Self {
703 Self::new()
704 }
705}
706
707#[async_trait]
708impl ProxyFetcher for FreeApiProxiesFetcher {
709 async fn fetch(&self) -> ProxyResult<Vec<Proxy>> {
710 let url = self.request_url();
711 let body = self
712 .client
713 .get(&url)
714 .timeout(Duration::from_secs(10))
715 .send()
716 .await
717 .map_err(|e| ProxyError::FetchFailed {
718 origin: url.clone(),
719 message: e.to_string(),
720 })?
721 .error_for_status()
722 .map_err(|e| ProxyError::FetchFailed {
723 origin: url.clone(),
724 message: e.to_string(),
725 })?
726 .text()
727 .await
728 .map_err(|e| ProxyError::FetchFailed {
729 origin: url.clone(),
730 message: e.to_string(),
731 })?;
732
733 self.parse_payload(&body)
734 }
735}
736
737pub async fn load_from_fetcher(
767 manager: &ProxyManager,
768 fetcher: &dyn ProxyFetcher,
769) -> ProxyResult<usize> {
770 let proxies = fetcher.fetch().await?;
771 let total = proxies.len();
772 let mut loaded = 0usize;
773
774 for proxy in proxies {
775 match manager.add_proxy(proxy).await {
776 Ok(_) => loaded += 1,
777 Err(e) => warn!("Skipped proxy during load: {e}"),
778 }
779 }
780
781 debug!(total, loaded, "Proxy list loaded into manager");
782 Ok(loaded)
783}
784
785#[cfg(feature = "dns-fetcher")]
818pub struct DnsTxtFetcher {
819 zone: String,
820 allowed_zone_suffixes: Vec<String>,
821 lookup_timeout: Duration,
822 tags: Vec<String>,
823}
824
825#[cfg(feature = "dns-fetcher")]
826impl DnsTxtFetcher {
827 const DEFAULT_LOOKUP_TIMEOUT: Duration = Duration::from_secs(5);
828 const MAX_JOINED_TXT_RECORD_LEN: usize = 2 * 1024;
829
830 pub fn new(zone: impl Into<String>) -> Self {
834 Self {
835 zone: zone.into().trim().to_string(),
836 allowed_zone_suffixes: Vec::new(),
837 lookup_timeout: Self::DEFAULT_LOOKUP_TIMEOUT,
838 tags: vec!["dns-txt".into()],
839 }
840 }
841
842 #[must_use]
857 pub fn with_allowed_zone_suffixes(mut self, suffixes: Vec<String>) -> Self {
858 self.allowed_zone_suffixes = suffixes;
859 self
860 }
861
862 #[must_use]
874 pub const fn with_lookup_timeout(mut self, timeout: Duration) -> Self {
875 self.lookup_timeout = timeout;
876 self
877 }
878
879 #[must_use]
881 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
882 self.tags.extend(tags);
883 self
884 }
885
886 fn normalize_zone(value: &str) -> String {
887 value.trim().trim_end_matches('.').to_ascii_lowercase()
888 }
889
890 fn validate_dns_zone(value: &str) -> bool {
891 let zone = Self::normalize_zone(value);
892 if zone.is_empty() || zone.len() > 253 {
893 return false;
894 }
895
896 for label in zone.split('.') {
897 if label.is_empty() || label.len() > 63 {
898 return false;
899 }
900
901 let bytes = label.as_bytes();
902 let first = bytes.first().copied();
903 let last = bytes.last().copied();
904 if first == Some(b'-') || last == Some(b'-') {
905 return false;
906 }
907
908 if !bytes
909 .iter()
910 .copied()
911 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
912 {
913 return false;
914 }
915 }
916
917 true
918 }
919
920 fn zone_allowed(&self, zone: &str) -> bool {
921 if self.allowed_zone_suffixes.is_empty() {
922 return true;
923 }
924
925 let zone = Self::normalize_zone(zone);
926 self.allowed_zone_suffixes.iter().any(|suffix| {
927 let suffix = Self::normalize_zone(suffix);
928 zone == suffix || zone.ends_with(&format!(".{suffix}"))
929 })
930 }
931
932 fn parse_record(&self, record: &str) -> Option<Proxy> {
941 let record = record.trim();
942 if record.is_empty() || record.starts_with('#') {
943 return None;
944 }
945 let (host, port, remainder) = Self::parse_host_port_remainder(record)?;
946 if host.is_empty() || port == 0 {
947 return None;
948 }
949 let parts: Vec<&str> = remainder.splitn(4, ':').collect();
952 let type_str = parts.first().map_or("http", |s| s.trim());
953 match type_str.to_ascii_lowercase().as_str() {
954 "cdn_edge" | "cdn" => Some(self.build_cdn_edge_proxy(&host, port, &parts)),
955 type_str => Some(self.build_typed_proxy(&host, port, type_str, &parts)),
956 }
957 }
958
959 fn parse_host_port_remainder(record: &str) -> Option<(String, u16, &str)> {
962 if let Some(rest) = record.strip_prefix('[') {
963 let end = rest.find(']')?;
964 let host = format!("[{}]", rest.get(..end)?);
965 let after = rest.get(end + 1..).unwrap_or("").trim_start_matches(':');
966 let colon = after.find(':').unwrap_or(after.len());
967 let port: u16 = after.get(..colon)?.trim().parse().ok()?;
968 let rem = after.get(colon + 1..).unwrap_or("");
969 Some((host, port, rem))
970 } else {
971 let first = record.find(':')?;
972 let host = record.get(..first)?.trim().to_string();
973 let rest = record.get(first + 1..)?;
974 let second = rest.find(':').unwrap_or(rest.len());
975 let port: u16 = rest.get(..second)?.trim().parse().ok()?;
976 let rem = rest.get(second + 1..).unwrap_or("");
977 Some((host, port, rem))
978 }
979 }
980
981 fn build_cdn_edge_proxy(&self, host: &str, port: u16, parts: &[&str]) -> Proxy {
985 let provider = parts
986 .get(1)
987 .copied()
988 .map(str::trim)
989 .filter(|s| !s.is_empty())
990 .map(str::to_string);
991 let ip_class = parts
992 .get(2)
993 .copied()
994 .map(str::trim)
995 .and_then(crate::types::IpClass::from_label)
996 .unwrap_or(crate::types::IpClass::Datacenter);
997 Proxy {
998 url: format!("https://{host}:{port}"),
999 proxy_type: ProxyType::CdnEdge,
1000 username: None,
1001 password: None,
1002 weight: 1,
1003 tags: self.tags.clone(),
1004 capabilities: crate::types::ProxyCapabilities {
1005 is_cdn_edge: true,
1006 cdn_provider: provider,
1007 ..Default::default()
1008 },
1009 ip_class,
1010 target_compatibility: crate::types::TargetVendorCompatibility::default_blocked(),
1011 }
1012 }
1013
1014 fn build_typed_proxy(&self, host: &str, port: u16, type_str: &str, parts: &[&str]) -> Proxy {
1019 let proxy_type = match type_str {
1020 "https" => ProxyType::Https,
1021 #[cfg(feature = "socks")]
1022 "socks5" | "socks" => ProxyType::Socks5,
1023 #[cfg(feature = "socks")]
1024 "socks4" => ProxyType::Socks4,
1025 _ => ProxyType::Http,
1026 };
1027 let scheme = match proxy_type {
1028 ProxyType::Http => "http",
1029 ProxyType::Https => "https",
1030 #[cfg(feature = "socks")]
1031 ProxyType::Socks4 => "socks4",
1032 #[cfg(feature = "socks")]
1033 ProxyType::Socks5 => "socks5",
1034 ProxyType::CdnEdge => "https",
1035 };
1036 let username = parts
1037 .get(1)
1038 .copied()
1039 .map(str::trim)
1040 .filter(|s| !s.is_empty())
1041 .map(str::to_string);
1042 let password = parts
1043 .get(2)
1044 .copied()
1045 .map(str::trim)
1046 .filter(|s| !s.is_empty())
1047 .map(str::to_string);
1048 let ip_class = parts
1049 .get(3)
1050 .copied()
1051 .map(str::trim)
1052 .and_then(crate::types::IpClass::from_label)
1053 .unwrap_or(crate::types::IpClass::Datacenter);
1054 Proxy {
1055 url: format!("{scheme}://{host}:{port}"),
1056 proxy_type,
1057 username,
1058 password,
1059 weight: 1,
1060 tags: self.tags.clone(),
1061 capabilities: crate::types::ProxyCapabilities::default(),
1062 ip_class,
1063 target_compatibility: crate::types::TargetVendorCompatibility::default_blocked(),
1064 }
1065 }
1066}
1067
1068#[cfg(feature = "dns-fetcher")]
1069#[async_trait]
1070impl ProxyFetcher for DnsTxtFetcher {
1071 async fn fetch(&self) -> ProxyResult<Vec<Proxy>> {
1072 use hickory_resolver::TokioResolver;
1073 use tokio::time::timeout;
1074
1075 let zone = Self::normalize_zone(&self.zone);
1076 if !Self::validate_dns_zone(&zone) {
1077 return Err(ProxyError::ConfigError(format!(
1078 "invalid DNS zone for DnsTxtFetcher: '{}'",
1079 self.zone
1080 )));
1081 }
1082
1083 for suffix in &self.allowed_zone_suffixes {
1084 if !Self::validate_dns_zone(suffix) {
1085 return Err(ProxyError::ConfigError(format!(
1086 "invalid allowed DNS zone suffix for DnsTxtFetcher: '{suffix}'"
1087 )));
1088 }
1089 }
1090
1091 if !self.zone_allowed(&zone) {
1092 return Err(ProxyError::FetchFailed {
1093 origin: zone.clone(),
1094 message: format!("DNS zone '{zone}' rejected by trusted suffix policy"),
1095 });
1096 }
1097
1098 let resolver = TokioResolver::builder_tokio()
1099 .map_err(|e| ProxyError::ConfigError(format!("DNS resolver init failed: {e}")))?
1100 .build()
1101 .map_err(|e| ProxyError::ConfigError(format!("DNS resolver build failed: {e}")))?;
1102
1103 let lookup = timeout(self.lookup_timeout, resolver.txt_lookup(zone.as_str()))
1104 .await
1105 .map_err(|_| ProxyError::FetchFailed {
1106 origin: zone.clone(),
1107 message: format!(
1108 "DNS TXT lookup timed out for '{}' after {:?}",
1109 zone, self.lookup_timeout
1110 ),
1111 })?
1112 .map_err(|e| ProxyError::FetchFailed {
1113 origin: zone.clone(),
1114 message: format!("DNS TXT lookup failed for '{zone}': {e}"),
1115 })?;
1116
1117 let mut proxies: Vec<Proxy> = Vec::new();
1118 for record in lookup.answers() {
1119 let hickory_resolver::proto::rr::RData::TXT(txt) = &record.data else {
1122 continue;
1123 };
1124 let mut record_str = String::new();
1125 let mut skipped_for_size = false;
1126 for bytes in &txt.txt_data {
1127 if let Ok(fragment) = std::str::from_utf8(bytes) {
1128 if record_str.len().saturating_add(fragment.len())
1129 > Self::MAX_JOINED_TXT_RECORD_LEN
1130 {
1131 skipped_for_size = true;
1132 break;
1133 }
1134 record_str.push_str(fragment);
1135 }
1136 }
1137 if skipped_for_size {
1138 warn!(
1139 zone = %zone,
1140 max_len = Self::MAX_JOINED_TXT_RECORD_LEN,
1141 "skipping oversized DNS TXT record",
1142 );
1143 continue;
1144 }
1145
1146 if let Some(proxy) = self.parse_record(&record_str) {
1147 proxies.push(proxy);
1148 }
1149 }
1150
1151 if proxies.is_empty() {
1152 return Err(ProxyError::FetchFailed {
1153 origin: zone.clone(),
1154 message: format!("no valid proxy records found in DNS TXT for '{zone}'"),
1155 });
1156 }
1157
1158 debug!(
1159 zone = %zone,
1160 count = proxies.len(),
1161 "fetched proxy list from DNS TXT",
1162 );
1163 Ok(proxies)
1164 }
1165}
1166
1167#[cfg(test)]
1170#[allow(
1171 clippy::unwrap_used,
1172 clippy::expect_used,
1173 clippy::panic,
1174 clippy::indexing_slicing
1175)] mod tests {
1177 use super::*;
1178
1179 #[cfg(feature = "dns-fetcher")]
1182 #[allow(
1183 clippy::unwrap_used,
1184 clippy::expect_used,
1185 clippy::panic,
1186 clippy::indexing_slicing
1187 )]
1188 mod dns_txt {
1189 use super::*;
1190
1191 fn fetcher() -> DnsTxtFetcher {
1192 DnsTxtFetcher::new("proxies.example.com")
1193 }
1194
1195 #[test]
1196 fn parse_http_host_port() {
1197 let proxy = fetcher().parse_record("10.0.1.5:8080").unwrap();
1198 assert_eq!(proxy.url, "http://10.0.1.5:8080");
1199 assert_eq!(proxy.proxy_type, ProxyType::Http);
1200 assert!(proxy.username.is_none());
1201 assert!(proxy.password.is_none());
1202 }
1203
1204 #[test]
1205 fn parse_https_record() {
1206 let proxy = fetcher().parse_record("10.0.1.5:443:https").unwrap();
1207 assert_eq!(proxy.url, "https://10.0.1.5:443");
1208 assert_eq!(proxy.proxy_type, ProxyType::Https);
1209 }
1210
1211 #[test]
1212 fn parse_cdn_edge_with_provider() {
1213 let proxy = fetcher()
1214 .parse_record("edge.cdn.example.com:443:cdn_edge:cloudflare")
1215 .unwrap();
1216 assert_eq!(proxy.url, "https://edge.cdn.example.com:443");
1217 assert_eq!(proxy.proxy_type, ProxyType::CdnEdge);
1218 assert!(proxy.capabilities.is_cdn_edge);
1219 assert_eq!(
1220 proxy.capabilities.cdn_provider.as_deref(),
1221 Some("cloudflare")
1222 );
1223 }
1224
1225 #[test]
1226 fn parse_cdn_edge_without_provider() {
1227 let proxy = fetcher()
1228 .parse_record("cdn.example.com:443:cdn_edge")
1229 .unwrap();
1230 assert!(proxy.capabilities.is_cdn_edge);
1231 assert!(proxy.capabilities.cdn_provider.is_none());
1232 }
1233
1234 #[test]
1235 fn parse_auth_fields() {
1236 let proxy = fetcher()
1237 .parse_record("10.0.0.1:3128:http:alice:secret")
1238 .unwrap();
1239 assert_eq!(proxy.username.as_deref(), Some("alice"));
1240 assert_eq!(proxy.password.as_deref(), Some("secret"));
1241 }
1242
1243 #[test]
1244 fn parse_ipv6_bracketed() {
1245 let proxy = fetcher().parse_record("[::1]:8080").unwrap();
1246 assert_eq!(proxy.url, "http://[::1]:8080");
1247 }
1248
1249 #[test]
1250 fn parse_empty_record_returns_none() {
1251 assert!(fetcher().parse_record("").is_none());
1252 assert!(fetcher().parse_record(" ").is_none());
1253 }
1254
1255 #[test]
1256 fn parse_comment_record_returns_none() {
1257 assert!(fetcher().parse_record("# comment line").is_none());
1258 }
1259
1260 #[test]
1261 fn parse_invalid_port_returns_none() {
1262 assert!(fetcher().parse_record("10.0.0.1:notaport").is_none());
1263 }
1264
1265 #[test]
1269 fn parse_record_with_mobile_ip_class_tag() {
1270 let proxy = fetcher()
1272 .parse_record("10.0.0.1:3128:http:alice:secret:mobile")
1273 .unwrap();
1274 assert_eq!(proxy.username.as_deref(), Some("alice"));
1275 assert_eq!(proxy.password.as_deref(), Some("secret"));
1276 assert_eq!(proxy.ip_class, crate::types::IpClass::Mobile);
1277 }
1278
1279 #[test]
1280 fn parse_record_with_isp_ip_class_tag() {
1281 let proxy = fetcher().parse_record("10.0.0.1:3128:http:::isp").unwrap();
1288 assert_eq!(proxy.ip_class, crate::types::IpClass::Isp);
1289 assert!(proxy.username.is_none());
1291 assert!(proxy.password.is_none());
1292 }
1293
1294 #[test]
1295 fn parse_record_without_ip_class_defaults_to_datacenter() {
1296 let proxy = fetcher().parse_record("10.0.0.1:3128").unwrap();
1297 assert_eq!(proxy.ip_class, crate::types::IpClass::Datacenter);
1298 assert_eq!(
1300 proxy
1301 .target_compatibility
1302 .get(crate::types::VendorId::DataDome),
1303 Some(crate::types::TrustTier::Blocked)
1304 );
1305 }
1306
1307 #[test]
1308 fn parse_record_with_unknown_ip_class_label_defaults_to_datacenter() {
1309 let proxy = fetcher()
1313 .parse_record("10.0.0.1:3128:http:::quantum")
1314 .unwrap();
1315 assert_eq!(proxy.ip_class, crate::types::IpClass::Datacenter);
1316 }
1317
1318 #[test]
1319 fn parse_cdn_edge_with_ip_class_tag() {
1320 let proxy = fetcher()
1322 .parse_record("edge.example.com:443:cdn_edge:cloudflare:mobile")
1323 .unwrap();
1324 assert!(proxy.capabilities.is_cdn_edge);
1325 assert_eq!(
1326 proxy.capabilities.cdn_provider.as_deref(),
1327 Some("cloudflare")
1328 );
1329 assert_eq!(proxy.ip_class, crate::types::IpClass::Mobile);
1330 }
1331
1332 #[test]
1333 fn parse_cdn_edge_without_ip_class_defaults_to_datacenter() {
1334 let proxy = fetcher()
1335 .parse_record("edge.example.com:443:cdn_edge:cloudflare")
1336 .unwrap();
1337 assert!(proxy.capabilities.is_cdn_edge);
1338 assert_eq!(proxy.ip_class, crate::types::IpClass::Datacenter);
1339 }
1340
1341 #[test]
1342 fn validate_dns_zone_accepts_valid_names() {
1343 assert!(DnsTxtFetcher::validate_dns_zone(
1344 "proxies.internal.example.com"
1345 ));
1346 assert!(DnsTxtFetcher::validate_dns_zone(
1347 "PROXIES.INTERNAL.EXAMPLE.COM"
1348 ));
1349 assert!(DnsTxtFetcher::validate_dns_zone(
1350 "proxy-1.internal.example.com"
1351 ));
1352 assert!(DnsTxtFetcher::validate_dns_zone(
1353 "proxy.internal.example.com."
1354 ));
1355 }
1356
1357 #[test]
1358 fn validate_dns_zone_rejects_invalid_names() {
1359 assert!(!DnsTxtFetcher::validate_dns_zone(""));
1360 assert!(!DnsTxtFetcher::validate_dns_zone(" "));
1361 assert!(!DnsTxtFetcher::validate_dns_zone("-bad.example.com"));
1362 assert!(!DnsTxtFetcher::validate_dns_zone("bad-.example.com"));
1363 assert!(!DnsTxtFetcher::validate_dns_zone("bad..example.com"));
1364 assert!(!DnsTxtFetcher::validate_dns_zone("bad_zone.example.com"));
1365 }
1366
1367 #[test]
1368 fn zone_allowed_matches_exact_or_child_suffix() {
1369 let fetcher = DnsTxtFetcher::new("proxies.internal.example.com")
1370 .with_allowed_zone_suffixes(vec!["internal.example.com".to_string()]);
1371 assert!(fetcher.zone_allowed("internal.example.com"));
1372 assert!(fetcher.zone_allowed("proxies.internal.example.com"));
1373 assert!(!fetcher.zone_allowed("example.com"));
1374 assert!(!fetcher.zone_allowed("evilinternal.example.com"));
1375 }
1376
1377 #[test]
1378 fn normalize_zone_trims_and_lowercases() {
1379 assert_eq!(
1380 DnsTxtFetcher::normalize_zone(" Proxies.Internal.Example.Com. "),
1381 "proxies.internal.example.com"
1382 );
1383 }
1384 }
1385
1386 #[test]
1387 fn free_api_proxies_fetcher_request_url_no_params() {
1388 let f = FreeApiProxiesFetcher::with_endpoint("https://example.test/api");
1389 assert_eq!(f.request_url(), "https://example.test/api");
1390 }
1391
1392 #[test]
1393 fn free_api_proxies_fetcher_request_url_with_params() {
1394 let f = FreeApiProxiesFetcher::with_endpoint("https://example.test/api")
1395 .with_limit(50)
1396 .with_protocol_filter("http")
1397 .with_country_filter("us");
1398 let url = f.request_url();
1399 assert!(url.contains("limit=50"), "expected limit param in {url}");
1400 assert!(
1401 url.contains("protocol=http"),
1402 "expected protocol param in {url}"
1403 );
1404 assert!(
1405 url.contains("country=US"),
1406 "expected country uppercased in {url}"
1407 );
1408 assert!(url.starts_with("https://example.test/api?"), "missing ?");
1409 }
1410
1411 #[test]
1412 fn free_api_proxies_fetcher_country_filter_uppercased() {
1413 let f = FreeApiProxiesFetcher::new().with_country_filter("de");
1414 assert_eq!(f.country_filter.as_deref(), Some("DE"));
1415 }
1416
1417 #[test]
1420 #[ignore = "requires live network access to freeapiproxies.azurewebsites.net"]
1421 fn free_api_proxies_fetcher_live_fetch() -> std::result::Result<(), Box<dyn std::error::Error>>
1422 {
1423 let fetcher = FreeApiProxiesFetcher::new().with_limit(20);
1424 let rt = tokio::runtime::Builder::new_current_thread()
1425 .enable_all()
1426 .build()
1427 .map_err(|e| std::io::Error::other(format!("failed to build runtime for test: {e}")))?;
1428 let proxies = rt.block_on(fetcher.fetch())?;
1429 assert!(
1430 !proxies.is_empty(),
1431 "expected at least one proxy from live endpoint"
1432 );
1433 for proxy in &proxies {
1434 assert!(
1435 proxy.url.starts_with("http://")
1436 || proxy.url.starts_with("https://")
1437 || proxy.url.starts_with("socks4://")
1438 || proxy.url.starts_with("socks5://"),
1439 "unexpected proxy url scheme: {}",
1440 proxy.url
1441 );
1442 }
1443 Ok(())
1444 }
1445
1446 #[test]
1447 fn free_list_source_url_is_nonempty() {
1448 #[cfg(not(feature = "socks"))]
1449 let sources = vec![
1450 FreeListSource::TheSpeedXHttp,
1451 FreeListSource::ClarketmHttp,
1452 FreeListSource::OpenProxyListHttp,
1453 FreeListSource::Custom {
1454 url: "https://example.com/proxies.txt".into(),
1455 proxy_type: ProxyType::Http,
1456 },
1457 ];
1458 #[cfg(feature = "socks")]
1459 let sources = {
1460 let mut s = vec![
1461 FreeListSource::TheSpeedXHttp,
1462 FreeListSource::ClarketmHttp,
1463 FreeListSource::OpenProxyListHttp,
1464 FreeListSource::Custom {
1465 url: "https://example.com/proxies.txt".into(),
1466 proxy_type: ProxyType::Http,
1467 },
1468 ];
1469 s.extend([
1470 FreeListSource::TheSpeedXSocks4,
1471 FreeListSource::TheSpeedXSocks5,
1472 ]);
1473 s
1474 };
1475 for src in &sources {
1476 assert!(
1477 !src.url().is_empty(),
1478 "FreeListSource::{src:?} has empty URL"
1479 );
1480 }
1481 }
1482
1483 #[test]
1484 fn free_list_source_proxy_types() {
1485 assert_eq!(FreeListSource::TheSpeedXHttp.proxy_type(), ProxyType::Http);
1486 #[cfg(feature = "socks")]
1487 assert_eq!(
1488 FreeListSource::TheSpeedXSocks4.proxy_type(),
1489 ProxyType::Socks4
1490 );
1491 #[cfg(feature = "socks")]
1492 assert_eq!(
1493 FreeListSource::TheSpeedXSocks5.proxy_type(),
1494 ProxyType::Socks5
1495 );
1496 assert_eq!(FreeListSource::ClarketmHttp.proxy_type(), ProxyType::Http);
1497 }
1498
1499 #[test]
1500 fn free_api_proxies_fetcher_parses_array_payload() -> crate::error::ProxyResult<()> {
1501 let fetcher = FreeApiProxiesFetcher::with_endpoint("https://example.test/freeapi");
1502 let body = r#"
1503[
1504 {"host":"1.2.3.4","port":8080,"protocol":"http","countryCode":"us"},
1505 {"address":"5.6.7.8:8443","protocol":"https"}
1506]
1507"#;
1508
1509 let proxies = fetcher.parse_payload(body)?;
1510 assert_eq!(proxies.len(), 2);
1511 assert_eq!(
1512 proxies.first().map(|proxy| proxy.url.as_str()),
1513 Some("http://1.2.3.4:8080")
1514 );
1515 assert_eq!(
1516 proxies.get(1).map(|proxy| proxy.url.as_str()),
1517 Some("https://5.6.7.8:8443")
1518 );
1519 Ok(())
1520 }
1521
1522 #[test]
1523 fn free_api_proxies_fetcher_parses_wrapped_results_payload() -> crate::error::ProxyResult<()> {
1524 let fetcher = FreeApiProxiesFetcher::with_endpoint("https://example.test/freeapi");
1525 let body = r#"
1526{
1527 "results": [
1528 {"ip":"9.9.9.9","port":3128,"type":"http"}
1529 ]
1530}
1531"#;
1532
1533 let proxies = fetcher.parse_payload(body)?;
1534 assert_eq!(proxies.len(), 1);
1535 assert_eq!(
1536 proxies.first().map(|proxy| proxy.url.as_str()),
1537 Some("http://9.9.9.9:3128")
1538 );
1539 Ok(())
1540 }
1541
1542 #[test]
1543 fn free_list_fetcher_parse_valid_lines() {
1544 let fetcher = FreeListFetcher::new(vec![]);
1545 let text = "1.2.3.4:8080\n# comment\n\nbad-line\n5.6.7.8:3128\n[2001:db8::1]:8081\n";
1547 let parsed: Vec<Proxy> = text
1548 .lines()
1549 .filter_map(|line| {
1550 let (host, port) = FreeListFetcher::parse_host_port_line(line)?;
1551 Some(Proxy {
1552 url: format!("http://{host}:{port}"),
1553 proxy_type: ProxyType::Http,
1554 username: None,
1555 password: None,
1556 weight: 1,
1557 tags: fetcher.tags.clone(),
1558 capabilities: crate::types::ProxyCapabilities::default(),
1559 ip_class: crate::types::IpClass::Datacenter,
1560 target_compatibility: crate::types::TargetVendorCompatibility::default_blocked(
1561 ),
1562 })
1563 })
1564 .collect();
1565
1566 assert_eq!(parsed.len(), 3);
1567 assert_eq!(
1568 parsed.first().map(|proxy| proxy.url.as_str()),
1569 Some("http://1.2.3.4:8080")
1570 );
1571 assert_eq!(
1572 parsed.get(1).map(|proxy| proxy.url.as_str()),
1573 Some("http://5.6.7.8:3128")
1574 );
1575 assert_eq!(
1576 parsed.get(2).map(|proxy| proxy.url.as_str()),
1577 Some("http://[2001:db8::1]:8081")
1578 );
1579 }
1580
1581 #[test]
1582 fn free_list_fetcher_with_tags_extends() {
1583 let f = FreeListFetcher::new(vec![]).with_tags(vec!["custom".into()]);
1584 assert!(f.tags.contains(&"free-list".to_string()));
1585 assert!(f.tags.contains(&"custom".to_string()));
1586 }
1587
1588 #[test]
1589 fn free_list_fetcher_skips_invalid_port() {
1590 assert!(FreeListFetcher::parse_host_port_line("1.2.3.4:notaport").is_none());
1591 assert!(FreeListFetcher::parse_host_port_line("1.2.3.4:0").is_none());
1592 assert!(FreeListFetcher::parse_host_port_line(":8080").is_none());
1593 assert!(FreeListFetcher::parse_host_port_line("2001:db8::1:8080").is_none());
1594 }
1595
1596 #[test]
1600 fn free_list_fetcher_ingest_tags_datacenter_and_blocked() {
1601 let fetcher = FreeListFetcher::new(vec![]);
1602 let text = "1.2.3.4:8080\n";
1603 let proxies: Vec<Proxy> = text
1604 .lines()
1605 .filter_map(|line| {
1606 let (host, port) = FreeListFetcher::parse_host_port_line(line)?;
1607 Some(Proxy {
1608 url: format!("http://{host}:{port}"),
1609 proxy_type: ProxyType::Http,
1610 username: None,
1611 password: None,
1612 weight: 1,
1613 tags: fetcher.tags.clone(),
1614 capabilities: crate::types::ProxyCapabilities::default(),
1615 ip_class: crate::types::IpClass::Datacenter,
1616 target_compatibility: crate::types::TargetVendorCompatibility::default_blocked(
1617 ),
1618 })
1619 })
1620 .collect();
1621 assert_eq!(proxies.len(), 1);
1622 let proxy = proxies.first().expect("at least one proxy");
1623 assert_eq!(proxy.ip_class, crate::types::IpClass::Datacenter);
1624 assert_eq!(
1627 proxy
1628 .target_compatibility
1629 .get(crate::types::VendorId::DataDome),
1630 Some(crate::types::TrustTier::Blocked)
1631 );
1632 assert_eq!(
1633 proxy
1634 .target_compatibility
1635 .get(crate::types::VendorId::Akamai),
1636 Some(crate::types::TrustTier::Blocked)
1637 );
1638 }
1639
1640 #[test]
1645 fn free_api_proxies_fetcher_parse_tags_datacenter_and_blocked() {
1646 let fetcher = FreeApiProxiesFetcher::with_endpoint("https://example.test/api");
1647 let body = r#"
1648[
1649 {"host":"1.2.3.4","port":8080,"protocol":"http"}
1650]
1651"#;
1652 let proxies = fetcher.parse_payload(body).expect("payload should parse");
1653 let proxy = proxies.first().expect("at least one proxy");
1654 assert_eq!(proxy.ip_class, crate::types::IpClass::Datacenter);
1655 assert_eq!(
1656 proxy
1657 .target_compatibility
1658 .get(crate::types::VendorId::DataDome),
1659 Some(crate::types::TrustTier::Blocked)
1660 );
1661 assert_eq!(
1662 proxy
1663 .target_compatibility
1664 .get(crate::types::VendorId::Cloudflare),
1665 Some(crate::types::TrustTier::Blocked)
1666 );
1667 }
1668
1669 #[test]
1670 fn free_list_fetcher_empty_sources_is_config_error()
1671 -> std::result::Result<(), Box<dyn std::error::Error>> {
1672 let fetcher = FreeListFetcher::new(vec![]);
1673 let rt = tokio::runtime::Builder::new_current_thread()
1674 .enable_time()
1675 .build()
1676 .map_err(|e| std::io::Error::other(format!("failed to build runtime for test: {e}")))?;
1677 let err = rt
1678 .block_on(fetcher.fetch())
1679 .err()
1680 .ok_or_else(|| std::io::Error::other("empty sources should fail"))?;
1681 match err {
1682 ProxyError::ConfigError(msg) => {
1683 assert!(msg.contains("no sources configured"));
1684 }
1685 other => {
1686 return Err(
1687 std::io::Error::other(format!("unexpected error variant: {other}")).into(),
1688 );
1689 }
1690 }
1691 Ok(())
1692 }
1693
1694 #[test]
1695 fn proxy_error_fetch_failed_display() {
1696 let e = ProxyError::FetchFailed {
1697 origin: "https://example.com".into(),
1698 message: "timed out".into(),
1699 };
1700 assert!(e.to_string().contains("https://example.com"));
1701 assert!(e.to_string().contains("timed out"));
1702 }
1703}