diff --git a/crates/web/init.js b/crates/web/init.js index ec67ce38..cf3b71a4 100644 --- a/crates/web/init.js +++ b/crates/web/init.js @@ -6,4 +6,13 @@ if (!window.__WILLOW_RELAY_URL && (h === '127.0.0.1' || h === 'localhost')) { window.__WILLOW_RELAY_URL = 'http://' + h + ':3340'; } + // STUN server overrides for WebRTC voice calls. Privacy-first default: + // no STUN servers are configured, so voice ICE relies on host candidates + // plus the iroh relay path — no third-party server learns the user's IP. + // See issue #179. + // + // To opt back into Google's public STUN server (leaks your IP to Google): + // window.__WILLOW_STUN_URLS = ['stun:stun.l.google.com:19302']; + // Or point at a self-hosted STUN server: + // window.__WILLOW_STUN_URLS = ['stun:stun.example.com:3478']; })(); diff --git a/crates/web/src/voice.rs b/crates/web/src/voice.rs index ece50b08..e0933187 100644 --- a/crates/web/src/voice.rs +++ b/crates/web/src/voice.rs @@ -283,17 +283,24 @@ impl VoiceManager { self.local_stream = Some(stream); } - /// Build an `RTCConfiguration` with a public STUN server. + /// Build an `RTCConfiguration` honouring the configured STUN URL list. + /// + /// See [`resolve_stun_urls`] for the privacy-first default (empty list) + /// and the `window.__WILLOW_STUN_URLS` override knob. fn rtc_config() -> RtcConfiguration { - let config = RtcConfiguration::new(); - let ice_servers = js_sys::Array::new(); - let server = RtcIceServer::new(); - let urls = js_sys::Array::new(); - urls.push(&"stun:stun.l.google.com:19302".into()); - server.set_urls(&urls); - ice_servers.push(&server); - config.set_ice_servers(&ice_servers); - config + build_rtc_config(&resolve_stun_urls()) + } + + /// Test-only accessor for the resolved `RTCConfiguration`. + /// + /// Browser tests live in a separate compilation unit (the + /// `wasm-bindgen-test` integration harness in `crates/web/tests/`) and + /// cannot see the private `rtc_config`. A thin `pub` wrapper exposes + /// just enough surface for those tests without leaking ICE-config + /// construction into the public API. + #[doc(hidden)] + pub fn rtc_config_for_test() -> RtcConfiguration { + Self::rtc_config() } /// Add local audio tracks (and video track if sharing) to a peer connection. @@ -819,3 +826,84 @@ impl VoiceManager { self.local_stream = None; } } + +/// Resolve the list of STUN server URLs to use for WebRTC ICE gathering. +/// +/// # Privacy-first default +/// +/// Returns an **empty list** by default. WebRTC will then rely on host +/// candidates plus whatever relay path the iroh layer provides — no +/// third-party STUN server learns the user's IP address. +/// +/// Hardcoding `stun:stun.l.google.com:19302` (the previous behaviour) leaked +/// every voice-call participant's public IP to Google. See issue #179. +/// +/// # Override +/// +/// Deployments that need NAT traversal via STUN can set the +/// `window.__WILLOW_STUN_URLS` global to an array of STUN URLs *before* the +/// WASM module boots, e.g. in `init.js`: +/// +/// ```js +/// // Opt back into Google's public STUN server (leaks your IP to Google): +/// window.__WILLOW_STUN_URLS = ['stun:stun.l.google.com:19302']; +/// +/// // Or point at a self-hosted STUN server: +/// window.__WILLOW_STUN_URLS = ['stun:stun.example.com:3478']; +/// ``` +/// +/// Values that are not a JS array, or entries that are not strings, are +/// silently ignored. +/// +/// # Follow-up +/// +/// - Medium term: ship a self-hosted STUN server alongside the relay. +/// - Long term: use the iroh relay path for ICE traversal directly so STUN +/// becomes unnecessary. +pub fn resolve_stun_urls() -> Vec { + let Some(window) = web_sys::window() else { + return Vec::new(); + }; + let raw = match js_sys::Reflect::get( + &window, + &wasm_bindgen::JsValue::from_str("__WILLOW_STUN_URLS"), + ) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + if raw.is_undefined() || raw.is_null() { + return Vec::new(); + } + let Ok(array) = raw.dyn_into::() else { + return Vec::new(); + }; + let mut out = Vec::with_capacity(array.length() as usize); + for i in 0..array.length() { + if let Some(s) = array.get(i).as_string() { + if !s.is_empty() { + out.push(s); + } + } + } + out +} + +/// Build an `RTCConfiguration` from a resolved list of STUN URLs. +/// +/// An empty `urls` list yields a config with **no** `iceServers` entries — +/// the privacy-first default. See [`resolve_stun_urls`] for rationale. +fn build_rtc_config(urls: &[String]) -> RtcConfiguration { + let config = RtcConfiguration::new(); + let ice_servers = js_sys::Array::new(); + if !urls.is_empty() { + let server = RtcIceServer::new(); + let url_array = js_sys::Array::new(); + for u in urls { + url_array.push(&wasm_bindgen::JsValue::from_str(u)); + } + server.set_urls(&url_array); + ice_servers.push(&server); + } + config.set_ice_servers(&ice_servers); + config +} diff --git a/crates/web/tests/browser.rs b/crates/web/tests/browser.rs index 4a8869b4..f114ea1a 100644 --- a/crates/web/tests/browser.rs +++ b/crates/web/tests/browser.rs @@ -12380,3 +12380,109 @@ mod service_worker_bridge { drop(cb); } } + +// ── STUN configuration (issue #179: privacy-first ICE config) ─────────────── + +mod stun_config { + //! Tests that the WebRTC ICE configuration honours the + //! `window.__WILLOW_STUN_URLS` override and defaults to an empty + //! `iceServers` list (privacy-first — no third-party STUN by default). + //! + //! See `crates/web/src/voice.rs::resolve_stun_urls` and + //! `crates/web/src/voice.rs::VoiceManager::rtc_config`. + + use wasm_bindgen::JsCast; + use wasm_bindgen_test::*; + use willow_web::voice; + + /// Clear any prior override so each test starts from a clean slate. + fn clear_override() { + let window = web_sys::window().expect("window exists"); + let _ = js_sys::Reflect::delete_property( + &window, + &wasm_bindgen::JsValue::from_str("__WILLOW_STUN_URLS"), + ); + } + + /// Set the global override to the supplied list of URLs. + fn set_override(urls: &[&str]) { + let window = web_sys::window().expect("window exists"); + let arr = js_sys::Array::new(); + for u in urls { + arr.push(&wasm_bindgen::JsValue::from_str(u)); + } + js_sys::Reflect::set( + &window, + &wasm_bindgen::JsValue::from_str("__WILLOW_STUN_URLS"), + &arr, + ) + .expect("set window override"); + } + + #[wasm_bindgen_test] + fn default_resolves_to_empty_list() { + clear_override(); + let urls = voice::resolve_stun_urls(); + assert!( + urls.is_empty(), + "default STUN URL list must be empty for privacy (got {urls:?})" + ); + } + + #[wasm_bindgen_test] + fn override_resolves_to_supplied_urls() { + set_override(&["stun:foo:1234", "stun:bar:5678"]); + let urls = voice::resolve_stun_urls(); + clear_override(); + assert_eq!( + urls, + vec!["stun:foo:1234".to_string(), "stun:bar:5678".to_string()] + ); + } + + #[wasm_bindgen_test] + fn default_rtc_config_has_no_ice_servers() { + clear_override(); + let cfg = voice::VoiceManager::rtc_config_for_test(); + // The `iceServers` property should either be absent or an empty array. + let ice_servers = + js_sys::Reflect::get(&cfg, &wasm_bindgen::JsValue::from_str("iceServers")) + .expect("read iceServers"); + if !ice_servers.is_undefined() && !ice_servers.is_null() { + let arr: js_sys::Array = ice_servers + .dyn_into() + .expect("iceServers should be an array if present"); + assert_eq!( + arr.length(), + 0, + "default iceServers must be empty (privacy-first default)" + ); + } + } + + #[wasm_bindgen_test] + fn override_rtc_config_includes_supplied_url() { + set_override(&["stun:example.com:3478"]); + let cfg = voice::VoiceManager::rtc_config_for_test(); + clear_override(); + + let ice_servers = + js_sys::Reflect::get(&cfg, &wasm_bindgen::JsValue::from_str("iceServers")) + .expect("read iceServers"); + let arr: js_sys::Array = ice_servers + .dyn_into() + .expect("iceServers should be an array"); + assert_eq!(arr.length(), 1, "exactly one ice server entry expected"); + + let server = arr.get(0); + let urls = js_sys::Reflect::get(&server, &wasm_bindgen::JsValue::from_str("urls")) + .expect("read urls"); + + // The `urls` field on a single RTCIceServer can be a string or an + // array of strings; web-sys's setter wraps in an array. + let urls_arr: js_sys::Array = urls.dyn_into().expect("urls should be array"); + assert_eq!(urls_arr.length(), 1); + let first = urls_arr.get(0).as_string().expect("url is a string"); + assert_eq!(first, "stun:example.com:3478"); + } +}