Skip to main content

stygian_proxy/
types.rs

1//! Core domain types for proxy management.
2
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::time::{Duration, Instant};
5
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9/// The protocol variant of a proxy endpoint.
10///
11/// # Example
12/// ```
13/// use stygian_proxy::types::ProxyType;
14/// assert_eq!(ProxyType::Http, ProxyType::Http);
15/// ```
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum ProxyType {
19    /// Plain HTTP proxy (CONNECT / forwarding).
20    Http,
21    /// HTTPS proxy over TLS.
22    Https,
23    #[cfg(feature = "socks")]
24    /// SOCKS4 proxy (requires the `socks` feature).
25    Socks4,
26    #[cfg(feature = "socks")]
27    /// SOCKS5 proxy (requires the `socks` feature).
28    Socks5,
29    /// CDN edge relay (`Cloudflare`, `CloudFront`, `Azure Front Door`, etc.).
30    ///
31    /// Traffic egresses through a CDN point-of-presence rather than a traditional proxy
32    /// server.  Provider metadata is carried in
33    /// [`ProxyCapabilities::cdn_provider`].
34    CdnEdge,
35}
36
37/// TLS-profiled request mode for proxy-side HTTP operations.
38///
39/// Used by `tls-profiled` integrations to decide how strictly browser TLS
40/// profiles should be mapped onto rustls.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum ProfiledRequestMode {
44    /// Broad compatibility: skip unknown entries and use safe fallbacks.
45    Compatible,
46    /// Profile-aware preset selected from the profile name.
47    Preset,
48    /// Strict cipher-suite mapping with compatibility group fallback.
49    Strict,
50    /// Strict cipher-suite + group mapping without fallback.
51    StrictAll,
52}
53
54/// Protocol-level capabilities advertised by a proxy endpoint.
55///
56/// These flags are set when the proxy is registered and consulted during
57/// capability-aware selection (see [`crate::manager::ProxyManager::acquire_with_capabilities`]).
58///
59/// # Example
60/// ```
61/// use stygian_proxy::types::ProxyCapabilities;
62/// let caps = ProxyCapabilities::default();
63/// assert!(!caps.supports_https_connect);
64/// assert!(!caps.supports_socks5_udp);
65/// assert!(!caps.supports_http3_tunnel);
66/// ```
67#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub struct ProxyCapabilities {
70    /// Proxy supports the `CONNECT` method for HTTPS tunnelling.
71    #[serde(default)]
72    pub supports_https_connect: bool,
73    /// Proxy supports SOCKS5 with UDP relay (for UDP-based transports).
74    #[serde(default)]
75    pub supports_socks5_udp: bool,
76    /// Proxy supports HTTP/3 (QUIC) tunnelling — future-compatible flag.
77    #[serde(default)]
78    pub supports_http3_tunnel: bool,
79    /// Optional ISO-3166-1 alpha-2 country code for the proxy egress location.
80    #[serde(default)]
81    pub geo_country: Option<String>,
82    /// Confidence score `[0.0, 1.0]` for the geo-location data.
83    ///
84    /// `None` means the provider did not supply confidence metadata.
85    #[serde(default)]
86    pub geo_confidence: Option<f32>,
87    /// `true` when this proxy routes through a CDN edge node rather than a
88    /// traditional SOCKS/HTTP proxy server.
89    #[serde(default)]
90    pub is_cdn_edge: bool,
91    /// CDN provider name when `is_cdn_edge` is `true`.
92    ///
93    /// Advisory — used for monitoring and routing hints.
94    /// Examples: `"cloudflare"`, `"cloudfront"`, `"azure-front-door"`.
95    #[serde(default)]
96    pub cdn_provider: Option<String>,
97    /// TLS fingerprint profile this proxy presents toward the upstream target.
98    ///
99    /// Advisory identifier such as `"chrome-131"`, `"firefox-120"`, or
100    /// `"curl"`.  Use with [`CapabilityRequirement::require_tls_profile`] to
101    /// select proxies by their TLS stack identity.  `None` means unknown.
102    #[serde(default)]
103    pub tls_profile: Option<String>,
104}
105
106impl ProxyCapabilities {
107    /// Returns `true` if every required flag in `req` is satisfied by `self`.
108    ///
109    /// # Example
110    /// ```
111    /// use stygian_proxy::types::{ProxyCapabilities, CapabilityRequirement};
112    /// let caps = ProxyCapabilities { supports_https_connect: true, ..Default::default() };
113    /// let req = CapabilityRequirement { require_https_connect: true, ..Default::default() };
114    /// assert!(caps.satisfies(&req));
115    /// let req2 = CapabilityRequirement { require_socks5_udp: true, ..Default::default() };
116    /// assert!(!caps.satisfies(&req2));
117    /// ```
118    pub fn satisfies(&self, req: &CapabilityRequirement) -> bool {
119        if req.require_https_connect && !self.supports_https_connect {
120            return false;
121        }
122        if req.require_socks5_udp && !self.supports_socks5_udp {
123            return false;
124        }
125        if req.require_http3_tunnel && !self.supports_http3_tunnel {
126            return false;
127        }
128        if let Some(ref required_country) = req.require_geo_country
129            && self.geo_country.as_deref() != Some(required_country.as_str())
130        {
131            return false;
132        }
133        if req.require_cdn_edge && !self.is_cdn_edge {
134            return false;
135        }
136        if let Some(ref required_profile) = req.require_tls_profile
137            && self.tls_profile.as_deref() != Some(required_profile.as_str())
138        {
139            return false;
140        }
141        true
142    }
143}
144
145/// Required capability set used as a filter when acquiring a proxy.
146///
147/// All fields default to `false`/`None` — an empty requirement matches any proxy.
148///
149/// # Example
150/// ```
151/// use stygian_proxy::types::CapabilityRequirement;
152/// let req = CapabilityRequirement::default();
153/// // empty requirement — any proxy qualifies
154/// assert!(!req.require_https_connect);
155/// ```
156#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "snake_case")]
158pub struct CapabilityRequirement {
159    /// Require `supports_https_connect`.
160    #[serde(default)]
161    pub require_https_connect: bool,
162    /// Require `supports_socks5_udp`.
163    #[serde(default)]
164    pub require_socks5_udp: bool,
165    /// Require `supports_http3_tunnel`.
166    #[serde(default)]
167    pub require_http3_tunnel: bool,
168    /// Require a specific egress country (ISO-3166-1 alpha-2).
169    #[serde(default)]
170    pub require_geo_country: Option<String>,
171    /// Require a CDN-edge proxy (`is_cdn_edge` must be `true`).
172    #[serde(default)]
173    pub require_cdn_edge: bool,
174    /// Require a specific TLS fingerprint profile.
175    ///
176    /// When `Some`, only proxies whose [`ProxyCapabilities::tls_profile`]
177    /// matches this value exactly are eligible.  Examples: `"chrome-131"`,
178    /// `"firefox-120"`, `"curl"`.
179    #[serde(default)]
180    pub require_tls_profile: Option<String>,
181}
182
183/// The protocol routing path resolved for an outbound request.
184///
185/// Returned by [`crate::routing::resolve_routing_path`] to indicate how the
186/// proxy should forward the connection.
187///
188/// # Example
189/// ```
190/// use stygian_proxy::types::RoutingPath;
191/// let path = RoutingPath::H1H2OverTcp;
192/// assert_eq!(format!("{path:?}"), "H1H2OverTcp");
193/// ```
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
195#[serde(rename_all = "snake_case")]
196pub enum RoutingPath {
197    /// HTTP/1.1 or HTTP/2 multiplexed over a TCP CONNECT tunnel.
198    H1H2OverTcp,
199    /// HTTP/3 (QUIC) over a UDP relay — requires `supports_http3_tunnel`.
200    H3OverUdp,
201    /// Persistent TCP CONNECT tunnel — connection is kept alive between requests.
202    ///
203    /// Selected when [`crate::routing::TransportPreference::PersistentTcp`] is used.
204    PersistentTcp,
205}
206
207/// A proxy endpoint with optional authentication credentials.
208///
209/// `Debug` output masks `password` to prevent accidental credential logging.
210///
211/// # Example
212/// ```
213/// use stygian_proxy::types::{Proxy, ProxyType, ProxyCapabilities};
214/// let p = Proxy {
215///     url: "http://proxy.example.com:8080".into(),
216///     proxy_type: ProxyType::Http,
217///     username: Some("alice".into()),
218///     password: Some("secret".into()),
219///     weight: 1,
220///     tags: vec!["prod".into()],
221///     capabilities: ProxyCapabilities::default(),
222/// };
223/// let debug = format!("{p:?}");
224/// assert!(debug.contains("***"), "password must be masked in Debug output");
225/// ```
226#[derive(Clone, Serialize, Deserialize)]
227#[serde(rename_all = "snake_case")]
228pub struct Proxy {
229    /// The proxy URL, e.g. `http://proxy.example.com:8080`.
230    pub url: String,
231    pub proxy_type: ProxyType,
232    pub username: Option<String>,
233    pub password: Option<String>,
234    /// Relative selection weight for weighted rotation (default: `1`).
235    pub weight: u32,
236    /// User-defined tags for filtering and grouping.
237    pub tags: Vec<String>,
238    /// Protocol-level capabilities advertised by this proxy.
239    #[serde(default)]
240    pub capabilities: ProxyCapabilities,
241}
242
243impl std::fmt::Debug for Proxy {
244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        f.debug_struct("Proxy")
246            .field("url", &self.url)
247            .field("proxy_type", &self.proxy_type)
248            .field("username", &self.username)
249            .field("password", &self.password.as_deref().map(|_| "***"))
250            .field("weight", &self.weight)
251            .field("tags", &self.tags)
252            .field("capabilities", &self.capabilities)
253            .finish()
254    }
255}
256
257/// A [`Proxy`] with a stable identity and insertion timestamp.
258///
259/// # Example
260/// ```
261/// use stygian_proxy::types::{Proxy, ProxyType, ProxyRecord};
262/// let proxy = Proxy {
263///     url: "http://proxy.example.com:8080".into(),
264///     proxy_type: ProxyType::Http,
265///     username: None,
266///     password: None,
267///     weight: 1,
268///     tags: vec![],
269///     capabilities: Default::default(),
270/// };
271/// let record = ProxyRecord::new(proxy);
272/// assert!(!record.id.is_nil());
273/// ```
274#[derive(Debug, Clone, Serialize, Deserialize)]
275#[serde(rename_all = "snake_case")]
276pub struct ProxyRecord {
277    pub id: Uuid,
278    pub proxy: Proxy,
279    /// Wall-clock time the proxy was added. Not serialized — `Instant` is
280    /// not meaningfully portable; defaults to `Instant::now()` on deserialization.
281    #[serde(skip, default = "Instant::now")]
282    pub added_at: Instant,
283}
284
285impl ProxyRecord {
286    /// Create a new [`ProxyRecord`] wrapping `proxy` with a freshly generated UUID.
287    pub fn new(proxy: Proxy) -> Self {
288        Self {
289            id: Uuid::new_v4(),
290            proxy,
291            added_at: Instant::now(),
292        }
293    }
294}
295
296/// Per-proxy runtime metrics using lock-free atomic counters.
297///
298/// Intended to be shared via `Arc<ProxyMetrics>`.
299///
300/// # Example
301/// ```
302/// use stygian_proxy::types::ProxyMetrics;
303/// let m = ProxyMetrics::default();
304/// assert_eq!(m.success_rate(), 0.0);
305/// assert_eq!(m.avg_latency_ms(), 0.0);
306/// ```
307#[derive(Debug, Default)]
308pub struct ProxyMetrics {
309    pub requests_total: AtomicU64,
310    pub successes: AtomicU64,
311    pub failures: AtomicU64,
312    pub total_latency_ms: AtomicU64,
313}
314
315impl ProxyMetrics {
316    /// Cast a `u64` counter to `f64` for ratio computation.
317    ///
318    /// `u64` can represent values up to ~1.8 × 10¹⁹; `f64` has 53-bit
319    /// mantissa, so precision loss begins around 9 × 10¹⁵.  For long-running
320    /// proxies that number is never reached in practice, and direct casting
321    /// preserves ratios correctly (unlike saturating to `u32::MAX`).
322    #[allow(clippy::cast_precision_loss)]
323    const fn u64_as_f64(value: u64) -> f64 {
324        value as f64
325    }
326
327    /// Returns the fraction of requests that succeeded, in `[0.0, 1.0]`.
328    ///
329    /// Returns `0.0` when no requests have been recorded.
330    ///
331    /// # Example
332    /// ```
333    /// use stygian_proxy::types::ProxyMetrics;
334    /// use std::sync::atomic::Ordering;
335    /// let m = ProxyMetrics::default();
336    /// m.requests_total.store(10, Ordering::Relaxed);
337    /// m.successes.store(8, Ordering::Relaxed);
338    /// assert!((m.success_rate() - 0.8).abs() < f64::EPSILON);
339    /// ```
340    pub fn success_rate(&self) -> f64 {
341        let total = self.requests_total.load(Ordering::Relaxed);
342        if total == 0 {
343            return 0.0;
344        }
345        Self::u64_as_f64(self.successes.load(Ordering::Relaxed)) / Self::u64_as_f64(total)
346    }
347
348    /// Returns the average request latency in milliseconds.
349    ///
350    /// Returns `0.0` when no requests have been recorded.
351    ///
352    /// # Example
353    /// ```
354    /// use stygian_proxy::types::ProxyMetrics;
355    /// use std::sync::atomic::Ordering;
356    /// let m = ProxyMetrics::default();
357    /// m.requests_total.store(4, Ordering::Relaxed);
358    /// m.total_latency_ms.store(400, Ordering::Relaxed);
359    /// assert!((m.avg_latency_ms() - 100.0).abs() < f64::EPSILON);
360    /// ```
361    pub fn avg_latency_ms(&self) -> f64 {
362        let total = self.requests_total.load(Ordering::Relaxed);
363        if total == 0 {
364            return 0.0;
365        }
366        Self::u64_as_f64(self.total_latency_ms.load(Ordering::Relaxed)) / Self::u64_as_f64(total)
367    }
368}
369
370mod serde_duration_secs {
371    use serde::{Deserialize, Deserializer, Serialize, Serializer};
372    use std::time::Duration;
373
374    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
375        d.as_secs().serialize(s)
376    }
377
378    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
379        Ok(Duration::from_secs(u64::deserialize(d)?))
380    }
381}
382
383/// Configuration governing health checking and circuit-breaker behaviour.
384///
385/// Duration fields serialize as integer seconds for TOML/JSON compatibility.
386///
387/// # Example
388/// ```
389/// use stygian_proxy::types::ProxyConfig;
390/// use std::time::Duration;
391/// let cfg = ProxyConfig::default();
392/// assert_eq!(cfg.health_check_url, "https://httpbin.org/ip");
393/// assert_eq!(cfg.health_check_interval, Duration::from_secs(60));
394/// assert_eq!(cfg.health_check_timeout, Duration::from_secs(5));
395/// assert_eq!(cfg.circuit_open_threshold, 5);
396/// assert_eq!(cfg.circuit_half_open_after, Duration::from_secs(30));
397/// assert!(cfg.profiled_request_mode.is_none());
398/// assert_eq!(cfg.health_check_jitter_pct, 0.20_f32);
399/// assert!(cfg.max_requests_per_connection.is_none());
400/// assert!(cfg.connection_max_age_secs.is_none());
401/// ```
402#[derive(Debug, Clone, Serialize, Deserialize)]
403#[serde(rename_all = "snake_case")]
404pub struct ProxyConfig {
405    /// URL called during health checks to verify proxy liveness.
406    pub health_check_url: String,
407    /// How often to run health checks (seconds).
408    #[serde(with = "serde_duration_secs")]
409    pub health_check_interval: Duration,
410    /// Per-probe HTTP timeout (seconds).
411    #[serde(with = "serde_duration_secs")]
412    pub health_check_timeout: Duration,
413    /// Jitter factor applied to the health-check sleep window.
414    ///
415    /// `0.20` distributes each check window uniformly over
416    /// `interval × [0.80, 1.20)`, preventing synchronised fleet-wide check
417    /// storms.  Set to `0.0` to disable jitter.  Clamped to `[0.0, 0.99]`
418    /// at runtime.
419    ///
420    /// Default: `0.20` (±20 %).
421    #[serde(default = "default_health_check_jitter_pct")]
422    pub health_check_jitter_pct: f32,
423    /// Consecutive failures before the circuit trips to OPEN.
424    pub circuit_open_threshold: u32,
425    /// How long to wait in OPEN before transitioning to HALF-OPEN (seconds).
426    #[serde(with = "serde_duration_secs")]
427    pub circuit_half_open_after: Duration,
428    /// Sticky-session policy for domain→proxy binding.
429    #[serde(default)]
430    pub sticky_policy: crate::session::StickyPolicy,
431    /// Optional default mode for TLS-profiled helper clients.
432    ///
433    /// When set and `tls-profiled` is enabled, `ProxyManager` initializes its
434    /// `HealthChecker` with a Chrome-profiled requester using this mode.
435    ///
436    /// Ignored when `tls-profiled` is disabled.
437    #[serde(default)]
438    pub profiled_request_mode: Option<ProfiledRequestMode>,
439    /// Maximum requests routed through one persistent TCP connection before it
440    /// is recycled.  `None` means no limit.  Only consulted when
441    /// [`crate::routing::TransportPreference::PersistentTcp`] is active.
442    #[serde(default)]
443    pub max_requests_per_connection: Option<u32>,
444    /// Maximum age of a persistent TCP connection in seconds before it is
445    /// replaced.  `None` means no age limit.
446    #[serde(default)]
447    pub connection_max_age_secs: Option<u64>,
448}
449
450const fn default_health_check_jitter_pct() -> f32 {
451    0.20
452}
453
454impl Default for ProxyConfig {
455    fn default() -> Self {
456        Self {
457            health_check_url: "https://httpbin.org/ip".into(),
458            health_check_interval: Duration::from_mins(1),
459            health_check_timeout: Duration::from_secs(5),
460            health_check_jitter_pct: 0.20,
461            circuit_open_threshold: 5,
462            circuit_half_open_after: Duration::from_secs(30),
463            sticky_policy: crate::session::StickyPolicy::default(),
464            profiled_request_mode: None,
465            max_requests_per_connection: None,
466            connection_max_age_secs: None,
467        }
468    }
469}