Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions crates/web/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
})();
108 changes: 98 additions & 10 deletions crates/web/src/voice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<String> {
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::<js_sys::Array>() 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
}
106 changes: 106 additions & 0 deletions crates/web/tests/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}