//! SIP helper utilities — ID generation, codec registry, SDP builder, //! Digest authentication, SDP parser, and MWI body builder. use md5::{Digest, Md5}; use rand::Rng; // ---- ID generators --------------------------------------------------------- /// Generate a random SIP Call-ID (32 hex chars). pub fn generate_call_id(domain: Option<&str>) -> String { let id = random_hex(16); match domain { Some(d) => format!("{id}@{d}"), None => id, } } /// Generate a random SIP From/To tag (16 hex chars). pub fn generate_tag() -> String { random_hex(8) } /// Generate an RFC 3261 compliant Via branch (starts with `z9hG4bK` magic cookie). pub fn generate_branch() -> String { format!("z9hG4bK-{}", random_hex(8)) } fn random_hex(bytes: usize) -> String { let mut rng = rand::thread_rng(); (0..bytes) .map(|_| format!("{:02x}", rng.gen::())) .collect() } // ---- Codec registry -------------------------------------------------------- /// Look up the rtpmap name for a static payload type. pub fn codec_name(pt: u8) -> &'static str { match pt { 0 => "PCMU/8000", 3 => "GSM/8000", 4 => "G723/8000", 8 => "PCMA/8000", 9 => "G722/8000", 18 => "G729/8000", 101 => "telephone-event/8000", _ => "unknown", } } // ---- SDP builder ----------------------------------------------------------- /// Options for building an SDP body. pub struct SdpOptions<'a> { pub ip: &'a str, pub port: u16, pub payload_types: &'a [u8], pub session_id: Option<&'a str>, pub session_name: Option<&'a str>, pub direction: Option<&'a str>, pub attributes: &'a [&'a str], } impl<'a> Default for SdpOptions<'a> { fn default() -> Self { Self { ip: "0.0.0.0", port: 0, payload_types: &[9, 0, 8, 101], session_id: None, session_name: None, direction: None, attributes: &[], } } } /// Build a minimal SDP body suitable for SIP INVITE offers/answers. pub fn build_sdp(opts: &SdpOptions) -> String { let session_id = opts .session_id .map(|s| s.to_string()) .unwrap_or_else(|| format!("{}", rand::thread_rng().gen_range(0..1_000_000_000u64))); let session_name = opts.session_name.unwrap_or("-"); let direction = opts.direction.unwrap_or("sendrecv"); let pts: Vec = opts.payload_types.iter().map(|pt| pt.to_string()).collect(); let mut lines = vec![ "v=0".to_string(), format!("o=- {session_id} {session_id} IN IP4 {}", opts.ip), format!("s={session_name}"), format!("c=IN IP4 {}", opts.ip), "t=0 0".to_string(), format!("m=audio {} RTP/AVP {}", opts.port, pts.join(" ")), ]; for &pt in opts.payload_types { let name = codec_name(pt); if name != "unknown" { lines.push(format!("a=rtpmap:{pt} {name}")); } if pt == 101 { lines.push("a=fmtp:101 0-16".to_string()); } } lines.push(format!("a={direction}")); for attr in opts.attributes { lines.push(format!("a={attr}")); } lines.push(String::new()); // trailing CRLF lines.join("\r\n") } // ---- SIP Digest authentication (RFC 2617) ---------------------------------- /// Parsed fields from a Proxy-Authenticate or WWW-Authenticate header. #[derive(Debug, Clone)] pub struct DigestChallenge { pub realm: String, pub nonce: String, pub algorithm: Option, pub opaque: Option, pub qop: Option, } /// Parse a `Proxy-Authenticate` or `WWW-Authenticate` header value. pub fn parse_digest_challenge(header: &str) -> Option { let lower = header.to_ascii_lowercase(); if !lower.starts_with("digest ") { return None; } let params = &header[7..]; let get = |key: &str| -> Option { // Try quoted value first. let pat = format!("{}=", key); if let Some(pos) = params.to_ascii_lowercase().find(&pat) { let after = ¶ms[pos + pat.len()..]; let after = after.trim_start(); if after.starts_with('"') { let end = after[1..].find('"')?; return Some(after[1..1 + end].to_string()); } // Unquoted value. let end = after .find(|c: char| c == ',' || c.is_whitespace()) .unwrap_or(after.len()); return Some(after[..end].to_string()); } None }; let realm = get("realm")?; let nonce = get("nonce")?; Some(DigestChallenge { realm, nonce, algorithm: get("algorithm"), opaque: get("opaque"), qop: get("qop"), }) } fn md5_hex(s: &str) -> String { let mut hasher = Md5::new(); hasher.update(s.as_bytes()); format!("{:x}", hasher.finalize()) } /// Compute a SIP Digest Authorization header value. pub fn compute_digest_auth( username: &str, password: &str, realm: &str, nonce: &str, method: &str, uri: &str, algorithm: Option<&str>, opaque: Option<&str>, ) -> String { let ha1 = md5_hex(&format!("{username}:{realm}:{password}")); let ha2 = md5_hex(&format!("{method}:{uri}")); let response = md5_hex(&format!("{ha1}:{nonce}:{ha2}")); let alg = algorithm.unwrap_or("MD5"); let mut header = format!( "Digest username=\"{username}\", realm=\"{realm}\", \ nonce=\"{nonce}\", uri=\"{uri}\", response=\"{response}\", \ algorithm={alg}" ); if let Some(op) = opaque { header.push_str(&format!(", opaque=\"{op}\"")); } header } // ---- SDP parser ------------------------------------------------------------ use crate::Endpoint; /// Parse the audio media port, connection address, and preferred codec from an SDP body. pub fn parse_sdp_endpoint(sdp: &str) -> Option { let mut addr: Option<&str> = None; let mut port: Option = None; let mut codec_pt: Option = None; let normalized = sdp.replace("\r\n", "\n"); for raw in normalized.split('\n') { let line = raw.trim(); if let Some(rest) = line.strip_prefix("c=IN IP4 ") { addr = Some(rest.trim()); } else if let Some(rest) = line.strip_prefix("m=audio ") { // m=audio RTP/AVP [ ...] let parts: Vec<&str> = rest.split_whitespace().collect(); if !parts.is_empty() { port = parts[0].parse().ok(); } // parts[1] is "RTP/AVP" or similar, parts[2..] are payload types. // The first PT is the preferred codec. if parts.len() > 2 { codec_pt = parts[2].parse::().ok(); } } } match (addr, port) { (Some(a), Some(p)) => Some(Endpoint { address: a.to_string(), port: p, codec_pt, }), _ => None, } } // ---- MWI (RFC 3842) -------------------------------------------------------- /// Build the body and extra headers for an MWI NOTIFY (RFC 3842 message-summary). pub struct MwiResult { pub body: String, pub content_type: &'static str, pub extra_headers: Vec<(String, String)>, } pub fn build_mwi_body(new_messages: u32, old_messages: u32, account_uri: &str) -> MwiResult { let waiting = if new_messages > 0 { "yes" } else { "no" }; let body = format!( "Messages-Waiting: {waiting}\r\n\ Message-Account: {account_uri}\r\n\ Voice-Message: {new_messages}/{old_messages}\r\n" ); MwiResult { body, content_type: "application/simple-message-summary", extra_headers: vec![ ("Event".to_string(), "message-summary".to_string()), ( "Subscription-State".to_string(), "terminated;reason=noresource".to_string(), ), ], } } #[cfg(test)] mod tests { use super::*; #[test] fn test_generate_branch_has_magic_cookie() { let branch = generate_branch(); assert!(branch.starts_with("z9hG4bK-")); assert!(branch.len() > 8); } #[test] fn test_codec_name() { assert_eq!(codec_name(0), "PCMU/8000"); assert_eq!(codec_name(9), "G722/8000"); assert_eq!(codec_name(101), "telephone-event/8000"); assert_eq!(codec_name(255), "unknown"); } #[test] fn test_build_sdp() { let sdp = build_sdp(&SdpOptions { ip: "192.168.1.1", port: 20000, payload_types: &[9, 0, 101], ..Default::default() }); assert!(sdp.contains("m=audio 20000 RTP/AVP 9 0 101")); assert!(sdp.contains("c=IN IP4 192.168.1.1")); assert!(sdp.contains("a=rtpmap:9 G722/8000")); assert!(sdp.contains("a=fmtp:101 0-16")); assert!(sdp.contains("a=sendrecv")); } #[test] fn test_parse_digest_challenge() { let header = r#"Digest realm="asterisk", nonce="abc123", algorithm=MD5, opaque="xyz""#; let ch = parse_digest_challenge(header).unwrap(); assert_eq!(ch.realm, "asterisk"); assert_eq!(ch.nonce, "abc123"); assert_eq!(ch.algorithm.as_deref(), Some("MD5")); assert_eq!(ch.opaque.as_deref(), Some("xyz")); } #[test] fn test_compute_digest_auth() { let auth = compute_digest_auth( "user", "pass", "realm", "nonce", "REGISTER", "sip:host", None, None, ); assert!(auth.starts_with("Digest ")); assert!(auth.contains("username=\"user\"")); assert!(auth.contains("realm=\"realm\"")); assert!(auth.contains("response=\"")); } #[test] fn test_parse_sdp_endpoint() { let sdp = "v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5060 RTP/AVP 0\r\n"; let ep = parse_sdp_endpoint(sdp).unwrap(); assert_eq!(ep.address, "10.0.0.1"); assert_eq!(ep.port, 5060); } #[test] fn test_build_mwi_body() { let mwi = build_mwi_body(3, 5, "sip:user@host"); assert!(mwi.body.contains("Messages-Waiting: yes")); assert!(mwi.body.contains("Voice-Message: 3/5")); assert_eq!(mwi.content_type, "application/simple-message-summary"); } }