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