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