use std::fmt::Write as FmtWrite;
fn derive_ice_ufrag(seed: &str) -> String {
let mut h: u32 = 0x811c_9dc5;
for b in seed.as_bytes() {
h ^= *b as u32;
h = h.wrapping_mul(0x0100_0193);
}
let chars: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
let len = chars.len() as u32;
(0..4)
.map(|i| chars[((h >> (i * 8)) % len) as usize] as char)
.collect()
}
fn derive_ice_pwd(seed: &str) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325_u64;
for b in seed.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3_u64);
}
let chars: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let len = chars.len() as u64;
(0u64..24)
.map(|i| {
let mixed = h
.wrapping_add(i.wrapping_mul(6_364_136_223_846_793_005_u64))
.wrapping_mul((i + 1).wrapping_mul(2_862_933_555_777_941_757_u64));
chars[((mixed >> 33) % len) as usize] as char
})
.collect()
}
fn next_session_id() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(1);
COUNTER.fetch_add(1, Ordering::Relaxed)
}
#[derive(Debug, Clone)]
pub struct WhepConfig {
pub endpoint: String,
pub bearer_token: Option<String>,
}
impl WhepConfig {
#[must_use]
pub fn new(endpoint: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
bearer_token: None,
}
}
#[must_use]
pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
self.bearer_token = Some(token.into());
self
}
#[must_use]
pub fn authorization_header(&self) -> Option<String> {
self.bearer_token.as_deref().map(|t| format!("Bearer {t}"))
}
}
#[derive(Debug, Clone)]
pub struct WhepSession {
pub id: String,
pub sdp_offer: String,
pub sdp_answer: Option<String>,
}
impl WhepSession {
#[must_use]
pub fn is_answered(&self) -> bool {
self.sdp_answer.is_some()
}
}
pub struct WhepClient {
config: WhepConfig,
}
impl WhepClient {
#[must_use]
pub fn new(config: WhepConfig) -> Self {
Self { config }
}
#[must_use]
pub fn config(&self) -> &WhepConfig {
&self.config
}
#[must_use]
pub fn create_offer(&self) -> WhepSession {
let seq = next_session_id();
let id = format!("whep-{seq}");
let ufrag = derive_ice_ufrag(&id);
let pwd = derive_ice_pwd(&id);
let mut sdp = String::with_capacity(512);
sdp.push_str("v=0\r\n");
let _ = writeln!(sdp, "o=- {seq} 0 IN IP4 0.0.0.0\r");
sdp.push_str("s=WHEP Egress\r\n");
sdp.push_str("t=0 0\r\n");
let _ = writeln!(sdp, "a=ice-ufrag:{ufrag}\r");
let _ = writeln!(sdp, "a=ice-pwd:{pwd}\r");
sdp.push_str(
"a=fingerprint:sha-256 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:\
00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00\r\n",
);
sdp.push_str("a=setup:actpass\r\n");
sdp.push_str("m=video 9 UDP/TLS/RTP/SAVPF 96\r\n");
sdp.push_str("c=IN IP4 0.0.0.0\r\n");
sdp.push_str("a=rtpmap:96 H264/90000\r\n");
sdp.push_str("a=recvonly\r\n");
sdp.push_str("a=mid:video\r\n");
sdp.push_str("m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n");
sdp.push_str("c=IN IP4 0.0.0.0\r\n");
sdp.push_str("a=rtpmap:111 opus/48000/2\r\n");
sdp.push_str("a=recvonly\r\n");
sdp.push_str("a=mid:audio\r\n");
WhepSession {
id,
sdp_offer: sdp,
sdp_answer: None,
}
}
pub fn process_answer(&self, session: &mut WhepSession, sdp_answer: String) {
session.sdp_answer = Some(sdp_answer);
}
#[must_use]
pub fn format_whep_request(session: &WhepSession) -> String {
let mut out = String::with_capacity(session.sdp_offer.len() + 256);
out.push_str("Content-Type: application/sdp\r\n");
out.push_str("Accept: application/sdp\r\n");
out.push_str("\r\n");
out.push_str(&session.sdp_offer);
out
}
#[must_use]
pub fn format_whep_request_authenticated(&self, session: &WhepSession) -> String {
let mut out = String::with_capacity(session.sdp_offer.len() + 256);
if let Some(auth) = self.config.authorization_header() {
let _ = writeln!(out, "Authorization: {auth}\r");
}
out.push_str("Content-Type: application/sdp\r\n");
out.push_str("Accept: application/sdp\r\n");
out.push_str("\r\n");
out.push_str(&session.sdp_offer);
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_client() -> WhepClient {
WhepClient::new(WhepConfig::new("https://example.com/whep"))
}
#[test]
fn test_whep_config_no_token() {
let cfg = WhepConfig::new("https://example.com/whep");
assert!(cfg.bearer_token.is_none());
assert!(cfg.authorization_header().is_none());
}
#[test]
fn test_whep_config_bearer_token() {
let cfg = WhepConfig::new("https://example.com/whep").with_bearer_token("tok123");
assert_eq!(cfg.bearer_token.as_deref(), Some("tok123"));
assert_eq!(cfg.authorization_header().as_deref(), Some("Bearer tok123"));
}
#[test]
fn test_whep_client_new() {
let client = make_client();
assert_eq!(client.config().endpoint, "https://example.com/whep");
}
#[test]
fn test_create_offer_session_id() {
let client = make_client();
let session = client.create_offer();
assert!(!session.id.is_empty());
assert!(session.id.starts_with("whep-"));
}
#[test]
fn test_create_offer_sdp_starts_v0() {
let client = make_client();
let session = client.create_offer();
assert!(session.sdp_offer.starts_with("v=0"), "SDP must begin v=0");
}
#[test]
fn test_create_offer_recvonly() {
let client = make_client();
let session = client.create_offer();
assert!(
session.sdp_offer.contains("a=recvonly"),
"WHEP subscriber must use recvonly"
);
}
#[test]
fn test_create_offer_video_mline() {
let client = make_client();
let session = client.create_offer();
assert!(session.sdp_offer.contains("m=video"), "must have video m=");
}
#[test]
fn test_create_offer_audio_mline() {
let client = make_client();
let session = client.create_offer();
assert!(session.sdp_offer.contains("m=audio"), "must have audio m=");
}
#[test]
fn test_no_answer_initially() {
let client = make_client();
let session = client.create_offer();
assert!(!session.is_answered());
assert!(session.sdp_answer.is_none());
}
#[test]
fn test_process_answer_stores() {
let client = make_client();
let mut session = client.create_offer();
client.process_answer(&mut session, "v=0\r\no=- 0 0 IN IP4 0.0.0.0\r\n".to_owned());
assert!(session.is_answered());
assert!(session.sdp_answer.is_some());
}
#[test]
fn test_process_answer_content() {
let client = make_client();
let mut session = client.create_offer();
let answer_sdp = "v=0\r\no=- 12345 0 IN IP4 192.168.1.1\r\n".to_owned();
client.process_answer(&mut session, answer_sdp.clone());
assert_eq!(session.sdp_answer.as_deref(), Some(answer_sdp.as_str()));
}
#[test]
fn test_format_whep_request_content_type() {
let client = make_client();
let session = client.create_offer();
let req = WhepClient::format_whep_request(&session);
assert!(req.contains("Content-Type: application/sdp"));
}
#[test]
fn test_format_whep_request_accept() {
let client = make_client();
let session = client.create_offer();
let req = WhepClient::format_whep_request(&session);
assert!(req.contains("Accept: application/sdp"));
}
#[test]
fn test_format_whep_request_body() {
let client = make_client();
let session = client.create_offer();
let req = WhepClient::format_whep_request(&session);
assert!(req.contains("v=0"));
assert!(req.contains("m=video"));
}
#[test]
fn test_format_whep_request_authenticated_with_token() {
let cfg = WhepConfig::new("https://example.com/whep").with_bearer_token("my-token");
let client = WhepClient::new(cfg);
let session = client.create_offer();
let req = client.format_whep_request_authenticated(&session);
assert!(req.contains("Authorization: Bearer my-token"));
}
#[test]
fn test_format_whep_request_no_auth_when_no_token() {
let client = make_client();
let session = client.create_offer();
let req = client.format_whep_request_authenticated(&session);
assert!(!req.contains("Authorization:"));
}
#[test]
fn test_create_offer_unique_ids() {
let client = make_client();
let s1 = client.create_offer();
let s2 = client.create_offer();
assert_ne!(s1.id, s2.id);
}
#[test]
fn test_create_offer_ice_ufrag_present() {
let client = make_client();
let session = client.create_offer();
assert!(session.sdp_offer.contains("a=ice-ufrag:"));
}
#[test]
fn test_create_offer_ice_pwd_present() {
let client = make_client();
let session = client.create_offer();
assert!(session.sdp_offer.contains("a=ice-pwd:"));
}
#[test]
fn test_is_answered_transitions() {
let client = make_client();
let mut session = client.create_offer();
assert!(!session.is_answered());
client.process_answer(&mut session, "v=0\r\n".to_owned());
assert!(session.is_answered());
}
}