Skip to main content

rustenium_identity/
lib.rs

1pub mod cdp;
2pub mod error;
3pub mod identity;
4pub mod local_proxy_server;
5pub mod preset;
6pub mod script;
7pub mod tz;
8pub mod ua;
9
10use error::IdentityError;
11use local_proxy_server::start_overlay;
12use rustenium::browsers::{
13    BidiBrowser,
14    chrome::browser::{ChromeBrowser, ChromeConfig},
15};
16
17pub use error::IdentityError as Error;
18pub use identity::*;
19
20/// Configuration for launching an identity-spoofed browser session.
21pub struct IdentityConfig {
22    pub identity: Identity,
23    pub chrome: ChromeConfig,
24}
25
26impl From<Identity> for IdentityConfig {
27    fn from(identity: Identity) -> Self {
28        Self {
29            identity,
30            chrome: ChromeConfig {
31                enable_bidi: false,
32                enable_cdp: true,
33                ..Default::default()
34            },
35        }
36    }
37}
38
39impl IdentityConfig {
40    pub fn new(identity: Identity, chrome: ChromeConfig) -> Self {
41        Self { identity, chrome }
42    }
43}
44
45/// Read the running browser's real Chromium version, before any UA override is in
46/// place. Diagnostic only — the caller warns on a mismatch and never rewrites the
47/// identity. Returns `None` if the version cannot be determined.
48async fn read_chromium_version(browser: &mut ChromeBrowser) -> Option<Vec<u16>> {
49    // `uaFullVersion` first: UA reduction freezes the UA string at `MAJOR.0.0.0`,
50    // while the client hint still carries the true build. Taking the reduced form
51    // would make us answer getHighEntropyValues with `146.0.0.0` — a value no real
52    // Chrome returns, which is a worse tell than the version gap we came to fix.
53    const EXPR: &str = r#"(async () => {
54        try {
55            const d = navigator.userAgentData;
56            if (d && d.getHighEntropyValues) {
57                const v = await d.getHighEntropyValues(['uaFullVersion']);
58                if (v && v.uaFullVersion) return v.uaFullVersion;
59            }
60        } catch (e) {}
61        const m = navigator.userAgent.match(/Chrome\/([\d.]+)/);
62        return m ? m[1] : '';
63    })()"#;
64    // ChromeBrowser implements both browser traits; this is the CDP path.
65    let result =
66        rustenium::browsers::cdp_browser::CdpBrowser::evaluate_script(browser, EXPR, true)
67            .await
68            .ok()?;
69    let version = result.result.value.as_ref()?.as_str()?.to_string();
70    ua::parse_version_parts(&version)
71}
72
73/// Read the real unmasked WebGL vendor/renderer, before any patch is installed.
74///
75/// Diagnostic only — the caller warns on a mismatch and never rewrites the
76/// identity. The renderer *string* is spoofable but the capability profile behind
77/// it is not: `MAX_TEXTURE_SIZE` and friends come from the actual GPU, and known
78/// (brand → capabilities) pairings are checked. Claiming a card whose capabilities
79/// do not follow is the same shape of lie as claiming a browser version the engine
80/// contradicts — which is worth knowing about, but the answer is a host with the
81/// right GPU or a catalogue entry that suits it, not a persona rewritten at launch.
82async fn read_gpu_strings(browser: &mut ChromeBrowser) -> Option<(String, String)> {
83    const EXPR: &str = r#"(() => {
84        try {
85            const gl = document.createElement('canvas').getContext('webgl');
86            const ext = gl && gl.getExtension('WEBGL_debug_renderer_info');
87            if (!ext) return '[]';
88            return JSON.stringify([
89                gl.getParameter(ext.UNMASKED_VENDOR_WEBGL),
90                gl.getParameter(ext.UNMASKED_RENDERER_WEBGL),
91            ]);
92        } catch (e) { return '[]'; }
93    })()"#;
94    let result =
95        rustenium::browsers::cdp_browser::CdpBrowser::evaluate_script(browser, EXPR, false)
96            .await
97            .ok()?;
98    let json = result.result.value.as_ref()?.as_str()?;
99    let pair: Vec<String> = serde_json::from_str(json).ok()?;
100    let [vendor, renderer] = <[String; 2]>::try_from(pair).ok()?;
101    if vendor.is_empty() || renderer.is_empty() {
102        return None;
103    }
104    Some((vendor, renderer))
105}
106
107/// Whether this machine has a GPU, answered before the browser starts.
108///
109/// Linux exposes one DRM render node per usable GPU — `/dev/dri/renderD128` and
110/// up. A VM with no adapter has no `/dev/dri` at all (measured on the deployment
111/// host: no `/dev/dri`, no `/sys/class/drm`, and no VGA device in `lspci`).
112/// Windows and macOS always have a display adapter, so there is nothing to test.
113fn host_has_gpu() -> bool {
114    if !cfg!(target_os = "linux") {
115        return true;
116    }
117    std::fs::read_dir("/dev/dri").is_ok_and(|nodes| {
118        nodes
119            .flatten()
120            .any(|n| n.file_name().to_string_lossy().starts_with("renderD"))
121    })
122}
123
124/// Flags that get a WebGL context back on a host with no GPU.
125///
126/// ANGLE defaults to its bundled SwiftShader over Vulkan, which fails to
127/// initialize headless, and Chrome then blocklists the software renderer it falls
128/// back to — leaving `getContext("webgl")` returning null. A desktop persona with
129/// no WebGL at all fails the first vector every detector reads.
130/// `--use-angle=gl` points ANGLE at the system GL instead, and
131/// `--ignore-gpu-blocklist` is what actually unblocks it.
132///
133/// Applied **only** when `host_has_gpu()` is false, because on a machine that has
134/// one they move ANGLE off the platform default — D3D11 on Windows, Metal on
135/// macOS, where that default is what essentially every real install runs. Every
136/// limit, extension and precision format then comes from the GL backend instead,
137/// and since only 37445 and 37446 are spoofed those reach the detector untouched.
138///
139/// Measured, same machine and same persona: with these flags CreepJS bold-fails
140/// the WebGL panel, without them it flags nothing. (On Linux, ANGLE over GL is an
141/// ordinary configuration — the harm is specific to hosts whose default is not
142/// GL in the first place.)
143const SOFTWARE_GL_FLAGS: [&str; 3] = ["--use-gl=angle", "--use-angle=gl", "--ignore-gpu-blocklist"];
144
145/// A rustenium browser session with an identity applied.
146pub struct IdentitySession {
147    identity: Identity,
148    browser: ChromeBrowser,
149}
150
151impl IdentitySession {
152    /// Launch a new Chromium instance from the given config.
153    /// Applies all CDP emulation overrides and registers the stealth
154    /// bootstrap script before returning.
155    pub async fn launch(config: impl Into<IdentityConfig>) -> Result<Self, IdentityError> {
156        let config = config.into();
157        let geo = tz::resolve_geo(
158            config.identity.timezone.as_deref(),
159            config.identity.proxy.as_deref(),
160        )
161        .await?;
162        let timezone = geo.timezone.clone();
163
164        let mut chrome_config = config.chrome;
165        chrome_config.enable_bidi = false;
166        chrome_config.enable_cdp = true;
167
168        // Remove the `navigator.webdriver` tell at the source instead of patching it in JS.
169        let mut flags = chrome_config.browser_flags.take().unwrap_or_default();
170        flags.push("--disable-blink-features=AutomationControlled".to_string());
171        if !host_has_gpu() {
172            tracing::warn!(
173                "no GPU on this host; pointing ANGLE at the system GL. The limits, \
174                 extension list and precision formats a page reads will describe a \
175                 software renderer — only the vendor/renderer strings are spoofed."
176            );
177            flags.extend(SOFTWARE_GL_FLAGS.iter().map(|f| f.to_string()));
178        }
179        // Set the engine's locale before the first target exists. The CDP
180        // override below is authoritative, but it lands after startup, and a
181        // worker that was already running keeps the locale it was created with —
182        // so a target-count-dependent Intl mismatch is possible without this.
183        if let Some(lang) = config.identity.language.first() {
184            flags.push(format!("--lang={lang}"));
185        }
186        chrome_config.browser_flags = Some(flags);
187
188        if let Some(ref proxy_url) = config.identity.proxy {
189            if !proxy_url.is_empty() {
190                let local_addr = start_overlay(proxy_url)
191                    .await
192                    .map_err(|e| IdentityError::ProxyError(e.to_string()))?;
193                let mut flags = chrome_config.browser_flags.take().unwrap_or_default();
194                flags.push(format!(
195                    "--proxy-server=http://127.0.0.1:{}",
196                    local_addr.port()
197                ));
198                chrome_config.browser_flags = Some(flags);
199            }
200        }
201
202        let mut browser = ChromeBrowser::new(chrome_config).await;
203
204        // Report, do not correct. The identity is the source of truth; a launch
205        // that rewrites it produces a persona nobody configured and hides the
206        // real problem, which is a catalogue entry that no longer matches the
207        // binary being shipped.
208        //
209        // Worth warning about because the engine leaks the version independently
210        // of the UA — which CSS properties parse, which `window` members exist,
211        // which JS builtins are present — and each maps to a release range. A
212        // persona claiming a different major is caught with an exact distance.
213        // Only the Chromium-based personas apply; Safari and iOS carry a WebKit
214        // version.
215        if matches!(config.identity.browser, Browser::Chrome | Browser::Edge)
216            && !matches!(config.identity.os, Os::Ios)
217        {
218            match read_chromium_version(&mut browser).await {
219                Some(version) if version != config.identity.browser_version => tracing::warn!(
220                    configured = ?config.identity.browser_version,
221                    running = ?version,
222                    "persona browser_version does not match the running Chromium"
223                ),
224                Some(_) => {}
225                None => tracing::warn!("could not read the running Chromium version"),
226            }
227        }
228
229        // Same rule for the GPU: report, do not correct. The catalogued strings
230        // are what the page sees, spoofed on getParameter.
231        //
232        // Worth warning about because only the two strings are spoofable — the
233        // capability profile behind them (MAX_TEXTURE_SIZE and the rest) comes
234        // from the real driver, and known (brand → capabilities) pairings are
235        // checked. A host whose renderer is far from the claimed card is a
236        // pairing that will not hold up.
237        match read_gpu_strings(&mut browser).await {
238            Some((_, renderer)) if renderer != config.identity.gpu.webgl_renderer => {
239                tracing::warn!(
240                    host_renderer = %renderer,
241                    persona_renderer = %config.identity.gpu.webgl_renderer,
242                    "host GPU differs from the persona's; the WebGL capability \
243                     profile comes from the host and will not match the claimed card"
244                )
245            }
246            Some(_) => {}
247            None => tracing::warn!("could not read the real WebGL vendor/renderer"),
248        }
249
250        cdp::apply_identity(&mut browser, &config.identity, &timezone).await?;
251
252        Ok(Self {
253            identity: config.identity,
254            browser,
255        })
256    }
257
258    /// Access the underlying rustenium ChromeBrowser.
259    pub fn browser(&self) -> &ChromeBrowser {
260        &self.browser
261    }
262
263    /// Mutable access to the underlying rustenium ChromeBrowser.
264    pub fn browser_mut(&mut self) -> &mut ChromeBrowser {
265        &mut self.browser
266    }
267
268    /// Get the identity.
269    pub fn identity(&self) -> &Identity {
270        &self.identity
271    }
272
273    pub async fn close(self) -> bool {
274        self.browser.close().await.map_err(|_| false).is_ok()
275    }
276}