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}