#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SdpOffer {
pub mid: String,
pub payload_type: u8,
pub audio_payload_type: Option<u8>,
pub send: bool,
pub rid_ext_id: Option<u8>,
pub simulcast_rids: Vec<String>,
pub data_mid: Option<String>,
}
impl SdpOffer {
pub fn has_data_channel(&self) -> bool {
self.data_mid.is_some()
}
pub fn is_simulcast(&self) -> bool {
self.simulcast_rids.len() > 1
}
pub fn parse(sdp: &str) -> Option<SdpOffer> {
#[derive(PartialEq)]
enum Section {
None,
Video,
Audio,
Application,
}
let mut section = Section::None;
let mut payload_type = None;
let mut mid = "0".to_string();
let mut send = false;
let mut h264_pt = None;
let mut opus_pt = None;
let mut audio_default_pt = None;
let mut rid_ext_id = None;
let mut rid_send: Vec<String> = Vec::new();
let mut simulcast: Vec<String> = Vec::new();
let mut data_mid = None;
let mut app_mid = "data".to_string();
for line in sdp.lines() {
let line = line.trim_end();
if let Some(rest) = line.strip_prefix("m=") {
if rest.starts_with("video") {
section = Section::Video;
payload_type = rest.split(' ').nth(3).and_then(|p| p.parse().ok());
} else if rest.starts_with("audio") {
section = Section::Audio;
audio_default_pt = rest.split(' ').nth(3).and_then(|p| p.parse().ok());
} else if rest.starts_with("application") {
section = Section::Application;
data_mid = Some(app_mid.clone());
} else {
section = Section::None;
}
} else if section == Section::Video {
if let Some(rtpmap) = line.strip_prefix("a=rtpmap:") {
if rtpmap.contains("H264") && h264_pt.is_none() {
h264_pt = rtpmap.split(' ').next().and_then(|p| p.parse().ok());
}
} else if let Some(m) = line.strip_prefix("a=mid:") {
mid = m.to_string();
} else if let Some(ext) = line.strip_prefix("a=extmap:") {
if ext.contains("sdes:rid") && !ext.contains("repaired") {
rid_ext_id = ext.split(['/', ' ']).next().and_then(|id| id.parse().ok());
}
} else if let Some(rid) = line.strip_prefix("a=rid:") {
let mut it = rid.split_whitespace();
if let (Some(id), Some(dir)) = (it.next(), it.next()) {
if dir == "send" {
rid_send.push(id.to_string());
}
}
} else if let Some(sc) = line.strip_prefix("a=simulcast:") {
if let Some(send_list) = sc.strip_prefix("send ") {
simulcast = send_list
.split(';')
.filter_map(|alt| alt.split(',').next())
.map(|r| r.trim_start_matches('~').to_string())
.filter(|r| !r.is_empty())
.collect();
}
} else if line == "a=sendonly" || line == "a=sendrecv" {
send = true;
}
} else if section == Section::Audio {
if let Some(rtpmap) = line.strip_prefix("a=rtpmap:") {
if rtpmap.to_ascii_lowercase().contains("opus") && opus_pt.is_none() {
opus_pt = rtpmap.split(' ').next().and_then(|p| p.parse().ok());
}
} else if line == "a=sendonly" || line == "a=sendrecv" {
send = true;
}
} else if section == Section::Application {
if let Some(m) = line.strip_prefix("a=mid:") {
app_mid = m.to_string();
data_mid = Some(app_mid.clone());
}
}
}
let payload_type = h264_pt.or(payload_type)?;
let simulcast_rids = if !simulcast.is_empty() {
simulcast
} else {
rid_send
};
Some(SdpOffer {
mid,
payload_type,
audio_payload_type: opus_pt.or(audio_default_pt),
send,
rid_ext_id,
simulcast_rids,
data_mid,
})
}
}
#[derive(Debug, Clone)]
pub struct SdpAnswerParams {
pub fingerprint: String,
pub ice_ufrag: String,
pub ice_pwd: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MediaDirection {
RecvOnly,
SendOnly,
}
impl MediaDirection {
fn attr(self) -> &'static str {
match self {
MediaDirection::RecvOnly => "recvonly",
MediaDirection::SendOnly => "sendonly",
}
}
}
pub fn build_answer(offer: &SdpOffer, params: &SdpAnswerParams) -> String {
build_answer_directed(offer, params, MediaDirection::RecvOnly)
}
pub fn build_answer_directed(
offer: &SdpOffer,
params: &SdpAnswerParams,
direction: MediaDirection,
) -> String {
let pt = offer.payload_type;
let bundle = match &offer.data_mid {
Some(d) => format!("{} {}", offer.mid, d),
None => offer.mid.clone(),
};
let mut sdp = format!(
"v=0\r\n\
o=- 0 0 IN IP4 0.0.0.0\r\n\
s=-\r\n\
t=0 0\r\n\
a=group:BUNDLE {bundle}\r\n\
m=video 9 UDP/TLS/RTP/SAVPF {pt}\r\n\
c=IN IP4 0.0.0.0\r\n\
a=rtcp-mux\r\n\
a=mid:{mid}\r\n\
a={dir}\r\n\
a=ice-ufrag:{ufrag}\r\n\
a=ice-pwd:{pwd}\r\n\
a=fingerprint:{fp}\r\n\
a=setup:passive\r\n\
a=rtpmap:{pt} H264/90000\r\n\
a=rtcp-fb:{pt} nack pli\r\n\
a=rtcp-fb:{pt} ccm fir\r\n",
bundle = bundle,
mid = offer.mid,
pt = pt,
dir = direction.attr(),
ufrag = params.ice_ufrag,
pwd = params.ice_pwd,
fp = params.fingerprint,
);
if direction == MediaDirection::RecvOnly && offer.is_simulcast() {
if let Some(id) = offer.rid_ext_id {
sdp.push_str(&format!(
"a=extmap:{id} urn:ietf:params:rtp-hdrext:sdes:rid\r\n"
));
}
for rid in &offer.simulcast_rids {
sdp.push_str(&format!("a=rid:{rid} recv\r\n"));
}
sdp.push_str(&format!(
"a=simulcast:recv {}\r\n",
offer.simulcast_rids.join(";")
));
}
if let Some(data_mid) = &offer.data_mid {
sdp.push_str(&format!(
"m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n\
c=IN IP4 0.0.0.0\r\n\
a=mid:{data_mid}\r\n\
a=ice-ufrag:{ufrag}\r\n\
a=ice-pwd:{pwd}\r\n\
a=fingerprint:{fp}\r\n\
a=setup:passive\r\n\
a=sctp-port:5000\r\n",
data_mid = data_mid,
ufrag = params.ice_ufrag,
pwd = params.ice_pwd,
fp = params.fingerprint,
));
}
sdp
}
#[cfg(test)]
mod tests {
use super::*;
const OFFER: &str = "v=0\r\n\
o=- 0 0 IN IP4 0.0.0.0\r\n\
m=video 9 UDP/TLS/RTP/SAVPF 96 97\r\n\
a=mid:vid\r\n\
a=sendonly\r\n\
a=rtpmap:96 H264/90000\r\n";
#[test]
fn parses_video_offer() {
let o = SdpOffer::parse(OFFER).unwrap();
assert_eq!(o.payload_type, 96);
assert_eq!(o.mid, "vid");
assert!(o.send);
}
#[test]
fn audio_only_offer_is_rejected() {
assert!(SdpOffer::parse("m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n").is_none());
}
#[test]
fn parses_opus_audio_payload_alongside_video() {
let offer = "v=0\r\n\
m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\
a=mid:vid\r\n\
a=sendonly\r\n\
a=rtpmap:96 H264/90000\r\n\
m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n\
a=mid:aud\r\n\
a=sendonly\r\n\
a=rtpmap:111 opus/48000/2\r\n";
let o = SdpOffer::parse(offer).unwrap();
assert_eq!(o.payload_type, 96);
assert_eq!(o.audio_payload_type, Some(111));
assert_eq!(SdpOffer::parse(OFFER).unwrap().audio_payload_type, None);
}
#[test]
fn answer_echoes_mid_and_payload_and_injects_crypto() {
let o = SdpOffer::parse(OFFER).unwrap();
let a = build_answer(
&o,
&SdpAnswerParams {
fingerprint: "sha-256 11:22".into(),
ice_ufrag: "uf".into(),
ice_pwd: "pw".into(),
},
);
assert!(a.contains("a=mid:vid"));
assert!(a.contains("m=video 9 UDP/TLS/RTP/SAVPF 96"));
assert!(a.contains("a=recvonly"));
assert!(a.contains("a=ice-ufrag:uf"));
assert!(a.contains("a=fingerprint:sha-256 11:22"));
assert!(a.contains("a=rtcp-fb:96 nack pli"));
}
const SIMULCAST_OFFER: &str = "v=0\r\n\
o=- 0 0 IN IP4 0.0.0.0\r\n\
m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\
a=mid:0\r\n\
a=sendonly\r\n\
a=rtpmap:96 H264/90000\r\n\
a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:rid\r\n\
a=extmap:5 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\n\
a=rid:q send\r\n\
a=rid:h send\r\n\
a=rid:f send\r\n\
a=simulcast:send q;h;f\r\n";
#[test]
fn parses_simulcast_layers_and_rid_extension() {
let o = SdpOffer::parse(SIMULCAST_OFFER).unwrap();
assert!(o.is_simulcast());
assert_eq!(o.rid_ext_id, Some(4)); assert_eq!(o.simulcast_rids, vec!["q", "h", "f"]);
}
#[test]
fn answer_echoes_simulcast_as_recv() {
let o = SdpOffer::parse(SIMULCAST_OFFER).unwrap();
let a = build_answer(
&o,
&SdpAnswerParams {
fingerprint: "sha-256 11:22".into(),
ice_ufrag: "uf".into(),
ice_pwd: "pw".into(),
},
);
assert!(a.contains("a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:rid"));
assert!(a.contains("a=rid:q recv"));
assert!(a.contains("a=rid:f recv"));
assert!(a.contains("a=simulcast:recv q;h;f"));
}
#[test]
fn parses_and_answers_data_channel() {
let offer = "v=0\r\n\
o=- 0 0 IN IP4 0.0.0.0\r\n\
m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\
a=mid:0\r\n\
a=sendonly\r\n\
a=rtpmap:96 H264/90000\r\n\
m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n\
a=mid:dc\r\n\
a=sctp-port:5000\r\n";
let o = SdpOffer::parse(offer).unwrap();
assert!(o.has_data_channel());
assert_eq!(o.data_mid.as_deref(), Some("dc"));
let a = build_answer(
&o,
&SdpAnswerParams {
fingerprint: "sha-256 11:22".into(),
ice_ufrag: "uf".into(),
ice_pwd: "pw".into(),
},
);
assert!(a.contains("a=group:BUNDLE 0 dc"));
assert!(a.contains("m=application 9 UDP/DTLS/SCTP webrtc-datachannel"));
assert!(a.contains("a=mid:dc"));
assert!(a.contains("a=sctp-port:5000"));
}
}