421 lines
13 KiB
Rust
421 lines
13 KiB
Rust
//! SIP helper utilities — ID generation, codec registry, SDP builder,
|
|
//! Digest authentication, SDP parser, and MWI body builder.
|
|
|
|
use md5::{Digest, Md5};
|
|
use rand::Rng;
|
|
|
|
use crate::{Endpoint, SdpMediaKind};
|
|
|
|
// ---- 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::<u8>()))
|
|
.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 media_kind: SdpMediaKind,
|
|
pub transport: &'a str,
|
|
pub media_formats: &'a [&'a str],
|
|
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],
|
|
media_kind: SdpMediaKind::Audio,
|
|
transport: "RTP/AVP",
|
|
media_formats: &[],
|
|
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 media_formats: Vec<String> = if !opts.media_formats.is_empty() {
|
|
opts.media_formats
|
|
.iter()
|
|
.map(|fmt| fmt.to_string())
|
|
.collect()
|
|
} else {
|
|
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={} {} {} {}",
|
|
opts.media_kind.as_sdp_token(),
|
|
opts.port,
|
|
opts.transport,
|
|
media_formats.join(" ")
|
|
),
|
|
];
|
|
|
|
if opts.media_kind == SdpMediaKind::Audio {
|
|
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<String>,
|
|
pub opaque: Option<String>,
|
|
pub qop: Option<String>,
|
|
}
|
|
|
|
/// Parse a `Proxy-Authenticate` or `WWW-Authenticate` header value.
|
|
pub fn parse_digest_challenge(header: &str) -> Option<DigestChallenge> {
|
|
let lower = header.to_ascii_lowercase();
|
|
if !lower.starts_with("digest ") {
|
|
return None;
|
|
}
|
|
let params = &header[7..];
|
|
|
|
let get = |key: &str| -> Option<String> {
|
|
// 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 ------------------------------------------------------------
|
|
|
|
/// Parse the preferred media endpoint from an SDP body.
|
|
///
|
|
/// Audio `m=` lines are preferred when present so existing RTP call flows keep
|
|
/// their current behavior. If no audio section exists, the first media section
|
|
/// is returned, which allows T.38-only SDP offers/answers to be represented.
|
|
pub fn parse_sdp_endpoint(sdp: &str) -> Option<Endpoint> {
|
|
let mut addr: Option<&str> = None;
|
|
let mut preferred: Option<(SdpMediaKind, u16, Option<u8>, String)> = None;
|
|
let mut fallback: Option<(SdpMediaKind, u16, Option<u8>, String)> = 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=") {
|
|
// m=<media> <port> <transport> <fmt1> [<fmt2> ...]
|
|
let mut media_and_rest = rest.splitn(2, ' ');
|
|
let media = media_and_rest.next().unwrap_or("");
|
|
let remainder = media_and_rest.next().unwrap_or("");
|
|
let media_kind = SdpMediaKind::from_sdp_token(media);
|
|
if media_kind == SdpMediaKind::Unknown {
|
|
continue;
|
|
}
|
|
|
|
let parts: Vec<&str> = remainder.split_whitespace().collect();
|
|
if !parts.is_empty() {
|
|
if let Ok(port) = parts[0].parse() {
|
|
let transport = parts.get(1).copied().unwrap_or("").to_string();
|
|
let codec_pt = if media_kind == SdpMediaKind::Audio && parts.len() > 2 {
|
|
parts[2].parse::<u8>().ok()
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let candidate = (media_kind, port, codec_pt, transport);
|
|
if fallback.is_none() {
|
|
fallback = Some(candidate.clone());
|
|
}
|
|
if media_kind == SdpMediaKind::Audio {
|
|
preferred = Some(candidate);
|
|
} else if preferred.is_none() {
|
|
preferred = Some(candidate);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
match (addr, preferred.or(fallback)) {
|
|
(Some(a), Some((media_kind, port, codec_pt, transport))) => Some(Endpoint {
|
|
address: a.to_string(),
|
|
port,
|
|
codec_pt,
|
|
media_kind,
|
|
transport,
|
|
}),
|
|
_ => 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);
|
|
assert_eq!(ep.media_kind, SdpMediaKind::Audio);
|
|
assert_eq!(ep.transport, "RTP/AVP");
|
|
assert!(ep.is_audio_rtp());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_t38_sdp_endpoint() {
|
|
let sdp = concat!(
|
|
"v=0\r\n",
|
|
"c=IN IP4 203.0.113.9\r\n",
|
|
"m=image 4000 udptl t38\r\n",
|
|
"a=T38FaxVersion:0\r\n",
|
|
);
|
|
let ep = parse_sdp_endpoint(sdp).unwrap();
|
|
assert_eq!(ep.address, "203.0.113.9");
|
|
assert_eq!(ep.port, 4000);
|
|
assert_eq!(ep.media_kind, SdpMediaKind::Image);
|
|
assert_eq!(ep.transport, "udptl");
|
|
assert!(ep.is_t38_udptl());
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_t38_sdp() {
|
|
let sdp = build_sdp(&SdpOptions {
|
|
ip: "192.168.1.1",
|
|
port: 4000,
|
|
media_kind: SdpMediaKind::Image,
|
|
transport: "udptl",
|
|
media_formats: &["t38"],
|
|
attributes: &["T38FaxVersion:0"],
|
|
..Default::default()
|
|
});
|
|
assert!(sdp.contains("m=image 4000 udptl t38"));
|
|
assert!(sdp.contains("a=T38FaxVersion:0"));
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
}
|