use super::cdp_probe::ProbeOk;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EndpointKind {
GenericCdp,
NativeStealth,
}
impl EndpointKind {
pub fn as_str(self) -> &'static str {
match self {
EndpointKind::GenericCdp => "generic_cdp",
EndpointKind::NativeStealth => "native_stealth",
}
}
}
#[derive(Debug, Clone)]
pub struct EndpointCapabilities {
pub kind: EndpointKind,
pub identity_params: bool,
pub vendor: Option<String>,
}
impl EndpointCapabilities {
pub fn generic() -> Self {
Self {
kind: EndpointKind::GenericCdp,
identity_params: false,
vendor: None,
}
}
pub fn detect(probe: &ProbeOk) -> Self {
if !probe.stealth_provider.trim().is_empty() {
return Self {
kind: EndpointKind::NativeStealth,
identity_params: true,
vendor: Some(probe.stealth_provider.trim().to_string()),
};
}
let banner = probe.browser.trim();
let banner_lc = banner.to_ascii_lowercase();
if banner_lc.starts_with("cloakserve/") || banner_lc.starts_with("cloakserve ") {
return Self {
kind: EndpointKind::NativeStealth,
identity_params: true,
vendor: Some(banner.to_string()),
};
}
Self::generic()
}
pub fn build_connect_url(
&self,
base: &str,
hints: &IdentityHints<'_>,
) -> Result<String, String> {
if !self.identity_params || hints.is_empty() {
return Ok(base.to_string());
}
let mut url = url::Url::parse(base)
.map_err(|e| format!("external CDP url is not parseable (`{base}`): {e}"))?;
{
let mut q = url.query_pairs_mut();
if let Some(v) = hints.seed {
q.append_pair("seed", v);
}
if let Some(v) = hints.timezone {
q.append_pair("timezone", v);
}
if let Some(v) = hints.locale {
q.append_pair("locale", v);
}
if let Some(v) = hints.proxy {
q.append_pair("proxy", v);
}
if let Some(v) = hints.geoip {
q.append_pair("geoip", v);
}
}
Ok(url.to_string())
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct IdentityHints<'a> {
pub seed: Option<&'a str>,
pub timezone: Option<&'a str>,
pub locale: Option<&'a str>,
pub proxy: Option<&'a str>,
pub geoip: Option<&'a str>,
}
impl<'a> IdentityHints<'a> {
pub fn is_empty(&self) -> bool {
self.seed.is_none()
&& self.timezone.is_none()
&& self.locale.is_none()
&& self.proxy.is_none()
&& self.geoip.is_none()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn probe(browser: &str, stealth: &str) -> ProbeOk {
ProbeOk {
web_socket_debugger_url: "ws://x/y".to_string(),
browser: browser.to_string(),
stealth_provider: stealth.to_string(),
}
}
#[test]
fn detect_generic_chrome_banner() {
let cap = EndpointCapabilities::detect(&probe("Chrome/149.0.0.0", ""));
assert_eq!(cap.kind, EndpointKind::GenericCdp);
assert!(!cap.identity_params);
assert!(cap.vendor.is_none());
}
#[test]
fn detect_cloakserve_browser_banner() {
let cap = EndpointCapabilities::detect(&probe("cloakserve/0.4.2 (chrome 149)", ""));
assert_eq!(cap.kind, EndpointKind::NativeStealth);
assert!(cap.identity_params);
assert_eq!(cap.vendor.as_deref(), Some("cloakserve/0.4.2 (chrome 149)"));
}
#[test]
fn detect_explicit_stealth_provider_wins_over_chrome_banner() {
let cap = EndpointCapabilities::detect(&probe("Chrome/149", "stealth-cdp/1.0"));
assert_eq!(cap.kind, EndpointKind::NativeStealth);
assert!(cap.identity_params);
assert_eq!(cap.vendor.as_deref(), Some("stealth-cdp/1.0"));
}
#[test]
fn detect_unknown_browser_falls_back_to_generic() {
let cap = EndpointCapabilities::detect(&probe("Other/1.0", ""));
assert_eq!(cap.kind, EndpointKind::GenericCdp);
assert!(!cap.identity_params);
}
#[test]
fn build_url_generic_returns_base_unchanged_even_with_hints() {
let cap = EndpointCapabilities::generic();
let hints = IdentityHints {
seed: Some("abc"),
timezone: Some("UTC"),
..IdentityHints::default()
};
let out = cap
.build_connect_url("http://127.0.0.1:9222", &hints)
.unwrap();
assert_eq!(out, "http://127.0.0.1:9222");
}
#[test]
fn build_url_native_stealth_appends_only_set_hints() {
let cap = EndpointCapabilities {
kind: EndpointKind::NativeStealth,
identity_params: true,
vendor: Some("cloakserve/x".to_string()),
};
let hints = IdentityHints {
seed: Some("session-42"),
timezone: Some("Europe/Lisbon"),
locale: Some("pt-PT"),
proxy: Some("http://user:pass@proxy.example:3128"),
geoip: Some("PT"),
};
let out = cap
.build_connect_url("http://stealth.example:9222", &hints)
.unwrap();
let parsed = url::Url::parse(&out).unwrap();
let q: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect();
assert_eq!(q.get("seed").map(String::as_str), Some("session-42"));
assert_eq!(q.get("timezone").map(String::as_str), Some("Europe/Lisbon"));
assert_eq!(q.get("locale").map(String::as_str), Some("pt-PT"));
assert_eq!(
q.get("proxy").map(String::as_str),
Some("http://user:pass@proxy.example:3128"),
);
assert_eq!(q.get("geoip").map(String::as_str), Some("PT"));
}
#[test]
fn build_url_empty_hints_returns_base_unchanged() {
let cap = EndpointCapabilities {
kind: EndpointKind::NativeStealth,
identity_params: true,
vendor: None,
};
let out = cap
.build_connect_url("http://stealth.example:9222", &IdentityHints::default())
.unwrap();
assert_eq!(out, "http://stealth.example:9222");
}
#[test]
fn build_url_preserves_existing_query() {
let cap = EndpointCapabilities {
kind: EndpointKind::NativeStealth,
identity_params: true,
vendor: None,
};
let hints = IdentityHints {
seed: Some("s1"),
..IdentityHints::default()
};
let out = cap
.build_connect_url("http://stealth.example:9222/?token=abc", &hints)
.unwrap();
let parsed = url::Url::parse(&out).unwrap();
let q: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect();
assert_eq!(q.get("token").map(String::as_str), Some("abc"));
assert_eq!(q.get("seed").map(String::as_str), Some("s1"));
}
}