feat(fax): add fax routing, job tracking, inbox management, and T.38/UDPTL media support

This commit is contained in:
2026-04-20 20:43:42 +00:00
parent 3c010a3b1b
commit d2c18a4ebb
27 changed files with 4247 additions and 280 deletions
+107 -26
View File
@@ -4,6 +4,8 @@
use md5::{Digest, Md5};
use rand::Rng;
use crate::{Endpoint, SdpMediaKind};
// ---- ID generators ---------------------------------------------------------
/// Generate a random SIP Call-ID (32 hex chars).
@@ -55,6 +57,9 @@ 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>,
@@ -67,6 +72,9 @@ impl<'a> Default for SdpOptions<'a> {
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,
@@ -83,7 +91,14 @@ pub fn build_sdp(opts: &SdpOptions) -> 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<String> = opts.payload_types.iter().map(|pt| pt.to_string()).collect();
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(),
@@ -91,16 +106,24 @@ pub fn build_sdp(opts: &SdpOptions) -> String {
format!("s={session_name}"),
format!("c=IN IP4 {}", opts.ip),
"t=0 0".to_string(),
format!("m=audio {} RTP/AVP {}", opts.port, pts.join(" ")),
format!(
"m={} {} {} {}",
opts.media_kind.as_sdp_token(),
opts.port,
opts.transport,
media_formats.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());
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());
}
}
}
@@ -199,38 +222,62 @@ pub fn compute_digest_auth(
// ---- SDP parser ------------------------------------------------------------
use crate::Endpoint;
/// Parse the audio media port, connection address, and preferred codec from an SDP body.
/// 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 port: Option<u16> = None;
let mut codec_pt: Option<u8> = 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=audio ") {
// m=audio <port> RTP/AVP <pt1> [<pt2> ...]
let parts: Vec<&str> = rest.split_whitespace().collect();
if !parts.is_empty() {
port = parts[0].parse().ok();
} 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;
}
// 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::<u8>().ok();
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, port) {
(Some(a), Some(p)) => Some(Endpoint {
match (addr, preferred.or(fallback)) {
(Some(a), Some((media_kind, port, codec_pt, transport))) => Some(Endpoint {
address: a.to_string(),
port: p,
port,
codec_pt,
media_kind,
transport,
}),
_ => None,
}
@@ -327,6 +374,40 @@ mod tests {
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]
+43
View File
@@ -16,4 +16,47 @@ pub struct Endpoint {
pub port: u16,
/// First payload type from the SDP `m=audio` line (the preferred codec).
pub codec_pt: Option<u8>,
/// SDP media kind from the `m=` line.
pub media_kind: SdpMediaKind,
/// SDP transport token from the `m=` line (e.g. `RTP/AVP`, `udptl`).
pub transport: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SdpMediaKind {
Audio,
Image,
Application,
Unknown,
}
impl SdpMediaKind {
pub fn as_sdp_token(&self) -> &'static str {
match self {
Self::Audio => "audio",
Self::Image => "image",
Self::Application => "application",
Self::Unknown => "unknown",
}
}
pub fn from_sdp_token(token: &str) -> Self {
match token.to_ascii_lowercase().as_str() {
"audio" => Self::Audio,
"image" => Self::Image,
"application" => Self::Application,
_ => Self::Unknown,
}
}
}
impl Endpoint {
pub fn is_audio_rtp(&self) -> bool {
self.media_kind == SdpMediaKind::Audio
&& self.transport.to_ascii_uppercase().starts_with("RTP/")
}
pub fn is_t38_udptl(&self) -> bool {
self.media_kind == SdpMediaKind::Image && self.transport.eq_ignore_ascii_case("udptl")
}
}
+40 -9
View File
@@ -2,7 +2,7 @@
//!
//! Ported from ts/sip/rewrite.ts.
use crate::Endpoint;
use crate::{Endpoint, SdpMediaKind};
/// Replaces the host:port in every `sip:` / `sips:` URI found in `value`.
pub fn rewrite_sip_uri(value: &str, host: &str, port: u16) -> String {
@@ -57,12 +57,12 @@ pub fn rewrite_sip_uri(value: &str, host: &str, port: u16) -> String {
result
}
/// Rewrites the connection address (`c=`) and audio media port (`m=audio`)
/// in an SDP body. Returns the rewritten body together with the original
/// endpoint that was replaced (if any).
/// Rewrites the connection address (`c=`) and first supported media port
/// (`m=audio`, `m=image`, `m=application`) in an SDP body. Returns the
/// rewritten body together with the original endpoint that was replaced (if any).
pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option<Endpoint>) {
let mut orig_addr: Option<String> = None;
let mut orig_port: Option<u16> = None;
let mut orig_media: Option<(SdpMediaKind, u16, String)> = None;
let lines: Vec<String> = body
.replace("\r\n", "\n")
@@ -71,10 +71,25 @@ pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option<Endpoint>
if let Some(rest) = line.strip_prefix("c=IN IP4 ") {
orig_addr = Some(rest.trim().to_string());
format!("c=IN IP4 {ip}")
} else if line.starts_with("m=audio ") {
} else if line.starts_with("m=audio ")
|| line.starts_with("m=image ")
|| line.starts_with("m=application ")
{
let parts: Vec<&str> = line.split(' ').collect();
if parts.len() >= 2 {
orig_port = parts[1].parse().ok();
let media_kind = parts[0]
.strip_prefix("m=")
.map(SdpMediaKind::from_sdp_token)
.unwrap_or(SdpMediaKind::Unknown);
if orig_media.is_none() {
orig_media = parts[1].parse().ok().map(|orig_port| {
(
media_kind,
orig_port,
parts.get(2).copied().unwrap_or("").to_string(),
)
});
}
let mut rebuilt = parts[0].to_string();
rebuilt.push(' ');
rebuilt.push_str(&port.to_string());
@@ -91,11 +106,13 @@ pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option<Endpoint>
})
.collect();
let original = match (orig_addr, orig_port) {
(Some(a), Some(p)) => Some(Endpoint {
let original = match (orig_addr, orig_media) {
(Some(a), Some((media_kind, p, transport))) => Some(Endpoint {
address: a,
port: p,
codec_pt: None,
media_kind,
transport,
}),
_ => None,
};
@@ -130,5 +147,19 @@ mod tests {
let ep = orig.unwrap();
assert_eq!(ep.address, "10.0.0.1");
assert_eq!(ep.port, 5060);
assert_eq!(ep.transport, "RTP/AVP");
}
#[test]
fn test_rewrite_t38_sdp() {
let sdp = "v=0\r\nc=IN IP4 10.0.0.1\r\nm=image 5060 udptl t38\r\na=T38FaxVersion:0\r\n";
let (rewritten, orig) = rewrite_sdp(sdp, "192.168.1.1", 4000);
assert!(rewritten.contains("c=IN IP4 192.168.1.1"));
assert!(rewritten.contains("m=image 4000 udptl t38"));
let ep = orig.unwrap();
assert_eq!(ep.address, "10.0.0.1");
assert_eq!(ep.port, 5060);
assert_eq!(ep.media_kind, SdpMediaKind::Image);
assert_eq!(ep.transport, "udptl");
}
}