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.)
143/// Keeps WebRTC from routing around the proxy.
144///
145/// `--proxy-server` carries TCP only. Chrome creates WebRTC's UDP sockets without
146/// consulting the proxy at all, so STUN leaves on the host's own interface and the
147/// server-reflexive candidate carries the real public IP while every HTTP request
148/// carries the proxy's. That pair is not a fingerprint oddity but a deanonymiser:
149/// the page learns the real address and can link every session that ever ran on
150/// this host, proxy or not.
151///
152/// `disable_non_proxied_udp` confines WebRTC to what it can route through the
153/// proxy, which is TCP. The API is untouched — `RTCPeerConnection` constructs,
154/// gathering runs to `complete`, and `getCapabilities()` (the codec list, by far
155/// the larger fingerprint surface here) is unaffected. Only the candidate list
156/// changes, and on a CONNECT proxy it changes completely: measured on Chrome 151,
157/// gathering runs to `complete` and yields *no candidates at all*. The mDNS host
158/// candidate goes with the rest, being UDP itself. So a leak test reads nothing
159/// rather than the wrong thing — a quieter answer than the real IP, but a more
160/// visible one than a short list, which is the shape of the trade.
161///
162/// That state is not invented. `WebRtcIPHandlingPolicy` is a stock Chrome
163/// enterprise policy, set by exactly the networks that do not want WebRTC
164/// bypassing their proxy — uncommon, but coherent with a browser whose traffic is
165/// proxied, which is the trade this makes.
166///
167/// Applied only alongside a proxy: on a direct connection there is nothing to
168/// route around, and a missing srflx candidate would be the only strange thing
169/// about the browser.
170///
171/// **The switch name is version-specific and Chrome ignores what it does not
172/// recognise.** `--force-webrtc-ip-handling-policy` is the name carried in
173/// Chromium's content switches; the string present in a stock Chrome 151 binary is
174/// the unprefixed `--webrtc-ip-handling-policy`, and passing the other one changes
175/// nothing at all — measured, with the real IP still in the candidate list. A
176/// silently ignored flag looks exactly like a working one from the outside, which
177/// is what `tests/webrtc.rs` is for. Re-check it after a Chrome major.
178///
179/// The tell-free version of this is not a browser flag at all — it is a host-level
180/// UDP route (tun2socks, WireGuard, a SOCKS5 exit that carries UDP), where STUN
181/// behaves normally and simply egresses at the proxy's address. Chrome cannot be
182/// configured into that, and neither can this crate.
183const WEBRTC_IP_POLICY_FLAG: &str = "--webrtc-ip-handling-policy=disable_non_proxied_udp";
184
185const SOFTWARE_GL_FLAGS: [&str; 3] = ["--use-gl=angle", "--use-angle=gl", "--ignore-gpu-blocklist"];
186
187/// A rustenium browser session with an identity applied.
188pub struct IdentitySession {
189 identity: Identity,
190 browser: ChromeBrowser,
191}
192
193impl IdentitySession {
194 /// Launch a new Chromium instance from the given config.
195 /// Applies all CDP emulation overrides and registers the stealth
196 /// bootstrap script before returning.
197 pub async fn launch(config: impl Into<IdentityConfig>) -> Result<Self, IdentityError> {
198 let config = config.into();
199 let geo = tz::resolve_geo(
200 config.identity.timezone.as_deref(),
201 config.identity.proxy.as_deref(),
202 )
203 .await?;
204 let timezone = geo.timezone.clone();
205
206 let mut chrome_config = config.chrome;
207 chrome_config.enable_bidi = false;
208 chrome_config.enable_cdp = true;
209
210 // Remove the `navigator.webdriver` tell at the source instead of patching it in JS.
211 let mut flags = chrome_config.browser_flags.take().unwrap_or_default();
212 flags.push("--disable-blink-features=AutomationControlled".to_string());
213 if !host_has_gpu() {
214 tracing::warn!(
215 "no GPU on this host; pointing ANGLE at the system GL. The limits, \
216 extension list and precision formats a page reads will describe a \
217 software renderer — only the vendor/renderer strings are spoofed."
218 );
219 flags.extend(SOFTWARE_GL_FLAGS.iter().map(|f| f.to_string()));
220 }
221 // Set the engine's locale before the first target exists. The CDP
222 // override below is authoritative, but it lands after startup, and a
223 // worker that was already running keeps the locale it was created with —
224 // so a target-count-dependent Intl mismatch is possible without this.
225 if let Some(lang) = config.identity.language.first() {
226 flags.push(format!("--lang={lang}"));
227 }
228 chrome_config.browser_flags = Some(flags);
229
230 if let Some(ref proxy_url) = config.identity.proxy {
231 if !proxy_url.is_empty() {
232 let local_addr = start_overlay(proxy_url)
233 .await
234 .map_err(|e| IdentityError::ProxyError(e.to_string()))?;
235 let mut flags = chrome_config.browser_flags.take().unwrap_or_default();
236 flags.push(format!(
237 "--proxy-server=http://127.0.0.1:{}",
238 local_addr.port()
239 ));
240 flags.push(WEBRTC_IP_POLICY_FLAG.to_string());
241 chrome_config.browser_flags = Some(flags);
242 }
243 }
244
245 let mut browser = ChromeBrowser::new(chrome_config).await;
246
247 // Report, do not correct. The identity is the source of truth; a launch
248 // that rewrites it produces a persona nobody configured and hides the
249 // real problem, which is a catalogue entry that no longer matches the
250 // binary being shipped.
251 //
252 // Worth warning about because the engine leaks the version independently
253 // of the UA — which CSS properties parse, which `window` members exist,
254 // which JS builtins are present — and each maps to a release range. A
255 // persona claiming a different major is caught with an exact distance.
256 // Only the Chromium-based personas apply; Safari and iOS carry a WebKit
257 // version.
258 if matches!(config.identity.browser, Browser::Chrome | Browser::Edge)
259 && !matches!(config.identity.os, Os::Ios)
260 {
261 match read_chromium_version(&mut browser).await {
262 Some(version) if version != config.identity.browser_version => tracing::warn!(
263 configured = ?config.identity.browser_version,
264 running = ?version,
265 "persona browser_version does not match the running Chromium"
266 ),
267 Some(_) => {}
268 None => tracing::warn!("could not read the running Chromium version"),
269 }
270 }
271
272 // Same rule for the GPU: report, do not correct. The catalogued strings
273 // are what the page sees, spoofed on getParameter.
274 //
275 // Worth warning about because only the two strings are spoofable — the
276 // capability profile behind them (MAX_TEXTURE_SIZE and the rest) comes
277 // from the real driver, and known (brand → capabilities) pairings are
278 // checked. A host whose renderer is far from the claimed card is a
279 // pairing that will not hold up.
280 match read_gpu_strings(&mut browser).await {
281 Some((_, renderer)) if renderer != config.identity.gpu.webgl_renderer => {
282 tracing::warn!(
283 host_renderer = %renderer,
284 persona_renderer = %config.identity.gpu.webgl_renderer,
285 "host GPU differs from the persona's; the WebGL capability \
286 profile comes from the host and will not match the claimed card"
287 )
288 }
289 Some(_) => {}
290 None => tracing::warn!("could not read the real WebGL vendor/renderer"),
291 }
292
293 cdp::apply_identity(&mut browser, &config.identity, &timezone).await?;
294
295 Ok(Self {
296 identity: config.identity,
297 browser,
298 })
299 }
300
301 /// Access the underlying rustenium ChromeBrowser.
302 pub fn browser(&self) -> &ChromeBrowser {
303 &self.browser
304 }
305
306 /// Mutable access to the underlying rustenium ChromeBrowser.
307 pub fn browser_mut(&mut self) -> &mut ChromeBrowser {
308 &mut self.browser
309 }
310
311 /// Get the identity.
312 pub fn identity(&self) -> &Identity {
313 &self.identity
314 }
315
316 pub async fn close(self) -> bool {
317 self.browser.close().await.map_err(|_| false).is_ok()
318 }
319}