1use serde::{Deserialize, Serialize};
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
51#[serde(rename_all = "snake_case")]
52pub enum WebRtcPolicy {
53 AllowAll,
55 #[default]
61 DisableNonProxied,
62 BlockAll,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct ProxyLocation {
87 pub latitude: f64,
89 pub longitude: f64,
91 pub accuracy: f64,
93 pub timezone: String,
95 pub locale: String,
97}
98
99impl ProxyLocation {
100 pub fn new_us_east() -> Self {
102 Self {
103 latitude: 40.7128,
104 longitude: -74.0060,
105 accuracy: 1000.0,
106 timezone: "America/New_York".to_string(),
107 locale: "en-US".to_string(),
108 }
109 }
110
111 pub fn new_us_west() -> Self {
113 Self {
114 latitude: 34.0522,
115 longitude: -118.2437,
116 accuracy: 1000.0,
117 timezone: "America/Los_Angeles".to_string(),
118 locale: "en-US".to_string(),
119 }
120 }
121
122 pub fn new_uk() -> Self {
124 Self {
125 latitude: 51.5074,
126 longitude: -0.1278,
127 accuracy: 1000.0,
128 timezone: "Europe/London".to_string(),
129 locale: "en-GB".to_string(),
130 }
131 }
132
133 pub fn new_eu_central() -> Self {
135 Self {
136 latitude: 50.1109,
137 longitude: 8.6821,
138 accuracy: 1000.0,
139 timezone: "Europe/Berlin".to_string(),
140 locale: "de-DE".to_string(),
141 }
142 }
143
144 pub fn new_apac() -> Self {
146 Self {
147 latitude: 1.3521,
148 longitude: 103.8198,
149 accuracy: 1000.0,
150 timezone: "Asia/Singapore".to_string(),
151 locale: "en-SG".to_string(),
152 }
153 }
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize, Default)]
174pub struct WebRtcConfig {
175 pub policy: WebRtcPolicy,
177
178 pub public_ip: Option<String>,
183
184 pub local_ip: Option<String>,
187
188 pub location: Option<ProxyLocation>,
191}
192
193impl WebRtcConfig {
194 pub fn is_permissive(&self) -> bool {
205 self.policy == WebRtcPolicy::AllowAll && self.location.is_none()
206 }
207
208 pub fn chrome_args(&self) -> Vec<String> {
223 match self.policy {
224 WebRtcPolicy::AllowAll => vec![],
225 WebRtcPolicy::DisableNonProxied | WebRtcPolicy::BlockAll => {
226 vec!["--force-webrtc-ip-handling-policy=disable_non_proxied_udp".to_string()]
227 }
228 }
229 }
230
231 pub fn injection_script(&self) -> String {
247 let mut parts: Vec<String> = Vec::new();
248
249 let rtc_part = match self.policy {
250 WebRtcPolicy::AllowAll => String::new(),
251 WebRtcPolicy::DisableNonProxied => rtc_disable_non_proxied_script(),
252 WebRtcPolicy::BlockAll => {
253 let public_ip = self.public_ip.as_deref().unwrap_or("203.0.113.1");
254 let local_ip = self.local_ip.as_deref().unwrap_or("10.0.0.1");
255 rtc_block_all_script(public_ip, local_ip)
256 }
257 };
258
259 if !rtc_part.is_empty() {
260 parts.push(rtc_part);
261 }
262
263 if let Some(loc) = &self.location {
264 parts.push(geolocation_script(loc));
265 }
266
267 if parts.is_empty() {
268 return String::new();
269 }
270
271 format!(
273 "(function(){{\n 'use strict';\n{}\n}})();",
274 parts.join("\n")
275 )
276 }
277}
278
279fn rtc_disable_non_proxied_script() -> String {
285 r"
286 // WebRTC: suppress host/srflx candidates; allow relay (TURN) candidates only
287 (function patchRTCNonProxied() {
288 var _RPC = window.RTCPeerConnection;
289 if (!_RPC) return;
290 var patchedRPC = function(config) {
291 var pc = new _RPC(config);
292 var origSetLocalDescription = pc.setLocalDescription.bind(pc);
293 // Intercept onicecandidate to strip host + srflx candidates
294 var origOICH = pc.__lookupGetter__ ? null : null; // will use addEventListener
295 pc.addEventListener('icecandidate', function(e) {
296 if (e.candidate && e.candidate.candidate) {
297 var c = e.candidate.candidate;
298 // Drop host (LAN) and server-reflexive (public via STUN) candidates
299 if (c.indexOf('typ host') !== -1 || c.indexOf('typ srflx') !== -1) {
300 Object.defineProperty(e, 'candidate', { value: null, configurable: true });
301 }
302 }
303 }, true);
304 return pc;
305 };
306 patchedRPC.prototype = _RPC.prototype;
307 Object.defineProperty(window, 'RTCPeerConnection', {
308 value: patchedRPC,
309 writable: false,
310 configurable: false,
311 });
312 })();
313"
314 .to_string()
315}
316
317fn rtc_block_all_script(public_ip: &str, local_ip: &str) -> String {
320 format!(
321 r"
322 // WebRTC: replace all real IPs in SDP with fake ones
323 (function patchRTCBlockAll() {{
324 var _RPC = window.RTCPeerConnection;
325 if (!_RPC) return;
326 var PUBLIC_IP = '{public_ip}';
327 var LOCAL_IP = '{local_ip}';
328 var PRIV_RE = /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/;
329
330 function patchSDP(sdp) {{
331 return sdp.replace(
332 /(\b(?:\d{{1,3}}\.)\d{{1,3}}\.(?:\d{{1,3}}\.)\d{{1,3}}\b)/g,
333 function(ip) {{
334 if (ip === '127.0.0.1' || ip === '0.0.0.0') return ip;
335 if (PRIV_RE.test(ip)) return LOCAL_IP;
336 return PUBLIC_IP;
337 }}
338 );
339 }}
340
341 var patchedRPC = function(config) {{
342 // Remove all ICE servers so no STUN/TURN queries are made
343 if (config && Array.isArray(config.iceServers)) {{
344 config.iceServers = [];
345 }}
346 var pc = new _RPC(config);
347 ['createOffer', 'createAnswer'].forEach(function(method) {{
348 var orig = pc[method].bind(pc);
349 pc[method] = function() {{
350 return orig.apply(this, arguments).then(function(desc) {{
351 if (desc && desc.sdp) {{
352 return new RTCSessionDescription({{
353 type: desc.type,
354 sdp: patchSDP(desc.sdp),
355 }});
356 }}
357 return desc;
358 }});
359 }};
360 }});
361 return pc;
362 }};
363 patchedRPC.prototype = _RPC.prototype;
364 Object.defineProperty(window, 'RTCPeerConnection', {{
365 value: patchedRPC,
366 writable: false,
367 configurable: false,
368 }});
369 }})();
370"
371 )
372}
373
374fn geolocation_script(loc: &ProxyLocation) -> String {
376 format!(
377 r"
378 // Geolocation override to match proxy region
379 (function patchGeolocation() {{
380 var fakeCoords = {{
381 latitude: {lat},
382 longitude: {lon},
383 accuracy: {acc},
384 altitude: null,
385 altitudeAccuracy: null,
386 heading: null,
387 speed: null,
388 }};
389 var fakePosition = {{ coords: fakeCoords, timestamp: Date.now() }};
390
391 try {{
392 Object.defineProperty(navigator, 'geolocation', {{
393 value: {{
394 getCurrentPosition: function(success, _err, _opts) {{
395 setTimeout(function() {{ success(fakePosition); }}, 50 + Math.random() * 100);
396 }},
397 watchPosition: function(success, _err, _opts) {{
398 setTimeout(function() {{ success(fakePosition); }}, 50 + Math.random() * 100);
399 return 1;
400 }},
401 clearWatch: function() {{}},
402 }},
403 writable: false,
404 configurable: false,
405 }});
406 }} catch (_) {{
407 // Already non-configurable in some browsers; best-effort only.
408 }}
409 }})();
410",
411 lat = loc.latitude,
412 lon = loc.longitude,
413 acc = loc.accuracy,
414 )
415}
416
417#[cfg(test)]
420mod tests {
421 use super::*;
422
423 #[test]
424 fn default_policy_is_disable_non_proxied() {
425 assert_eq!(WebRtcPolicy::default(), WebRtcPolicy::DisableNonProxied);
426 }
427
428 #[test]
429 fn allow_all_has_no_chrome_args() {
430 let cfg = WebRtcConfig {
431 policy: WebRtcPolicy::AllowAll,
432 ..Default::default()
433 };
434 assert!(cfg.chrome_args().is_empty());
435 }
436
437 #[test]
438 fn disable_non_proxied_adds_webrtc_flag() {
439 let cfg = WebRtcConfig::default();
440 let args = cfg.chrome_args();
441 assert_eq!(args.len(), 1);
442 assert!(
443 args.first()
444 .is_some_and(|a| a.contains("disable_non_proxied_udp"))
445 );
446 }
447
448 #[test]
449 fn block_all_adds_webrtc_flag() {
450 let cfg = WebRtcConfig {
451 policy: WebRtcPolicy::BlockAll,
452 ..Default::default()
453 };
454 let args = cfg.chrome_args();
455 assert!(!args.is_empty());
456 assert!(args.iter().any(|a| a.contains("disable_non_proxied_udp")));
457 }
458
459 #[test]
460 fn allow_all_injection_script_is_empty() {
461 let cfg = WebRtcConfig {
462 policy: WebRtcPolicy::AllowAll,
463 ..Default::default()
464 };
465 assert!(cfg.injection_script().is_empty());
466 }
467
468 #[test]
469 fn disable_non_proxied_script_contains_rtc() {
470 let cfg = WebRtcConfig::default();
471 let script = cfg.injection_script();
472 assert!(script.contains("RTCPeerConnection"));
473 }
474
475 #[test]
476 fn block_all_script_contains_rtc_and_fake_ips() {
477 let cfg = WebRtcConfig {
478 policy: WebRtcPolicy::BlockAll,
479 public_ip: Some("1.2.3.4".to_string()),
480 local_ip: Some("10.0.0.5".to_string()),
481 ..Default::default()
482 };
483 let script = cfg.injection_script();
484 assert!(script.contains("RTCPeerConnection"));
485 assert!(script.contains("1.2.3.4"));
486 assert!(script.contains("10.0.0.5"));
487 }
488
489 #[test]
490 fn block_all_uses_default_fake_ips_when_none_set() {
491 let cfg = WebRtcConfig {
492 policy: WebRtcPolicy::BlockAll,
493 public_ip: None,
494 local_ip: None,
495 ..Default::default()
496 };
497 let script = cfg.injection_script();
498 assert!(script.contains("203.0.113.1"));
500 assert!(script.contains("10.0.0.1"));
501 }
502
503 #[test]
504 fn geolocation_script_included_when_location_set() {
505 let cfg = WebRtcConfig {
506 policy: WebRtcPolicy::AllowAll,
507 location: Some(ProxyLocation::new_us_east()),
508 ..Default::default()
509 };
510 let script = cfg.injection_script();
511 assert!(script.contains("geolocation"));
512 assert!(script.contains("40.7128"));
513 }
514
515 #[test]
516 fn is_permissive_only_when_allow_all_and_no_location() {
517 let mut cfg = WebRtcConfig {
518 policy: WebRtcPolicy::AllowAll,
519 ..Default::default()
520 };
521 assert!(cfg.is_permissive());
522
523 cfg.location = Some(ProxyLocation::new_uk());
524 assert!(!cfg.is_permissive());
525
526 cfg.location = None;
527 cfg.policy = WebRtcPolicy::DisableNonProxied;
528 assert!(!cfg.is_permissive());
529 }
530
531 #[test]
532 fn proxy_location_presets_have_valid_coords() {
533 let presets = [
534 ProxyLocation::new_us_east(),
535 ProxyLocation::new_us_west(),
536 ProxyLocation::new_uk(),
537 ProxyLocation::new_eu_central(),
538 ProxyLocation::new_apac(),
539 ];
540 for loc in &presets {
541 assert!(loc.latitude >= -90.0 && loc.latitude <= 90.0);
542 assert!(loc.longitude >= -180.0 && loc.longitude <= 180.0);
543 assert!(loc.accuracy > 0.0);
544 assert!(!loc.timezone.is_empty());
545 assert!(!loc.locale.is_empty());
546 }
547 }
548
549 #[test]
550 fn proxy_location_serializes_to_json() -> Result<(), Box<dyn std::error::Error>> {
551 let loc = ProxyLocation::new_us_east();
552 let json = serde_json::to_string(&loc)?;
553 let back: ProxyLocation = serde_json::from_str(&json)?;
554 assert!((back.latitude - loc.latitude).abs() < 1e-9);
555 assert_eq!(back.timezone, loc.timezone);
556 Ok(())
557 }
558
559 #[test]
560 fn webrtc_config_serializes_to_json() -> Result<(), Box<dyn std::error::Error>> {
561 let cfg = WebRtcConfig {
562 policy: WebRtcPolicy::DisableNonProxied,
563 public_ip: Some("1.2.3.4".to_string()),
564 local_ip: None,
565 location: Some(ProxyLocation::new_uk()),
566 };
567 let json = serde_json::to_string(&cfg)?;
568 let back: WebRtcConfig = serde_json::from_str(&json)?;
569 assert_eq!(back.policy, cfg.policy);
570 assert_eq!(back.public_ip, cfg.public_ip);
571 Ok(())
572 }
573
574 #[test]
575 fn combined_script_is_valid_iife() {
576 let cfg = WebRtcConfig {
577 policy: WebRtcPolicy::DisableNonProxied,
578 location: Some(ProxyLocation::new_apac()),
579 ..Default::default()
580 };
581 let script = cfg.injection_script();
582 assert!(script.starts_with("(function(){"));
583 assert!(script.ends_with("})();"));
584 assert!(script.contains("RTCPeerConnection"));
585 assert!(script.contains("geolocation"));
586 }
587}