Skip to main content

stygian_proxy/
fetcher.rs

1//! Proxy list fetching — port trait and free-list HTTP adapter.
2//!
3//! [`ProxyFetcher`] is the port trait.  Implement it to pull proxies from any
4//! source (remote HTTP list, database, commercial API, etc.) and integrate with
5//! [`ProxyManager`] via [`load_from_fetcher`].
6//!
7//! The built-in [`FreeListFetcher`] downloads plain-text `host:port` proxy
8//! lists from public URLs (e.g. the `TheSpeedX/PROXY-List` feeds on GitHub)
9//! and parses them into [`Proxy`] records.  It is suitable for development,
10//! testing, and low-stakes scraping where proxy quality is less critical.
11//!
12//! ## Example — load from a free list and populate the pool
13//!
14//! ```no_run
15//! use std::sync::Arc;
16//! use stygian_proxy::{
17//!     ProxyManager,
18//!     storage::MemoryProxyStore,
19//!     fetcher::{FreeListFetcher, ProxyFetcher, FreeListSource},
20//! };
21//!
22//! # async fn run() -> stygian_proxy::error::ProxyResult<()> {
23//! let fetcher = FreeListFetcher::new(vec![
24//!     FreeListSource::TheSpeedXHttp,
25//! ]);
26//!
27//! let manager = ProxyManager::builder()
28//!     .storage(Arc::new(MemoryProxyStore::default()))
29//!     .build()?;
30//! let loaded = stygian_proxy::fetcher::load_from_fetcher(&manager, &fetcher).await?;
31//! println!("Loaded {loaded} proxies");
32//! # Ok(())
33//! # }
34//! ```
35
36use 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// ─── Port trait ───────────────────────────────────────────────────────────────
50
51/// A source that can produce a list of [`Proxy`] records asynchronously.
52///
53/// Implement this trait to integrate any proxy source (remote HTTP list,
54/// commercial API, database, file) with [`load_from_fetcher`].
55///
56/// # Example
57///
58/// ```
59/// use async_trait::async_trait;
60/// use stygian_proxy::{Proxy, ProxyType};
61/// use stygian_proxy::fetcher::ProxyFetcher;
62/// use stygian_proxy::error::ProxyResult;
63/// use stygian_proxy::types::ProxyCapabilities;
64///
65/// struct MyStaticFetcher;
66///
67/// #[async_trait]
68/// impl ProxyFetcher for MyStaticFetcher {
69///     async fn fetch(&self) -> ProxyResult<Vec<Proxy>> {
70///         Ok(vec![Proxy {
71///             url: "http://192.168.1.1:8080".into(),
72///             proxy_type: ProxyType::Http,
73///             username: None,
74///             password: None,
75///             weight: 1,
76///             tags: vec!["static".into()],
77///             capabilities: ProxyCapabilities::default(),
78///         }])
79///     }
80/// }
81/// ```
82#[async_trait]
83pub trait ProxyFetcher: Send + Sync {
84    /// Fetch the current proxy list.
85    ///
86    /// # Errors
87    ///
88    /// Returns [`ProxyError::FetchFailed`] if the source is unreachable or
89    /// returns malformed data.
90    async fn fetch(&self) -> ProxyResult<Vec<Proxy>>;
91}
92
93// ─── Free-list sources ────────────────────────────────────────────────────────
94
95/// A well-known free/public proxy list feed.
96///
97/// These lists are community-maintained and quality varies.  They are suitable
98/// for development and testing.  For production use, prefer a commercial
99/// provider adapter.
100///
101/// # Example
102///
103/// ```
104/// use stygian_proxy::fetcher::FreeListSource;
105/// let _src = FreeListSource::TheSpeedXHttp;
106/// ```
107#[derive(Debug, Clone, PartialEq, Eq)]
108#[non_exhaustive]
109pub enum FreeListSource {
110    /// HTTP proxies from `TheSpeedX/PROXY-List` (GitHub, plain `host:port`).
111    TheSpeedXHttp,
112    #[cfg(feature = "socks")]
113    /// SOCKS4 proxies from `TheSpeedX/PROXY-List` (requires the `socks` feature).
114    TheSpeedXSocks4,
115    #[cfg(feature = "socks")]
116    /// SOCKS5 proxies from `TheSpeedX/PROXY-List` (requires the `socks` feature).
117    TheSpeedXSocks5,
118    /// HTTP proxies from `clarketm/proxy-list` (GitHub, plain `host:port`).
119    ClarketmHttp,
120    /// Mixed HTTP proxies from `openproxylist.xyz`.
121    OpenProxyListHttp,
122    /// A custom URL.  Content must be one `host:port` entry per line.
123    Custom {
124        /// The URL to fetch.
125        url: String,
126        /// The [`ProxyType`] to assign all parsed entries.
127        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
165// ─── FreeListFetcher ──────────────────────────────────────────────────────────
166
167/// Fetches plain-text `host:port` proxy lists from one or more public URLs.
168///
169/// Each source is fetched concurrently.  Lines that do not parse as valid
170/// `host:port` entries are silently skipped.  An empty or unreachable source
171/// logs a warning but does not fail the entire fetch — at least one source
172/// must return results for the call to succeed.
173///
174/// # Example
175///
176/// ```no_run
177/// use stygian_proxy::fetcher::{FreeListFetcher, FreeListSource, ProxyFetcher};
178///
179/// # async fn run() -> stygian_proxy::error::ProxyResult<()> {
180/// let fetcher = FreeListFetcher::new(vec![FreeListSource::TheSpeedXHttp]);
181/// let proxies = fetcher.fetch().await?;
182/// println!("Got {} proxies", proxies.len());
183/// # Ok(())
184/// # }
185/// ```
186pub struct FreeListFetcher {
187    sources: Vec<FreeListSource>,
188    client: Client,
189    tags: Vec<String>,
190}
191
192impl FreeListFetcher {
193    /// Create a fetcher for the given sources with default HTTP client settings
194    /// (10 s timeout, TLS enabled).
195    ///
196    /// # Example
197    ///
198    /// ```
199    /// use stygian_proxy::fetcher::{FreeListFetcher, FreeListSource};
200    /// let _f = FreeListFetcher::new(vec![FreeListSource::TheSpeedXHttp]);
201    /// ```
202    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    /// Replace the internal HTTP client with a TLS-profiled one.
218    ///
219    /// Proxy-list fetch requests will carry a browser TLS fingerprint and
220    /// matching `Accept` / `Sec-CH-UA` headers.
221    ///
222    /// Only available with the `tls-profiled` feature.
223    ///
224    /// # Example
225    ///
226    /// ```no_run
227    /// use stygian_proxy::fetcher::{FreeListFetcher, FreeListSource};
228    /// use stygian_proxy::http_client::{ProfiledRequestMode, ProfiledRequester};
229    ///
230    /// # fn run() -> Result<(), Box<dyn std::error::Error>> {
231    /// let fetcher = FreeListFetcher::new(vec![FreeListSource::TheSpeedXHttp])
232    ///     .with_profiled_client(ProfiledRequester::chrome_mode(ProfiledRequestMode::Preset)?);
233    /// # Ok(())
234    /// # }
235    /// ```
236    #[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    /// Build and attach a profile-mode-based requester.
248    ///
249    /// Uses Chrome 131 as the baseline browser identity and applies `mode`
250    /// to TLS control mapping.
251    ///
252    /// Only available when the `tls-profiled` feature is enabled.
253    ///
254    /// # Errors
255    ///
256    /// Returns [`crate::error::ProxyError::ConfigError`] if the profiled
257    /// requester cannot be constructed.
258    #[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    /// Attach extra tags to every proxy produced by this fetcher.
269    ///
270    /// # Example
271    ///
272    /// ```
273    /// use stygian_proxy::fetcher::{FreeListFetcher, FreeListSource};
274    /// let _f = FreeListFetcher::new(vec![FreeListSource::TheSpeedXHttp])
275    ///     .with_tags(vec!["dev".into(), "http".into()]);
276    /// ```
277    #[must_use]
278    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
279        self.tags.extend(tags);
280        self
281    }
282
283    /// Parse one `host:port` line, including bracketed IPv6 addresses.
284    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    /// Fetch a single source, returning parsed proxies (empty on failure).
318    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        // Drive all source fetches concurrently.
392        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
411// ─── FreeAPIProxies adapter ──────────────────────────────────────────────────
412
413/// Fetches proxies from a JSON API compatible with FreeAPIProxies-style
414/// payloads.
415///
416/// The adapter accepts either a top-level array payload or an object payload
417/// with `data` or `results` arrays.  Optional query parameters (`limit`,
418/// `protocol`, `country`) are appended to the endpoint URL when set.
419///
420/// # Example
421///
422/// ```no_run
423/// use stygian_proxy::fetcher::{FreeApiProxiesFetcher, ProxyFetcher};
424///
425/// # async fn run() -> stygian_proxy::error::ProxyResult<()> {
426/// let fetcher = FreeApiProxiesFetcher::new()
427///     .with_limit(100)
428///     .with_protocol_filter("http")
429///     .with_country_filter("US");
430/// let proxies = fetcher.fetch().await?;
431/// println!("Got {} proxies", proxies.len());
432/// # Ok(())
433/// # }
434/// ```
435pub struct FreeApiProxiesFetcher {
436    endpoint: String,
437    client: Client,
438    tags: Vec<String>,
439    /// Maximum number of proxies to request from the API.
440    limit: Option<u32>,
441    /// Protocol filter sent as a query parameter (e.g. `"http"`, `"socks5"`).
442    protocol_filter: Option<String>,
443    /// ISO 3166-1 alpha-2 country code filter (e.g. `"US"`, `"DE"`).
444    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    /// Create a `FreeAPIProxies` fetcher using the default endpoint.
487    #[must_use]
488    pub fn new() -> Self {
489        Self::with_endpoint(Self::DEFAULT_ENDPOINT)
490    }
491
492    /// Create a `FreeAPIProxies` fetcher using a custom JSON endpoint.
493    #[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    /// Attach extra tags to every proxy produced by this fetcher.
514    #[must_use]
515    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
516        self.tags.extend(tags);
517        self
518    }
519
520    /// Set the maximum number of proxies to request from the API.
521    ///
522    /// Appended to the request as `?limit=<n>`.  Ignored when `None`.
523    ///
524    /// # Example
525    ///
526    /// ```
527    /// use stygian_proxy::fetcher::FreeApiProxiesFetcher;
528    /// let _f = FreeApiProxiesFetcher::new().with_limit(50);
529    /// ```
530    #[must_use]
531    pub const fn with_limit(mut self, limit: u32) -> Self {
532        self.limit = Some(limit);
533        self
534    }
535
536    /// Filter by proxy protocol on the server side.
537    ///
538    /// Appended to the request as `?protocol=<value>`.  Common values are
539    /// `"http"`, `"https"`, `"socks4"`, and `"socks5"`.
540    ///
541    /// # Example
542    ///
543    /// ```
544    /// use stygian_proxy::fetcher::FreeApiProxiesFetcher;
545    /// let _f = FreeApiProxiesFetcher::new().with_protocol_filter("http");
546    /// ```
547    #[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    /// Filter by ISO 3166-1 alpha-2 country code on the server side.
554    ///
555    /// Appended to the request as `?country=<value>` (uppercased).
556    ///
557    /// # Example
558    ///
559    /// ```
560    /// use stygian_proxy::fetcher::FreeApiProxiesFetcher;
561    /// let _f = FreeApiProxiesFetcher::new().with_country_filter("US");
562    /// ```
563    #[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    /// Build the full request URL with any configured query parameters.
570    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
729// ─── Helper ───────────────────────────────────────────────────────────────────
730
731/// Fetch proxies from `fetcher` and add them all to `manager`.
732///
733/// Returns the number of proxies successfully added.  Individual `add_proxy`
734/// failures (e.g. duplicate URL) are logged as warnings and do not abort the
735/// load.
736///
737/// # Errors
738///
739/// Returns any [`ProxyError`] emitted by `fetcher.fetch()` if the fetcher
740/// itself fails.
741///
742/// # Example
743///
744/// ```no_run
745/// use std::sync::Arc;
746/// use stygian_proxy::{ProxyManager, storage::MemoryProxyStore, fetcher::{FreeListFetcher, FreeListSource, load_from_fetcher}};
747///
748/// # async fn run() -> stygian_proxy::error::ProxyResult<()> {
749/// let manager = ProxyManager::builder()
750///     .storage(Arc::new(MemoryProxyStore::default()))
751///     .build()?;
752/// let fetcher = FreeListFetcher::new(vec![FreeListSource::TheSpeedXHttp]);
753/// let n = load_from_fetcher(&manager, &fetcher).await?;
754/// println!("Loaded {n} proxies");
755/// # Ok(())
756/// # }
757/// ```
758pub 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// ─── DnsTxtFetcher ───────────────────────────────────────────────────────────
778
779/// Fetches proxy endpoints from DNS TXT records.
780///
781/// Each TXT record at the configured zone should encode one proxy entry using
782/// the following colon-delimited format:
783///
784/// ```text
785/// host:port                        HTTP proxy, no auth
786/// host:port:https                  HTTPS proxy, no auth
787/// host:port:socks5                 SOCKS5 proxy (requires socks feature)
788/// host:port:socks5:user:pass       SOCKS5 proxy with auth
789/// host:port:http:user:pass         HTTP proxy with auth
790/// host:port:cdn_edge               CDN edge proxy
791/// host:port:cdn_edge:cloudflare    CDN edge proxy with provider metadata
792/// [::1]:port:http                  IPv6 host in bracket notation
793/// ```
794///
795/// Records that do not match the format are silently skipped.
796///
797/// # Example
798///
799/// ```no_run
800/// use stygian_proxy::fetcher::{DnsTxtFetcher, ProxyFetcher};
801///
802/// # async fn run() -> stygian_proxy::error::ProxyResult<()> {
803/// let fetcher = DnsTxtFetcher::new("proxies.internal.example.com");
804/// let proxies = fetcher.fetch().await?;
805/// println!("Discovered {} proxies via DNS", proxies.len());
806/// # Ok(())
807/// # }
808/// ```
809#[cfg(feature = "dns-fetcher")]
810pub struct DnsTxtFetcher {
811    zone: String,
812    tags: Vec<String>,
813}
814
815#[cfg(feature = "dns-fetcher")]
816impl DnsTxtFetcher {
817    /// Create a fetcher that queries TXT records for `zone`.
818    ///
819    /// `zone` is a DNS name such as `"proxies.internal.example.com"`.
820    pub fn new(zone: impl Into<String>) -> Self {
821        Self {
822            zone: zone.into(),
823            tags: vec!["dns-txt".into()],
824        }
825    }
826
827    /// Attach extra tags to every proxy discovered via this fetcher.
828    #[must_use]
829    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
830        self.tags.extend(tags);
831        self
832    }
833
834    /// Parse a single TXT record string into a [`Proxy`].
835    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        // Extract host and port, supporting bracketed IPv6 addresses.
841        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            // Each TXT record may contain multiple character-strings; join them.
951            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// ─── Tests ────────────────────────────────────────────────────────────────────
982
983#[cfg(test)]
984mod tests {
985    use super::*;
986
987    // ── DnsTxtFetcher::parse_record ───────────────────────────────────────────
988
989    #[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    /// Integration test — hits the live `FreeAPIProxies` endpoint.
1101    /// Run with: `cargo test -p stygian-proxy --all-features -- --ignored`
1102    #[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        // Test the parsing logic directly by calling parse on synthetic text.
1229        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}