rustenium-identity 0.1.12

A versatile stealth overlay for rustenium
Documentation
pub mod cdp;
pub mod error;
pub mod identity;
pub mod local_proxy_server;
pub mod preset;
pub mod script;
pub mod tz;
pub mod ua;

use error::IdentityError;
use local_proxy_server::start_overlay;
use rustenium::browsers::{
    BidiBrowser,
    chrome::browser::{ChromeBrowser, ChromeConfig},
};

pub use error::IdentityError as Error;
pub use identity::*;

/// Configuration for launching an identity-spoofed browser session.
pub struct IdentityConfig {
    pub identity: Identity,
    pub chrome: ChromeConfig,
}

impl From<Identity> for IdentityConfig {
    fn from(identity: Identity) -> Self {
        Self {
            identity,
            chrome: ChromeConfig {
                enable_bidi: false,
                enable_cdp: true,
                ..Default::default()
            },
        }
    }
}

impl IdentityConfig {
    pub fn new(identity: Identity, chrome: ChromeConfig) -> Self {
        Self { identity, chrome }
    }
}

/// Read the running browser's real Chromium version, before any UA override is in
/// place. Diagnostic only — the caller warns on a mismatch and never rewrites the
/// identity. Returns `None` if the version cannot be determined.
async fn read_chromium_version(browser: &mut ChromeBrowser) -> Option<Vec<u16>> {
    // `uaFullVersion` first: UA reduction freezes the UA string at `MAJOR.0.0.0`,
    // while the client hint still carries the true build. Taking the reduced form
    // would make us answer getHighEntropyValues with `146.0.0.0` — a value no real
    // Chrome returns, which is a worse tell than the version gap we came to fix.
    const EXPR: &str = r#"(async () => {
        try {
            const d = navigator.userAgentData;
            if (d && d.getHighEntropyValues) {
                const v = await d.getHighEntropyValues(['uaFullVersion']);
                if (v && v.uaFullVersion) return v.uaFullVersion;
            }
        } catch (e) {}
        const m = navigator.userAgent.match(/Chrome\/([\d.]+)/);
        return m ? m[1] : '';
    })()"#;
    // ChromeBrowser implements both browser traits; this is the CDP path.
    let result =
        rustenium::browsers::cdp_browser::CdpBrowser::evaluate_script(browser, EXPR, true)
            .await
            .ok()?;
    let version = result.result.value.as_ref()?.as_str()?.to_string();
    ua::parse_version_parts(&version)
}

/// Read the real unmasked WebGL vendor/renderer, before any patch is installed.
///
/// Diagnostic only — the caller warns on a mismatch and never rewrites the
/// identity. The renderer *string* is spoofable but the capability profile behind
/// it is not: `MAX_TEXTURE_SIZE` and friends come from the actual GPU, and known
/// (brand → capabilities) pairings are checked. Claiming a card whose capabilities
/// do not follow is the same shape of lie as claiming a browser version the engine
/// contradicts — which is worth knowing about, but the answer is a host with the
/// right GPU or a catalogue entry that suits it, not a persona rewritten at launch.
async fn read_gpu_strings(browser: &mut ChromeBrowser) -> Option<(String, String)> {
    const EXPR: &str = r#"(() => {
        try {
            const gl = document.createElement('canvas').getContext('webgl');
            const ext = gl && gl.getExtension('WEBGL_debug_renderer_info');
            if (!ext) return '[]';
            return JSON.stringify([
                gl.getParameter(ext.UNMASKED_VENDOR_WEBGL),
                gl.getParameter(ext.UNMASKED_RENDERER_WEBGL),
            ]);
        } catch (e) { return '[]'; }
    })()"#;
    let result =
        rustenium::browsers::cdp_browser::CdpBrowser::evaluate_script(browser, EXPR, false)
            .await
            .ok()?;
    let json = result.result.value.as_ref()?.as_str()?;
    let pair: Vec<String> = serde_json::from_str(json).ok()?;
    let [vendor, renderer] = <[String; 2]>::try_from(pair).ok()?;
    if vendor.is_empty() || renderer.is_empty() {
        return None;
    }
    Some((vendor, renderer))
}

/// Whether this machine has a GPU, answered before the browser starts.
///
/// Linux exposes one DRM render node per usable GPU — `/dev/dri/renderD128` and
/// up. A VM with no adapter has no `/dev/dri` at all (measured on the deployment
/// host: no `/dev/dri`, no `/sys/class/drm`, and no VGA device in `lspci`).
/// Windows and macOS always have a display adapter, so there is nothing to test.
fn host_has_gpu() -> bool {
    if !cfg!(target_os = "linux") {
        return true;
    }
    std::fs::read_dir("/dev/dri").is_ok_and(|nodes| {
        nodes
            .flatten()
            .any(|n| n.file_name().to_string_lossy().starts_with("renderD"))
    })
}

/// Flags that get a WebGL context back on a host with no GPU.
///
/// ANGLE defaults to its bundled SwiftShader over Vulkan, which fails to
/// initialize headless, and Chrome then blocklists the software renderer it falls
/// back to — leaving `getContext("webgl")` returning null. A desktop persona with
/// no WebGL at all fails the first vector every detector reads.
/// `--use-angle=gl` points ANGLE at the system GL instead, and
/// `--ignore-gpu-blocklist` is what actually unblocks it.
///
/// Applied **only** when `host_has_gpu()` is false, because on a machine that has
/// one they move ANGLE off the platform default — D3D11 on Windows, Metal on
/// macOS, where that default is what essentially every real install runs. Every
/// limit, extension and precision format then comes from the GL backend instead,
/// and since only 37445 and 37446 are spoofed those reach the detector untouched.
///
/// Measured, same machine and same persona: with these flags CreepJS bold-fails
/// the WebGL panel, without them it flags nothing. (On Linux, ANGLE over GL is an
/// ordinary configuration — the harm is specific to hosts whose default is not
/// GL in the first place.)
/// Keeps WebRTC from routing around the proxy.
///
/// `--proxy-server` carries TCP only. Chrome creates WebRTC's UDP sockets without
/// consulting the proxy at all, so STUN leaves on the host's own interface and the
/// server-reflexive candidate carries the real public IP while every HTTP request
/// carries the proxy's. That pair is not a fingerprint oddity but a deanonymiser:
/// the page learns the real address and can link every session that ever ran on
/// this host, proxy or not.
///
/// `disable_non_proxied_udp` confines WebRTC to what it can route through the
/// proxy, which is TCP. The API is untouched — `RTCPeerConnection` constructs,
/// gathering runs to `complete`, and `getCapabilities()` (the codec list, by far
/// the larger fingerprint surface here) is unaffected. Only the candidate list
/// changes, and on a CONNECT proxy it changes completely: measured on Chrome 151,
/// gathering runs to `complete` and yields *no candidates at all*. The mDNS host
/// candidate goes with the rest, being UDP itself. So a leak test reads nothing
/// rather than the wrong thing — a quieter answer than the real IP, but a more
/// visible one than a short list, which is the shape of the trade.
///
/// That state is not invented. `WebRtcIPHandlingPolicy` is a stock Chrome
/// enterprise policy, set by exactly the networks that do not want WebRTC
/// bypassing their proxy — uncommon, but coherent with a browser whose traffic is
/// proxied, which is the trade this makes.
///
/// Applied only alongside a proxy: on a direct connection there is nothing to
/// route around, and a missing srflx candidate would be the only strange thing
/// about the browser.
///
/// **The switch name is version-specific and Chrome ignores what it does not
/// recognise.** `--force-webrtc-ip-handling-policy` is the name carried in
/// Chromium's content switches; the string present in a stock Chrome 151 binary is
/// the unprefixed `--webrtc-ip-handling-policy`, and passing the other one changes
/// nothing at all — measured, with the real IP still in the candidate list. A
/// silently ignored flag looks exactly like a working one from the outside, which
/// is what `tests/webrtc.rs` is for. Re-check it after a Chrome major.
///
/// The tell-free version of this is not a browser flag at all — it is a host-level
/// UDP route (tun2socks, WireGuard, a SOCKS5 exit that carries UDP), where STUN
/// behaves normally and simply egresses at the proxy's address. Chrome cannot be
/// configured into that, and neither can this crate.
const WEBRTC_IP_POLICY_FLAG: &str = "--webrtc-ip-handling-policy=disable_non_proxied_udp";

const SOFTWARE_GL_FLAGS: [&str; 3] = ["--use-gl=angle", "--use-angle=gl", "--ignore-gpu-blocklist"];

/// A rustenium browser session with an identity applied.
pub struct IdentitySession {
    identity: Identity,
    browser: ChromeBrowser,
}

impl IdentitySession {
    /// Launch a new Chromium instance from the given config.
    /// Applies all CDP emulation overrides and registers the stealth
    /// bootstrap script before returning.
    pub async fn launch(config: impl Into<IdentityConfig>) -> Result<Self, IdentityError> {
        let config = config.into();
        let geo = tz::resolve_geo(
            config.identity.timezone.as_deref(),
            config.identity.proxy.as_deref(),
        )
        .await?;
        let timezone = geo.timezone.clone();

        let mut chrome_config = config.chrome;
        chrome_config.enable_bidi = false;
        chrome_config.enable_cdp = true;

        // Remove the `navigator.webdriver` tell at the source instead of patching it in JS.
        let mut flags = chrome_config.browser_flags.take().unwrap_or_default();
        flags.push("--disable-blink-features=AutomationControlled".to_string());
        if !host_has_gpu() {
            tracing::warn!(
                "no GPU on this host; pointing ANGLE at the system GL. The limits, \
                 extension list and precision formats a page reads will describe a \
                 software renderer — only the vendor/renderer strings are spoofed."
            );
            flags.extend(SOFTWARE_GL_FLAGS.iter().map(|f| f.to_string()));
        }
        // Set the engine's locale before the first target exists. The CDP
        // override below is authoritative, but it lands after startup, and a
        // worker that was already running keeps the locale it was created with —
        // so a target-count-dependent Intl mismatch is possible without this.
        if let Some(lang) = config.identity.language.first() {
            flags.push(format!("--lang={lang}"));
        }
        chrome_config.browser_flags = Some(flags);

        if let Some(ref proxy_url) = config.identity.proxy {
            if !proxy_url.is_empty() {
                let local_addr = start_overlay(proxy_url)
                    .await
                    .map_err(|e| IdentityError::ProxyError(e.to_string()))?;
                let mut flags = chrome_config.browser_flags.take().unwrap_or_default();
                flags.push(format!(
                    "--proxy-server=http://127.0.0.1:{}",
                    local_addr.port()
                ));
                flags.push(WEBRTC_IP_POLICY_FLAG.to_string());
                chrome_config.browser_flags = Some(flags);
            }
        }

        let mut browser = ChromeBrowser::new(chrome_config).await;

        // Report, do not correct. The identity is the source of truth; a launch
        // that rewrites it produces a persona nobody configured and hides the
        // real problem, which is a catalogue entry that no longer matches the
        // binary being shipped.
        //
        // Worth warning about because the engine leaks the version independently
        // of the UA — which CSS properties parse, which `window` members exist,
        // which JS builtins are present — and each maps to a release range. A
        // persona claiming a different major is caught with an exact distance.
        // Only the Chromium-based personas apply; Safari and iOS carry a WebKit
        // version.
        if matches!(config.identity.browser, Browser::Chrome | Browser::Edge)
            && !matches!(config.identity.os, Os::Ios)
        {
            match read_chromium_version(&mut browser).await {
                Some(version) if version != config.identity.browser_version => tracing::warn!(
                    configured = ?config.identity.browser_version,
                    running = ?version,
                    "persona browser_version does not match the running Chromium"
                ),
                Some(_) => {}
                None => tracing::warn!("could not read the running Chromium version"),
            }
        }

        // Same rule for the GPU: report, do not correct. The catalogued strings
        // are what the page sees, spoofed on getParameter.
        //
        // Worth warning about because only the two strings are spoofable — the
        // capability profile behind them (MAX_TEXTURE_SIZE and the rest) comes
        // from the real driver, and known (brand → capabilities) pairings are
        // checked. A host whose renderer is far from the claimed card is a
        // pairing that will not hold up.
        match read_gpu_strings(&mut browser).await {
            Some((_, renderer)) if renderer != config.identity.gpu.webgl_renderer => {
                tracing::warn!(
                    host_renderer = %renderer,
                    persona_renderer = %config.identity.gpu.webgl_renderer,
                    "host GPU differs from the persona's; the WebGL capability \
                     profile comes from the host and will not match the claimed card"
                )
            }
            Some(_) => {}
            None => tracing::warn!("could not read the real WebGL vendor/renderer"),
        }

        cdp::apply_identity(&mut browser, &config.identity, &timezone).await?;

        Ok(Self {
            identity: config.identity,
            browser,
        })
    }

    /// Access the underlying rustenium ChromeBrowser.
    pub fn browser(&self) -> &ChromeBrowser {
        &self.browser
    }

    /// Mutable access to the underlying rustenium ChromeBrowser.
    pub fn browser_mut(&mut self) -> &mut ChromeBrowser {
        &mut self.browser
    }

    /// Get the identity.
    pub fn identity(&self) -> &Identity {
        &self.identity
    }

    pub async fn close(self) -> bool {
        self.browser.close().await.map_err(|_| false).is_ok()
    }
}