diff --git a/src/calls.rs b/src/calls.rs index 5e67e09c2..9454a91d3 100644 --- a/src/calls.rs +++ b/src/calls.rs @@ -18,6 +18,7 @@ use anyhow::{Context as _, Result, ensure}; use sdp::SessionDescription; use serde::Serialize; use std::io::Cursor; +use std::str::FromStr; use std::time::Duration; use tokio::task; use tokio::time::sleep; @@ -38,6 +39,8 @@ const RINGING_SECONDS: i64 = 60; const CALL_ACCEPTED_TIMESTAMP: Param = Param::Arg; const CALL_ENDED_TIMESTAMP: Param = Param::Arg4; +const STUN_PORT: u16 = 3478; + /// Set if incoming call was ended explicitly /// by the other side before we accepted it. /// @@ -536,21 +539,14 @@ struct IceServer { pub credential: Option, } -/// Returns JSON with ICE servers. -/// -/// -/// -/// All returned servers are resolved to their IP addresses. -/// The primary point of DNS lookup is that Delta Chat Desktop -/// relies on the servers being specified by IP, -/// because it itself cannot utilize DNS. See -/// . -pub async fn ice_servers(context: &Context) -> Result { - let hostname = "ci-chatmail.testrun.org"; - let port = 3478; - let username = "ohV8aec1".to_string(); - let password = "zo3theiY".to_string(); - +/// Creates JSON with ICE servers. +async fn create_ice_servers( + context: &Context, + hostname: &str, + port: u16, + username: &str, + password: &str, +) -> Result { // Do not use cache because there is no TLS. let load_cache = false; let urls: Vec = lookup_host_with_cache(context, hostname, port, "", load_cache) @@ -561,13 +557,76 @@ pub async fn ice_servers(context: &Context) -> Result { let ice_server = IceServer { urls, - username: Some(username), - credential: Some(password), + username: Some(username.to_string()), + credential: Some(password.to_string()), }; let json = serde_json::to_string(&[ice_server])?; Ok(json) } +/// Creates JSON with ICE servers from a line received over IMAP METADATA. +/// +/// IMAP METADATA returns a line such as +/// `example.com:3478:1758650868:8Dqkyyu11MVESBqjbIylmB06rv8=` +/// +/// 1758650868 is the username and expiration timestamp +/// at the same time, +/// while `8Dqkyyu11MVESBqjbIylmB06rv8=` +/// is the password. +pub(crate) async fn create_ice_servers_from_metadata( + context: &Context, + metadata: &str, +) -> Result<(i64, String)> { + let (hostname, rest) = metadata.split_once(':').context("Missing hostname")?; + let (port, rest) = rest.split_once(':').context("Missing port")?; + let port = u16::from_str(port).context("Failed to parse the port")?; + let (ts, password) = rest.split_once(':').context("Missing timestamp")?; + let expiration_timestamp = i64::from_str(ts).context("Failed to parse the timestamp")?; + let ice_servers = create_ice_servers(context, hostname, port, ts, password).await?; + Ok((expiration_timestamp, ice_servers)) +} + +/// Creates JSON with ICE servers when no TURN servers are known. +pub(crate) async fn create_fallback_ice_servers(context: &Context) -> Result { + // Public STUN server from https://stunprotocol.org/, + // an open source STUN server. + let hostname = "stunserver2025.stunprotocol.org"; + + // Do not use cache because there is no TLS. + let load_cache = false; + let urls: Vec = lookup_host_with_cache(context, hostname, STUN_PORT, "", load_cache) + .await? + .into_iter() + .map(|addr| format!("stun:{addr}")) + .collect(); + + let ice_server = IceServer { + urls, + username: None, + credential: None, + }; + + let json = serde_json::to_string(&[ice_server])?; + Ok(json) +} + +/// Returns JSON with ICE servers. +/// +/// +/// +/// All returned servers are resolved to their IP addresses. +/// The primary point of DNS lookup is that Delta Chat Desktop +/// relies on the servers being specified by IP, +/// because it itself cannot utilize DNS. See +/// . +pub async fn ice_servers(context: &Context) -> Result { + if let Some(ref metadata) = *context.metadata.read().await { + Ok(metadata.ice_servers.clone()) + } else { + Ok("[]".to_string()) + } +} + #[cfg(test)] mod calls_tests; diff --git a/src/imap.rs b/src/imap.rs index 3ff463bda..b29422ac9 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -24,6 +24,7 @@ use rand::Rng; use ratelimit::Ratelimit; use url::Url; +use crate::calls::{create_fallback_ice_servers, create_ice_servers_from_metadata}; use crate::chat::{self, ChatId, ChatIdBlocked}; use crate::chatlist_events; use crate::config::Config; @@ -47,7 +48,7 @@ use crate::receive_imf::{ }; use crate::scheduler::connectivity::ConnectivityStore; use crate::stock_str; -use crate::tools::{self, create_id, duration_to_str}; +use crate::tools::{self, create_id, duration_to_str, time}; pub(crate) mod capabilities; mod client; @@ -123,6 +124,18 @@ pub(crate) struct ServerMetadata { pub admin: Option, pub iroh_relay: Option, + + /// JSON with ICE servers for WebRTC calls + /// and the expiration timestamp. + /// + /// If JSON is about to expire, new TURN credentials + /// should be fetched from the server + /// to be ready for WebRTC calls. + pub ice_servers: String, + + /// Timestamp when ICE servers are considered + /// expired and should be updated. + pub ice_servers_expiration_timestamp: i64, } impl async_imap::Authenticator for OAuth2 { @@ -1535,7 +1548,43 @@ impl Session { } let mut lock = context.metadata.write().await; - if (*lock).is_some() { + if let Some(ref mut old_metadata) = *lock { + let now = time(); + + // Refresh TURN server credentials if they expire in 12 hours. + if now + 3600 * 12 < old_metadata.ice_servers_expiration_timestamp { + return Ok(()); + } + + info!(context, "ICE servers expired, requesting new credentials."); + let mailbox = ""; + let options = ""; + let metadata = self + .get_metadata(mailbox, options, "(/shared/vendor/deltachat/turn)") + .await?; + let mut got_turn_server = false; + for m in metadata { + if m.entry == "/shared/vendor/deltachat/turn" { + if let Some(value) = m.value { + match create_ice_servers_from_metadata(context, &value).await { + Ok((parsed_timestamp, parsed_ice_servers)) => { + old_metadata.ice_servers_expiration_timestamp = parsed_timestamp; + old_metadata.ice_servers = parsed_ice_servers; + got_turn_server = false; + } + Err(err) => { + warn!(context, "Failed to parse TURN server metadata: {err:#}."); + } + } + } + } + } + + if !got_turn_server { + // Set expiration timestamp 7 days in the future so we don't request it again. + old_metadata.ice_servers_expiration_timestamp = time() + 3600 * 24 * 7; + old_metadata.ice_servers = create_fallback_ice_servers(context).await?; + } return Ok(()); } @@ -1547,6 +1596,8 @@ impl Session { let mut comment = None; let mut admin = None; let mut iroh_relay = None; + let mut ice_servers = None; + let mut ice_servers_expiration_timestamp = 0; let mailbox = ""; let options = ""; @@ -1554,7 +1605,7 @@ impl Session { .get_metadata( mailbox, options, - "(/shared/comment /shared/admin /shared/vendor/deltachat/irohrelay)", + "(/shared/comment /shared/admin /shared/vendor/deltachat/irohrelay /shared/vendor/deltachat/turn)", ) .await?; for m in metadata { @@ -1577,13 +1628,36 @@ impl Session { } } } + "/shared/vendor/deltachat/turn" => { + if let Some(value) = m.value { + match create_ice_servers_from_metadata(context, &value).await { + Ok((parsed_timestamp, parsed_ice_servers)) => { + ice_servers_expiration_timestamp = parsed_timestamp; + ice_servers = Some(parsed_ice_servers); + } + Err(err) => { + warn!(context, "Failed to parse TURN server metadata: {err:#}."); + } + } + } + } _ => {} } } + let ice_servers = if let Some(ice_servers) = ice_servers { + ice_servers + } else { + // Set expiration timestamp 7 days in the future so we don't request it again. + ice_servers_expiration_timestamp = time() + 3600 * 24 * 7; + create_fallback_ice_servers(context).await? + }; + *lock = Some(ServerMetadata { comment, admin, iroh_relay, + ice_servers, + ice_servers_expiration_timestamp, }); Ok(()) }