//! Credential-safe native port of the `WebRtcTest` validation program. #![allow(clippy::cast_possible_truncation)] // The bounded test tone fits exactly in i16. #![allow(clippy::cast_precision_loss)] // Tone synthesis intentionally uses f32 sample positions. #![allow(clippy::struct_excessive_bools)] // Independent CLI safety gates are clearer as flags. use clap::Parser; use libremetaverse::types::compat::{CancellationToken, Uri}; use libremetaverse::{GridClient, NetworkManager, Simulator}; use libremetaverse_structured_data::{OSD, OSDFormat, OSDParser}; use libremetaverse_voice_webrtc::{ CloseRequest, LoopbackSignaling, ProvisionRequest, ProvisionResponse, SignalingCompleteRequest, VoiceEvent, VoiceSecret, VoiceSessionConfig, VoiceSignaling, WebRtcError, WebRtcVoiceSession, audio_devices, encode_pcm_48k_mono, }; use std::collections::HashMap; use std::fmt; use std::future::Future; use std::io::{self, BufRead, IsTerminal, Write}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket}; use std::path::{Path, PathBuf}; use std::pin::Pin; use std::process::ExitCode; use std::sync::Arc; use std::time::Duration; use tokio::io::AsyncBufReadExt as _; pub const EXIT_SUCCESS: u8 = 0; pub const EXIT_USAGE: u8 = 2; pub const EXIT_INPUT: u8 = 3; pub const EXIT_SERVICE: u8 = 4; const LIVE_CONFIRMATION: &str = "LOGIN"; const MAX_CAPABILITY_BYTES: usize = 1024 * 1024; #[derive(Parser)] #[command( name = "webrtc-test", version, about = "Validate native LibreMetaverse WebRTC voice, Opus audio, and SLData behavior", arg_required_else_help = true, after_help = "CI/offline: --fake runs a real in-process ICE/DTLS/SRTP/SCTP peer with virtual 48 kHz audio. Live grid login requires --allow-live-login --confirm-live-login LOGIN; joining voice additionally requires --allow-session-audio. Live credentials use GRID_USER, GRID_PASSWORD, and GRID_LOGIN_URL from the environment or workspace .env. Real hardware enumeration requires building with --features real-audio and remains opt-in through --input-device/--output-device. Native libopus development files are required. Prefer GRID_PASSWORD over a positional password." )] struct Cli { /// Avatar first name; `GRID_FIRST_NAME` is the fallback. #[arg(value_name = "FIRSTNAME")] first_name: Option, /// Avatar last name; `GRID_LAST_NAME` is the fallback. #[arg(value_name = "LASTNAME")] last_name: Option, /// Avatar password; prefer `GRID_PASSWORD` to avoid process-list exposure. #[arg(value_name = "PASSWORD")] password: Option, /// Run the full deterministic loopback signaling, peer, media, and data-channel flow. #[arg(long)] fake: bool, /// List selectable virtual and compiled-in real audio endpoints. #[arg(long)] list_devices: bool, /// Explicitly permit a live grid login. #[arg(long)] allow_live_login: bool, /// Required literal confirmation for live login. #[arg(long, value_name = "LOGIN")] confirm_live_login: Option, /// Join live parcel voice and transmit/receive media. #[arg(long, requires = "allow_live_login")] allow_session_audio: bool, /// Override the grid login endpoint; `GRID_LOGIN_URL` is the fallback. #[arg(long, value_name = "URL")] login_uri: Option, /// Concrete local interface address advertised as the host ICE candidate. #[arg(long, value_name = "IP")] bind_ip: Option, /// Optional parcel local ID included in WebRTC provisioning. #[arg(long, value_name = "ID")] parcel_local_id: Option, /// Selected capture endpoint ID. #[arg(long, default_value = "virtual:microphone")] input_device: String, /// Selected playback endpoint ID. #[arg(long, default_value = "virtual:speaker")] output_device: String, /// WAV file to resample, Opus-encode, and send as microphone input. #[arg(long, value_name = "FILE")] wav: Option, /// Read interactive commands from a file instead of stdin. #[arg(long, value_name = "FILE")] commands: Option, /// Maximum duration for login, capabilities, connection, and teardown. #[arg(long, default_value_t = 45, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))] timeout_seconds: u64, } #[derive(Debug)] enum ProgramError { Usage(&'static str), Input(String), Grid(&'static str), Voice(WebRtcError), Io(io::Error), Timeout(&'static str), } impl ProgramError { const fn exit_code(&self) -> u8 { match self { Self::Usage(_) => EXIT_USAGE, Self::Input(_) | Self::Io(_) => EXIT_INPUT, Self::Grid(_) | Self::Voice(_) | Self::Timeout(_) => EXIT_SERVICE, } } } impl fmt::Display for ProgramError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Usage(message) | Self::Grid(message) => formatter.write_str(message), Self::Input(message) => write!(formatter, "invalid input: {message}"), Self::Voice(error) => error.fmt(formatter), Self::Io(error) => error.fmt(formatter), Self::Timeout(action) => write!(formatter, "{action} timed out"), } } } impl From for ProgramError { fn from(error: WebRtcError) -> Self { Self::Voice(error) } } impl From for ProgramError { fn from(error: io::Error) -> Self { Self::Io(error) } } /// Runs the `WebRtcTest` command. #[must_use] pub fn main_entry() -> ExitCode { let cli = Cli::parse(); let Ok(runtime) = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() else { eprintln!("webrtc-test: could not initialize the async runtime"); return ExitCode::from(EXIT_SERVICE); }; match runtime.block_on(run(cli)) { Ok(()) => ExitCode::SUCCESS, Err(error) => { eprintln!("webrtc-test: {error}"); ExitCode::from(error.exit_code()) } } } async fn run(cli: Cli) -> Result<(), ProgramError> { if cli.list_devices { if cli.fake || cli.first_name.is_some() || cli.last_name.is_some() || cli.password.is_some() || cli.allow_live_login { return Err(ProgramError::Usage( "--list-devices cannot be combined with fake or live mode", )); } print_devices()?; return Ok(()); } if cli.fake { if cli.first_name.is_some() || cli.last_name.is_some() || cli.password.is_some() || cli.allow_live_login || cli.confirm_live_login.is_some() || cli.allow_session_audio || cli.login_uri.is_some() || cli.commands.is_some() { return Err(ProgramError::Usage( "credentials and live/interactive options cannot be combined with --fake", )); } return run_fake(Duration::from_secs(cli.timeout_seconds)).await; } run_live(cli).await } fn print_devices() -> Result<(), ProgramError> { for device in audio_devices()? { println!( "{}\t{}\t{}{}", if device.input { "input" } else { "output" }, device.id, device.name, if device.is_default { " [default]" } else { "" } ); } Ok(()) } async fn run_fake(timeout: Duration) -> Result<(), ProgramError> { let peer = libremetaverse::types::UUID::new_with_string("11111111-2222-4333-8444-555555555555".into()) .map_err(|_| ProgramError::Grid("could not build deterministic peer ID"))?; let signaling = LoopbackSignaling::new(peer); let mut session = WebRtcVoiceSession::connect( signaling.clone(), VoiceSessionConfig { timeout, ..VoiceSessionConfig::default() }, ) .await?; let mut saw_connected = false; let mut saw_channel = false; let mut saw_audio_state = false; let mut saw_position = false; let mut saw_mute = false; let mut saw_gain = false; tokio::time::timeout(timeout, async { while !(saw_connected && saw_channel && saw_audio_state && saw_position && saw_mute && saw_gain) { match session.next_event().await { Some(VoiceEvent::Connected) => saw_connected = true, Some(VoiceEvent::DataChannelReady) => saw_channel = true, Some(VoiceEvent::PeerAudio(id, _)) if id == peer => saw_audio_state = true, Some(VoiceEvent::PeerPosition(id, _)) if id == peer => saw_position = true, Some(VoiceEvent::MuteMap(map)) if map.contains_key(&peer) => saw_mute = true, Some(VoiceEvent::GainMap(map)) if map.contains_key(&peer) => saw_gain = true, Some(VoiceEvent::Diagnostic(message)) => { return Err(ProgramError::Input(message)); } None | Some(VoiceEvent::Closed) => { return Err(ProgramError::Grid("fake WebRTC peer closed prematurely")); } _ => {} } } Ok(()) }) .await .map_err(|_| ProgramError::Timeout("fake WebRTC connection"))??; session.set_peer_mute(peer, true).await?; session.set_peer_gain(peer, 500).await?; session .send_position( [1.0, 2.0, 3.0], [0.0, 0.0, 0.0, 1.0], [4.0, 5.0, 6.0], [0.0, 0.0, 0.0, 1.0], ) .await?; let pcm: Vec = (0..4 * 960) .map(|index| { ((index as f32 / 48_000.0 * 440.0 * std::f32::consts::TAU).sin() * 8_000.0) as i16 }) .collect(); session .play_frames(encode_pcm_48k_mono(&pcm)?, false) .await?; tokio::time::timeout(timeout, async { while session.snapshot().received_audio_frames < 4 { if session.next_event().await.is_none() { return Err(ProgramError::Grid("fake audio echo stopped prematurely")); } } Ok(()) }) .await .map_err(|_| ProgramError::Timeout("virtual audio echo"))??; session.shutdown().await?; signaling.wait_closed(timeout).await?; let snapshot = session.snapshot(); let sent = signaling.received_messages(); if snapshot.active_tasks != 0 || signaling.active_tasks() != 0 || !sent.iter().any(|value| value.contains("\"m\"")) || !sent .iter() .any(|value| value.contains("\"ug\"") && value.contains("220")) || !sent.iter().any(|value| value.contains("\"sp\"")) || !sent.iter().any(|value| value.contains("\"pong\"")) { return Err(ProgramError::Grid( "fake peer did not observe every required SLData action or clean teardown", )); } println!( "Fake WebRTC validation complete; ice=connected dtls=connected srtp=opus sctp=SLData peer-events=4 sent-audio={} received-audio={} active-peer-tasks=0 active-audio-tasks=0", snapshot.sent_audio_frames, snapshot.received_audio_frames ); Ok(()) } async fn run_live(mut cli: Cli) -> Result<(), ProgramError> { if !cli.allow_live_login || cli.confirm_live_login.as_deref() != Some(LIVE_CONFIRMATION) { return Err(ProgramError::Usage( "live mode requires --allow-live-login --confirm-live-login LOGIN", )); } let (first_name, last_name) = grid_names(cli.first_name.take(), cli.last_name.take())?; let password = required(cli.password.take(), "GRID_PASSWORD", "PASSWORD is required")?; let timeout = Duration::from_secs(cli.timeout_seconds); let client = GridClient::new().map_err(|_| ProgramError::Grid("could not construct GridClient"))?; let network = client.network(); let mut login = network .default_login_params( first_name, last_name, password, "WebRtcTest".into(), env!("CARGO_PKG_VERSION").into(), ) .map_err(|_| ProgramError::Grid("could not build grid login parameters"))?; login.login_location = "WebRTC Voice 1/128/128/50".into(); if let Some(uri) = cli .login_uri .take() .or_else(|| credential("GRID_LOGIN_URL")) { login.uri = uri; } println!("Logging into the grid with redacted credentials..."); let logged_in = tokio::time::timeout( timeout, network.login_with_login_params_cancellation_token(login, None), ) .await .map_err(|_| ProgramError::Timeout("grid login"))? .unwrap_or(false); if !logged_in { return Err(ProgramError::Grid("grid login was rejected")); } let operation = async { let simulator = wait_for_event_queue(&network, timeout).await?; let provision_uri = capability(&simulator, "ProvisionVoiceAccountRequest")?; let signaling_uri = capability(&simulator, "VoiceSignalingRequest")?; println!("WebRTC voice capabilities are present; capability URLs are redacted."); if !cli.allow_session_audio { println!("Live voice not joined; pass --allow-session-audio to create a peer and transmit/receive audio."); return Ok(()); } let bind_ip = cli.bind_ip.unwrap_or_else(discover_bind_ip); let signaling: Arc = Arc::new(GridCapabilitySignaling { client: client.clone(), provision_uri, signaling_uri, timeout, }); let mut session = WebRtcVoiceSession::connect( signaling, VoiceSessionConfig { bind_ip, parcel_local_id: cli.parcel_local_id, timeout, input_device: cli.input_device, output_device: cli.output_device, }, ) .await?; let session_result = async { wait_ready(&mut session, timeout).await?; println!("WebRTC voice connected; ICE/DTLS/SRTP and ordered SLData are ready."); if let Some(wav) = cli.wav.as_deref() { session.play_wav(wav, true).await?; println!("WAV microphone playback started (48 kHz mono Opus)."); } run_commands(&mut session, cli.commands.as_deref()).await } .await; let shutdown_result = session.shutdown().await; if session.snapshot().active_tasks != 0 { return Err(ProgramError::Grid("peer/audio tasks remained after shutdown")); } session_result?; shutdown_result?; println!("WebRTC voice disconnected; peer, audio, and signaling resources closed."); Ok(()) } .await; let _ = network.logout_with_method(); operation } fn required( value: Option, environment: &str, message: &'static str, ) -> Result { value .or_else(|| credential(environment)) .ok_or(ProgramError::Usage(message)) } fn credential(name: &str) -> Option { std::env::var(name) .ok() .filter(|value| !value.trim().is_empty()) .or_else(|| { let dotenv = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.env"); let contents = std::fs::read_to_string(dotenv).ok()?; contents.lines().find_map(|line| { let line = line.trim().strip_prefix("export ").unwrap_or(line.trim()); let (key, value) = line.split_once('=')?; (key.trim() == name) .then(|| value.trim().trim_matches(['\'', '"']).to_owned()) .filter(|value| !value.is_empty()) }) }) } fn grid_names( first_name: Option, last_name: Option, ) -> Result<(String, String), ProgramError> { let first_name = first_name.or_else(|| credential("GRID_FIRST_NAME")); let last_name = last_name.or_else(|| credential("GRID_LAST_NAME")); match (first_name, last_name) { (Some(first), Some(last)) if !first.trim().is_empty() && !last.trim().is_empty() => { Ok((first, last)) } (Some(_), None) | (None, Some(_)) => Err(ProgramError::Usage( "FIRSTNAME and LASTNAME must be provided together", )), _ => split_grid_user(&credential("GRID_USER").ok_or(ProgramError::Usage( "FIRSTNAME/LASTNAME or GRID_USER is required", ))?), } } fn split_grid_user(value: &str) -> Result<(String, String), ProgramError> { let mut names = value.split_whitespace(); let (Some(first), Some(last), None) = (names.next(), names.next(), names.next()) else { return Err(ProgramError::Usage( "GRID_USER must contain exactly FIRSTNAME LASTNAME", )); }; Ok((first.to_owned(), last.to_owned())) } async fn wait_for_event_queue( network: &NetworkManager, timeout: Duration, ) -> Result { tokio::time::timeout(timeout, async { loop { if let Some(simulator) = network.current_sim() && simulator .is_event_queue_running(Some(false)) .unwrap_or(false) { return simulator; } tokio::time::sleep(Duration::from_millis(100)).await; } }) .await .map_err(|_| ProgramError::Timeout("EventQueueRunning")) } fn capability(simulator: &Simulator, name: &'static str) -> Result { simulator .native_capability_uri(name) .map_err(|_| ProgramError::Grid("voice capability lookup failed"))? .ok_or(ProgramError::Grid( "required WebRTC voice capability is missing", )) } fn discover_bind_ip() -> IpAddr { let socket = StdUdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)); if let Ok(socket) = socket && socket .connect(SocketAddr::from(([192, 0, 2, 1], 9))) .is_ok() && let Ok(address) = socket.local_addr() && !address.ip().is_unspecified() { return address.ip(); } IpAddr::V4(Ipv4Addr::LOCALHOST) } async fn wait_ready( session: &mut WebRtcVoiceSession, timeout: Duration, ) -> Result<(), ProgramError> { tokio::time::timeout(timeout, async { while !session.snapshot().data_channel_ready { match session.next_event().await { Some(VoiceEvent::Diagnostic(message)) => { return Err(ProgramError::Input(message)); } Some(VoiceEvent::Closed) | None => { return Err(ProgramError::Grid( "WebRTC peer closed before SLData opened", )); } Some(event) => print_event(&event), } } Ok(()) }) .await .map_err(|_| ProgramError::Timeout("WebRTC peer connection"))? } fn print_event(event: &VoiceEvent) { match event { VoiceEvent::PeerAudio(id, state) => println!( "[Voice] PeerAudio {id} Power={:?} VAD={:?} JoinedPrimary={:?} Left={}", state.power, state.voice_active, state.joined_primary, state.left ), VoiceEvent::PeerPosition(id, position) => { println!( "[Voice] PeerPosition {id} sender={:?}", position.sender_position ); } VoiceEvent::MuteMap(map) => { for (id, value) in map { println!("[Voice] Mute {id} = {value}"); } } VoiceEvent::GainMap(map) => { for (id, value) in map { println!("[Voice] Gain {id} = {value}"); } } _ => {} } } async fn run_commands( session: &mut WebRtcVoiceSession, path: Option<&Path>, ) -> Result<(), ProgramError> { if let Some(path) = path { let file = io::BufReader::new(std::fs::File::open(path)?); for line in file.lines().take(1_024) { if !execute_command(session, &line?).await? { break; } } return Ok(()); } if !io::stdin().is_terminal() { return Ok(()); } println!( "Commands: peers, mute , gain <0-220>, playwav , stopwav, quit" ); let mut lines = tokio::io::BufReader::new(tokio::io::stdin()).lines(); loop { print!("> "); io::stdout().flush()?; tokio::select! { signal = tokio::signal::ctrl_c() => { signal?; println!("\nCancellation requested; closing WebRTC voice."); break; } line = lines.next_line() => { let Some(line) = line? else { break }; if !execute_command(session, &line).await? { break; } } } } Ok(()) } async fn execute_command( session: &mut WebRtcVoiceSession, line: &str, ) -> Result { let fields: Vec<_> = line.split_whitespace().collect(); let Some(command) = fields.first().map(|value| value.to_ascii_lowercase()) else { return Ok(true); }; match command.as_str() { "quit" | "exit" => return Ok(false), "peers" | "list" => { for peer in session.snapshot().known_peers { println!("{peer}"); } } "mute" if fields.len() == 3 => { let peer = parse_peer(fields[1])?; let mute = fields[2] .parse() .map_err(|_| ProgramError::Input("mute value must be true or false".into()))?; session.set_peer_mute(peer, mute).await?; } "gain" if fields.len() == 3 => { let peer = parse_peer(fields[1])?; let gain = fields[2] .parse() .map_err(|_| ProgramError::Input("gain must be an integer".into()))?; session.set_peer_gain(peer, gain).await?; } "playwav" if fields.len() == 2 => { session.play_wav(Path::new(fields[1]), true).await?; } "stopwav" if fields.len() == 1 => session.stop_wav().await?, _ => { return Err(ProgramError::Input( "unknown command or incorrect command arguments".into(), )); } } Ok(true) } fn parse_peer(value: &str) -> Result { libremetaverse::types::UUID::new_with_string(value.to_owned()) .map_err(|_| ProgramError::Input("peer ID must be a UUID".into())) } struct GridCapabilitySignaling { client: GridClient, provision_uri: Uri, signaling_uri: Uri, timeout: Duration, } impl GridCapabilitySignaling { async fn post(&self, uri: &Uri, body: OSD) -> Result { let http = self.client.http_caps_client(); let request = http.post_with_uri_osd_format_osd_cancellation_token_i_progress( uri.clone(), OSDFormat::Xml, body, CancellationToken::default(), None, ); let (response, bytes) = tokio::time::timeout(self.timeout, request) .await .map_err(|_| WebRtcError::Timeout("grid voice capability"))? .map_err(|_| WebRtcError::Signaling("capability request failed".into()))?; if !response.is_success_status_code() || bytes.len() > MAX_CAPABILITY_BYTES { return Err(WebRtcError::Signaling(format!( "capability returned HTTP {}", response.status_code ))); } OSDParser::deserialize_with_bytes(bytes) .map_err(|_| WebRtcError::Signaling("capability returned malformed LLSD".into())) } } impl VoiceSignaling for GridCapabilitySignaling { fn provision( &self, request: ProvisionRequest, ) -> Pin> + Send + '_>> { Box::pin(async move { let mut jsep = HashMap::new(); jsep.insert("type".into(), OSD::String(request.jsep.kind)); jsep.insert("sdp".into(), OSD::String(request.jsep.sdp)); let mut body = HashMap::new(); body.insert("jsep".into(), OSD::Map(jsep)); body.insert("channel_type".into(), OSD::String(request.channel_type)); body.insert( "voice_server_type".into(), OSD::String(request.voice_server_type), ); if let Some(id) = request.parcel_local_id { body.insert("parcel_local_id".into(), OSD::Integer(id)); } let OSD::Map(response) = self.post(&self.provision_uri, OSD::Map(body)).await? else { return Err(WebRtcError::Signaling( "provision response was not a map".into(), )); }; let answer_sdp = match response.get("jsep") { Some(OSD::Map(jsep)) => jsep .get("sdp") .and_then(|value| value.as_string().ok()) .filter(|value| !value.is_empty()), _ => None, } .ok_or_else(|| WebRtcError::Signaling("provision response omitted SDP".into()))?; let viewer_session = response .get("viewer_session") .and_then(|value| value.as_uuid().ok()) .filter(|value| *value != libremetaverse::types::UUID::zero()) .ok_or_else(|| { WebRtcError::Signaling("provision response omitted session ID".into()) })?; let channel = response .get("channel") .and_then(|value| value.as_string().ok()) .filter(|value| !value.is_empty()); let credentials = response .get("credentials") .and_then(|value| value.as_string().ok()) .filter(|value| !value.is_empty()) .map(VoiceSecret::new) .transpose()?; Ok(ProvisionResponse { answer_sdp, viewer_session, channel, credentials, }) }) } fn complete( &self, request: SignalingCompleteRequest, ) -> Pin> + Send + '_>> { Box::pin(async move { let mut complete = HashMap::new(); complete.insert( "completed".into(), OSD::Boolean(request.candidate.completed), ); let mut body = HashMap::new(); body.insert( "voice_server_type".into(), OSD::String(request.voice_server_type), ); body.insert("viewer_session".into(), OSD::String(request.viewer_session)); body.insert("candidate".into(), OSD::Map(complete)); let _ = self.post(&self.signaling_uri, OSD::Map(body)).await?; Ok(()) }) } fn close( &self, request: CloseRequest, ) -> Pin> + Send + '_>> { Box::pin(async move { let mut body = HashMap::new(); body.insert("logout".into(), OSD::Boolean(request.logout)); body.insert( "voice_server_type".into(), OSD::String(request.voice_server_type), ); body.insert("viewer_session".into(), OSD::String(request.viewer_session)); if let Some(channel) = request.channel { body.insert("channel".into(), OSD::String(channel)); } if let Some(credentials) = request.credentials { body.insert( "credentials".into(), OSD::String(credentials.expose().to_owned()), ); } let _ = self.post(&self.provision_uri, OSD::Map(body)).await?; Ok(()) }) } } #[cfg(test)] mod tests { use super::*; #[test] fn live_mode_is_doubly_gated_and_does_not_echo_password() { let cli = Cli::try_parse_from(["webrtc-test", "First", "Last", "secret"]).unwrap(); let error = tokio::runtime::Runtime::new() .unwrap() .block_on(run(cli)) .unwrap_err(); assert!(matches!(error, ProgramError::Usage(_))); assert!(!error.to_string().contains("secret")); } #[test] fn bind_discovery_returns_a_concrete_address() { assert!(!discover_bind_ip().is_unspecified()); } #[test] fn grid_user_requires_the_repository_first_last_shape() { assert_eq!( split_grid_user("First Last").unwrap(), ("First".to_owned(), "Last".to_owned()) ); assert!(split_grid_user("First").is_err()); assert!(split_grid_user("First Middle Last").is_err()); } }