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}