feat(rust-proxy-engine): add a Rust SIP proxy engine with shared SIP and codec libraries

This commit is contained in:
2026-04-10 09:57:27 +00:00
parent f3b18a7170
commit 3132ba8cbb
28 changed files with 5042 additions and 548 deletions

View File

@@ -0,0 +1,563 @@
//! SIP message parsing, serialization, inspection, mutation, and factory methods.
//!
//! Ported from ts/sip/message.ts.
use crate::helpers::{generate_branch, generate_call_id, generate_tag};
/// A parsed SIP message (request or response).
#[derive(Debug, Clone)]
pub struct SipMessage {
pub start_line: String,
pub headers: Vec<(String, String)>,
pub body: String,
}
impl SipMessage {
pub fn new(start_line: String, headers: Vec<(String, String)>, body: String) -> Self {
Self { start_line, headers, body }
}
// ---- Parsing -----------------------------------------------------------
/// Parse a raw buffer into a SipMessage. Returns None for invalid data.
pub fn parse(buf: &[u8]) -> Option<Self> {
if buf.is_empty() {
return None;
}
// First byte must be ASCII A-z.
if buf[0] < 0x41 || buf[0] > 0x7a {
return None;
}
let text = std::str::from_utf8(buf).ok()?;
let (head, body) = if let Some(sep) = text.find("\r\n\r\n") {
(&text[..sep], &text[sep + 4..])
} else if let Some(sep) = text.find("\n\n") {
(&text[..sep], &text[sep + 2..])
} else {
(text, "")
};
let normalized = head.replace("\r\n", "\n");
let lines: Vec<&str> = normalized.split('\n').collect();
if lines.is_empty() || lines[0].is_empty() {
return None;
}
let start_line = lines[0];
// Validate: must be a SIP request or response start line.
if !is_sip_first_line(start_line) {
return None;
}
let mut headers = Vec::new();
for &line in &lines[1..] {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Some(colon) = line.find(':') {
let name = line[..colon].trim().to_string();
let value = line[colon + 1..].trim().to_string();
headers.push((name, value));
}
}
Some(SipMessage {
start_line: start_line.to_string(),
headers,
body: body.to_string(),
})
}
// ---- Serialization -----------------------------------------------------
/// Serialize the message to a byte buffer suitable for UDP transmission.
pub fn serialize(&self) -> Vec<u8> {
let mut head = self.start_line.clone();
for (name, value) in &self.headers {
head.push_str("\r\n");
head.push_str(name);
head.push_str(": ");
head.push_str(value);
}
head.push_str("\r\n\r\n");
let mut buf = head.into_bytes();
if !self.body.is_empty() {
buf.extend_from_slice(self.body.as_bytes());
}
buf
}
// ---- Inspectors --------------------------------------------------------
pub fn is_request(&self) -> bool {
!self.start_line.starts_with("SIP/")
}
pub fn is_response(&self) -> bool {
self.start_line.starts_with("SIP/")
}
/// Request method (INVITE, REGISTER, ...) or None for responses.
pub fn method(&self) -> Option<&str> {
if !self.is_request() {
return None;
}
self.start_line.split_whitespace().next()
}
/// Response status code or None for requests.
pub fn status_code(&self) -> Option<u16> {
if !self.is_response() {
return None;
}
self.start_line
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
}
pub fn call_id(&self) -> &str {
self.get_header("Call-ID").unwrap_or("noid")
}
/// Method from the CSeq header (e.g. "INVITE").
pub fn cseq_method(&self) -> Option<&str> {
let cseq = self.get_header("CSeq")?;
cseq.split_whitespace().nth(1)
}
/// True for INVITE, SUBSCRIBE, REFER, NOTIFY, UPDATE.
pub fn is_dialog_establishing(&self) -> bool {
matches!(
self.method(),
Some("INVITE" | "SUBSCRIBE" | "REFER" | "NOTIFY" | "UPDATE")
)
}
/// True when the body carries an SDP payload.
pub fn has_sdp_body(&self) -> bool {
if self.body.is_empty() {
return false;
}
let ct = self.get_header("Content-Type").unwrap_or("");
ct.to_ascii_lowercase().starts_with("application/sdp")
}
// ---- Header accessors --------------------------------------------------
/// Get the first header value matching `name` (case-insensitive).
pub fn get_header(&self, name: &str) -> Option<&str> {
let nl = name.to_ascii_lowercase();
for (n, v) in &self.headers {
if n.to_ascii_lowercase() == nl {
return Some(v.as_str());
}
}
None
}
/// Overwrites the first header with the given name, or appends it.
pub fn set_header(&mut self, name: &str, value: &str) -> &mut Self {
let nl = name.to_ascii_lowercase();
for h in &mut self.headers {
if h.0.to_ascii_lowercase() == nl {
h.1 = value.to_string();
return self;
}
}
self.headers.push((name.to_string(), value.to_string()));
self
}
/// Inserts a header at the top of the header list.
pub fn prepend_header(&mut self, name: &str, value: &str) -> &mut Self {
self.headers.insert(0, (name.to_string(), value.to_string()));
self
}
/// Removes all headers with the given name.
pub fn remove_header(&mut self, name: &str) -> &mut Self {
let nl = name.to_ascii_lowercase();
self.headers.retain(|(n, _)| n.to_ascii_lowercase() != nl);
self
}
/// Recalculates Content-Length to match the current body.
pub fn update_content_length(&mut self) -> &mut Self {
let len = self.body.len();
self.set_header("Content-Length", &len.to_string())
}
// ---- Start-line mutation -----------------------------------------------
/// Replace the Request-URI (second token) of a request start line.
pub fn set_request_uri(&mut self, uri: &str) -> &mut Self {
if !self.is_request() {
return self;
}
let parts: Vec<&str> = self.start_line.splitn(3, ' ').collect();
if parts.len() >= 3 {
self.start_line = format!("{} {} {}", parts[0], uri, parts[2]);
}
self
}
/// Returns the Request-URI (second token) of a request start line.
pub fn request_uri(&self) -> Option<&str> {
if !self.is_request() {
return None;
}
self.start_line.split_whitespace().nth(1)
}
// ---- Factory methods ---------------------------------------------------
/// Build a new SIP request.
pub fn create_request(method: &str, request_uri: &str, opts: RequestOptions) -> Self {
let branch = opts.via_branch.unwrap_or_else(|| generate_branch());
let transport = opts.via_transport.unwrap_or_else(|| "UDP".to_string());
let from_tag = opts.from_tag.unwrap_or_else(|| generate_tag());
let call_id = opts.call_id.unwrap_or_else(|| generate_call_id(None));
let cseq = opts.cseq.unwrap_or(1);
let max_forwards = opts.max_forwards.unwrap_or(70);
let from_display = opts
.from_display_name
.map(|d| format!("\"{d}\" "))
.unwrap_or_default();
let to_display = opts
.to_display_name
.map(|d| format!("\"{d}\" "))
.unwrap_or_default();
let to_tag_str = opts
.to_tag
.map(|t| format!(";tag={t}"))
.unwrap_or_default();
let mut headers = vec![
(
"Via".to_string(),
format!(
"SIP/2.0/{transport} {}:{};branch={branch};rport",
opts.via_host, opts.via_port
),
),
(
"From".to_string(),
format!("{from_display}<{}>;tag={from_tag}", opts.from_uri),
),
(
"To".to_string(),
format!("{to_display}<{}>{to_tag_str}", opts.to_uri),
),
("Call-ID".to_string(), call_id),
("CSeq".to_string(), format!("{cseq} {method}")),
("Max-Forwards".to_string(), max_forwards.to_string()),
];
if let Some(contact) = &opts.contact {
headers.push(("Contact".to_string(), contact.clone()));
}
if let Some(extra) = opts.extra_headers {
headers.extend(extra);
}
let body = opts.body.unwrap_or_default();
if !body.is_empty() {
if let Some(ct) = &opts.content_type {
headers.push(("Content-Type".to_string(), ct.clone()));
}
}
headers.push(("Content-Length".to_string(), body.len().to_string()));
SipMessage {
start_line: format!("{method} {request_uri} SIP/2.0"),
headers,
body,
}
}
/// Build a SIP response to an incoming request.
/// Copies Via, From, To, Call-ID, and CSeq from the original request.
pub fn create_response(
status_code: u16,
reason_phrase: &str,
request: &SipMessage,
opts: Option<ResponseOptions>,
) -> Self {
let opts = opts.unwrap_or_default();
let mut headers: Vec<(String, String)> = Vec::new();
// Copy all Via headers (order matters).
for (n, v) in &request.headers {
if n.to_ascii_lowercase() == "via" {
headers.push(("Via".to_string(), v.clone()));
}
}
// From — copied verbatim.
if let Some(from) = request.get_header("From") {
headers.push(("From".to_string(), from.to_string()));
}
// To — add tag if provided and not already present.
let mut to = request.get_header("To").unwrap_or("").to_string();
if let Some(tag) = &opts.to_tag {
if !to.contains("tag=") {
to.push_str(&format!(";tag={tag}"));
}
}
headers.push(("To".to_string(), to));
headers.push(("Call-ID".to_string(), request.call_id().to_string()));
if let Some(cseq) = request.get_header("CSeq") {
headers.push(("CSeq".to_string(), cseq.to_string()));
}
if let Some(contact) = &opts.contact {
headers.push(("Contact".to_string(), contact.clone()));
}
if let Some(extra) = opts.extra_headers {
headers.extend(extra);
}
let body = opts.body.unwrap_or_default();
if !body.is_empty() {
if let Some(ct) = &opts.content_type {
headers.push(("Content-Type".to_string(), ct.clone()));
}
}
headers.push(("Content-Length".to_string(), body.len().to_string()));
SipMessage {
start_line: format!("SIP/2.0 {status_code} {reason_phrase}"),
headers,
body,
}
}
/// Extract the tag from a From or To header value.
pub fn extract_tag(header_value: &str) -> Option<&str> {
let idx = header_value.find(";tag=")?;
let rest = &header_value[idx + 5..];
let end = rest
.find(|c: char| c.is_whitespace() || c == ';' || c == '>')
.unwrap_or(rest.len());
Some(&rest[..end])
}
/// Extract the URI from an addr-spec or name-addr (From/To/Contact).
pub fn extract_uri(header_value: &str) -> Option<&str> {
if let Some(start) = header_value.find('<') {
let end = header_value[start..].find('>')?;
Some(&header_value[start + 1..start + end])
} else {
let trimmed = header_value.trim();
let end = trimmed
.find(|c: char| c == ';' || c == '>')
.unwrap_or(trimmed.len());
let result = &trimmed[..end];
if result.is_empty() { None } else { Some(result) }
}
}
}
/// Options for `SipMessage::create_request`.
pub struct RequestOptions {
pub via_host: String,
pub via_port: u16,
pub via_transport: Option<String>,
pub via_branch: Option<String>,
pub from_uri: String,
pub from_display_name: Option<String>,
pub from_tag: Option<String>,
pub to_uri: String,
pub to_display_name: Option<String>,
pub to_tag: Option<String>,
pub call_id: Option<String>,
pub cseq: Option<u32>,
pub contact: Option<String>,
pub max_forwards: Option<u16>,
pub body: Option<String>,
pub content_type: Option<String>,
pub extra_headers: Option<Vec<(String, String)>>,
}
/// Options for `SipMessage::create_response`.
#[derive(Default)]
pub struct ResponseOptions {
pub to_tag: Option<String>,
pub contact: Option<String>,
pub body: Option<String>,
pub content_type: Option<String>,
pub extra_headers: Option<Vec<(String, String)>>,
}
/// Check if a string matches the SIP first-line pattern.
fn is_sip_first_line(line: &str) -> bool {
// Request: METHOD SP URI SP SIP/X.Y
// Response: SIP/X.Y SP STATUS SP REASON
if line.starts_with("SIP/") {
// Response: SIP/2.0 200 OK
let parts: Vec<&str> = line.splitn(3, ' ').collect();
if parts.len() >= 2 {
return parts[1].chars().all(|c| c.is_ascii_digit());
}
} else {
// Request: INVITE sip:user@host SIP/2.0
let parts: Vec<&str> = line.splitn(3, ' ').collect();
if parts.len() >= 3 {
return parts[0].chars().all(|c| c.is_ascii_uppercase())
&& parts[2].starts_with("SIP/");
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
const INVITE_RAW: &str = "INVITE sip:user@host SIP/2.0\r\n\
Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK-test\r\n\
From: <sip:caller@host>;tag=abc\r\n\
To: <sip:user@host>\r\n\
Call-ID: test-call-id\r\n\
CSeq: 1 INVITE\r\n\
Content-Length: 0\r\n\r\n";
#[test]
fn parse_invite() {
let msg = SipMessage::parse(INVITE_RAW.as_bytes()).unwrap();
assert!(msg.is_request());
assert!(!msg.is_response());
assert_eq!(msg.method(), Some("INVITE"));
assert_eq!(msg.call_id(), "test-call-id");
assert_eq!(msg.cseq_method(), Some("INVITE"));
assert!(msg.is_dialog_establishing());
assert_eq!(msg.request_uri(), Some("sip:user@host"));
}
#[test]
fn parse_response() {
let raw = "SIP/2.0 200 OK\r\n\
Via: SIP/2.0/UDP 192.168.1.1:5060;branch=z9hG4bK-test\r\n\
From: <sip:caller@host>;tag=abc\r\n\
To: <sip:user@host>;tag=def\r\n\
Call-ID: test-call-id\r\n\
CSeq: 1 INVITE\r\n\
Content-Length: 0\r\n\r\n";
let msg = SipMessage::parse(raw.as_bytes()).unwrap();
assert!(msg.is_response());
assert_eq!(msg.status_code(), Some(200));
assert_eq!(msg.cseq_method(), Some("INVITE"));
}
#[test]
fn serialize_roundtrip() {
let msg = SipMessage::parse(INVITE_RAW.as_bytes()).unwrap();
let serialized = msg.serialize();
let reparsed = SipMessage::parse(&serialized).unwrap();
assert_eq!(reparsed.call_id(), "test-call-id");
assert_eq!(reparsed.method(), Some("INVITE"));
assert_eq!(reparsed.headers.len(), msg.headers.len());
}
#[test]
fn header_mutation() {
let mut msg = SipMessage::parse(INVITE_RAW.as_bytes()).unwrap();
msg.set_header("X-Custom", "value1");
assert_eq!(msg.get_header("X-Custom"), Some("value1"));
msg.set_header("X-Custom", "value2");
assert_eq!(msg.get_header("X-Custom"), Some("value2"));
msg.prepend_header("X-First", "first");
assert_eq!(msg.headers[0].0, "X-First");
msg.remove_header("X-Custom");
assert_eq!(msg.get_header("X-Custom"), None);
}
#[test]
fn set_request_uri() {
let mut msg = SipMessage::parse(INVITE_RAW.as_bytes()).unwrap();
msg.set_request_uri("sip:new@host");
assert_eq!(msg.request_uri(), Some("sip:new@host"));
assert!(msg.start_line.starts_with("INVITE sip:new@host SIP/2.0"));
}
#[test]
fn extract_tag_and_uri() {
assert_eq!(
SipMessage::extract_tag("<sip:user@host>;tag=abc123"),
Some("abc123")
);
assert_eq!(SipMessage::extract_tag("<sip:user@host>"), None);
assert_eq!(
SipMessage::extract_uri("<sip:user@host>"),
Some("sip:user@host")
);
assert_eq!(
SipMessage::extract_uri("\"Name\" <sip:user@host>;tag=abc"),
Some("sip:user@host")
);
}
#[test]
fn create_request_and_response() {
let invite = SipMessage::create_request(
"INVITE",
"sip:user@host",
RequestOptions {
via_host: "192.168.1.1".to_string(),
via_port: 5070,
via_transport: None,
via_branch: None,
from_uri: "sip:caller@proxy".to_string(),
from_display_name: None,
from_tag: Some("mytag".to_string()),
to_uri: "sip:user@host".to_string(),
to_display_name: None,
to_tag: None,
call_id: Some("test-123".to_string()),
cseq: Some(1),
contact: Some("<sip:caller@192.168.1.1:5070>".to_string()),
max_forwards: None,
body: None,
content_type: None,
extra_headers: None,
},
);
assert_eq!(invite.method(), Some("INVITE"));
assert_eq!(invite.call_id(), "test-123");
assert!(invite.get_header("Via").unwrap().contains("192.168.1.1:5070"));
let response = SipMessage::create_response(
200,
"OK",
&invite,
Some(ResponseOptions {
to_tag: Some("remotetag".to_string()),
..Default::default()
}),
);
assert!(response.is_response());
assert_eq!(response.status_code(), Some(200));
let to = response.get_header("To").unwrap();
assert!(to.contains("tag=remotetag"));
}
#[test]
fn has_sdp_body() {
let mut msg = SipMessage::parse(INVITE_RAW.as_bytes()).unwrap();
assert!(!msg.has_sdp_body());
msg.body = "v=0\r\no=- 1 1 IN IP4 0.0.0.0\r\n".to_string();
msg.set_header("Content-Type", "application/sdp");
assert!(msg.has_sdp_body());
}
}