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,8 @@
[package]
name = "sip-proto"
version = "0.1.0"
edition = "2021"
[dependencies]
md-5 = "0.10"
rand = "0.8"

View File

@@ -0,0 +1,408 @@
//! SIP dialog state machine (RFC 3261 §12).
//!
//! Tracks local/remote tags, CSeq counters, route set, and remote target.
//! Provides methods to build in-dialog requests (BYE, re-INVITE, ACK, CANCEL).
//!
//! Ported from ts/sip/dialog.ts.
use crate::helpers::{generate_branch, generate_tag};
use crate::message::SipMessage;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DialogState {
Early,
Confirmed,
Terminated,
}
/// SIP dialog state per RFC 3261 §12.
#[derive(Debug, Clone)]
pub struct SipDialog {
pub call_id: String,
pub local_tag: String,
pub remote_tag: Option<String>,
pub local_uri: String,
pub remote_uri: String,
pub local_cseq: u32,
pub remote_cseq: u32,
pub route_set: Vec<String>,
pub remote_target: String,
pub state: DialogState,
pub local_host: String,
pub local_port: u16,
}
impl SipDialog {
/// Create a dialog from an INVITE we are sending (UAC side).
/// The dialog enters Early state; call `process_response()` when responses arrive.
pub fn from_uac_invite(invite: &SipMessage, local_host: &str, local_port: u16) -> Self {
let from = invite.get_header("From").unwrap_or("");
let to = invite.get_header("To").unwrap_or("");
let local_cseq = invite
.get_header("CSeq")
.and_then(|c| c.split_whitespace().next())
.and_then(|s| s.parse().ok())
.unwrap_or(1);
Self {
call_id: invite.call_id().to_string(),
local_tag: SipMessage::extract_tag(from)
.map(|s| s.to_string())
.unwrap_or_else(generate_tag),
remote_tag: None,
local_uri: SipMessage::extract_uri(from)
.unwrap_or("")
.to_string(),
remote_uri: SipMessage::extract_uri(to).unwrap_or("").to_string(),
local_cseq,
remote_cseq: 0,
route_set: Vec::new(),
remote_target: invite
.request_uri()
.or_else(|| SipMessage::extract_uri(to))
.unwrap_or("")
.to_string(),
state: DialogState::Early,
local_host: local_host.to_string(),
local_port,
}
}
/// Create a dialog from an INVITE we received (UAS side).
pub fn from_uas_invite(
invite: &SipMessage,
local_tag: &str,
local_host: &str,
local_port: u16,
) -> Self {
let from = invite.get_header("From").unwrap_or("");
let to = invite.get_header("To").unwrap_or("");
let contact = invite.get_header("Contact");
let remote_target = contact
.and_then(SipMessage::extract_uri)
.or_else(|| SipMessage::extract_uri(from))
.unwrap_or("")
.to_string();
Self {
call_id: invite.call_id().to_string(),
local_tag: local_tag.to_string(),
remote_tag: SipMessage::extract_tag(from).map(|s| s.to_string()),
local_uri: SipMessage::extract_uri(to).unwrap_or("").to_string(),
remote_uri: SipMessage::extract_uri(from).unwrap_or("").to_string(),
local_cseq: 0,
remote_cseq: 0,
route_set: Vec::new(),
remote_target,
state: DialogState::Early,
local_host: local_host.to_string(),
local_port,
}
}
/// Update dialog state from a received response.
pub fn process_response(&mut self, response: &SipMessage) {
let to = response.get_header("To").unwrap_or("");
let tag = SipMessage::extract_tag(to).map(|s| s.to_string());
let code = response.status_code().unwrap_or(0);
// Always update remoteTag from 2xx (RFC 3261 §12.1.2).
if let Some(ref t) = tag {
if code >= 200 && code < 300 {
self.remote_tag = Some(t.clone());
} else if self.remote_tag.is_none() {
self.remote_tag = Some(t.clone());
}
}
// Update remote target from Contact.
if let Some(contact) = response.get_header("Contact") {
if let Some(uri) = SipMessage::extract_uri(contact) {
self.remote_target = uri.to_string();
}
}
// Record-Route → route set (in reverse for UAC).
if self.state == DialogState::Early {
let rr: Vec<String> = response
.headers
.iter()
.filter(|(n, _)| n.to_ascii_lowercase() == "record-route")
.map(|(_, v)| v.clone())
.collect();
if !rr.is_empty() {
let mut reversed = rr;
reversed.reverse();
self.route_set = reversed;
}
}
if code >= 200 && code < 300 {
self.state = DialogState::Confirmed;
} else if code >= 300 {
self.state = DialogState::Terminated;
}
}
/// Build an in-dialog request (BYE, re-INVITE, INFO, ...).
/// Automatically increments the local CSeq.
pub fn create_request(
&mut self,
method: &str,
body: Option<&str>,
content_type: Option<&str>,
extra_headers: Option<Vec<(String, String)>>,
) -> SipMessage {
self.local_cseq += 1;
let branch = generate_branch();
let remote_tag_str = self
.remote_tag
.as_ref()
.map(|t| format!(";tag={t}"))
.unwrap_or_default();
let mut headers = vec![
(
"Via".to_string(),
format!(
"SIP/2.0/UDP {}:{};branch={branch};rport",
self.local_host, self.local_port
),
),
(
"From".to_string(),
format!("<{}>;tag={}", self.local_uri, self.local_tag),
),
(
"To".to_string(),
format!("<{}>{remote_tag_str}", self.remote_uri),
),
("Call-ID".to_string(), self.call_id.clone()),
(
"CSeq".to_string(),
format!("{} {method}", self.local_cseq),
),
("Max-Forwards".to_string(), "70".to_string()),
];
for route in &self.route_set {
headers.push(("Route".to_string(), route.clone()));
}
headers.push((
"Contact".to_string(),
format!("<sip:{}:{}>", self.local_host, self.local_port),
));
if let Some(extra) = extra_headers {
headers.extend(extra);
}
let body_str = body.unwrap_or("");
if !body_str.is_empty() {
if let Some(ct) = content_type {
headers.push(("Content-Type".to_string(), ct.to_string()));
}
}
headers.push(("Content-Length".to_string(), body_str.len().to_string()));
let ruri = self.resolve_ruri();
SipMessage::new(
format!("{method} {ruri} SIP/2.0"),
headers,
body_str.to_string(),
)
}
/// Build an ACK for a 2xx response to INVITE (RFC 3261 §13.2.2.4).
pub fn create_ack(&self) -> SipMessage {
let branch = generate_branch();
let remote_tag_str = self
.remote_tag
.as_ref()
.map(|t| format!(";tag={t}"))
.unwrap_or_default();
let mut headers = vec![
(
"Via".to_string(),
format!(
"SIP/2.0/UDP {}:{};branch={branch};rport",
self.local_host, self.local_port
),
),
(
"From".to_string(),
format!("<{}>;tag={}", self.local_uri, self.local_tag),
),
(
"To".to_string(),
format!("<{}>{remote_tag_str}", self.remote_uri),
),
("Call-ID".to_string(), self.call_id.clone()),
(
"CSeq".to_string(),
format!("{} ACK", self.local_cseq),
),
("Max-Forwards".to_string(), "70".to_string()),
];
for route in &self.route_set {
headers.push(("Route".to_string(), route.clone()));
}
headers.push(("Content-Length".to_string(), "0".to_string()));
let ruri = self.resolve_ruri();
SipMessage::new(format!("ACK {ruri} SIP/2.0"), headers, String::new())
}
/// Build a CANCEL for the original INVITE (same branch, CSeq).
pub fn create_cancel(&self, original_invite: &SipMessage) -> SipMessage {
let via = original_invite.get_header("Via").unwrap_or("").to_string();
let from = original_invite.get_header("From").unwrap_or("").to_string();
let to = original_invite.get_header("To").unwrap_or("").to_string();
let headers = vec![
("Via".to_string(), via),
("From".to_string(), from),
("To".to_string(), to),
("Call-ID".to_string(), self.call_id.clone()),
(
"CSeq".to_string(),
format!("{} CANCEL", self.local_cseq),
),
("Max-Forwards".to_string(), "70".to_string()),
("Content-Length".to_string(), "0".to_string()),
];
let ruri = original_invite
.request_uri()
.unwrap_or(&self.remote_target)
.to_string();
SipMessage::new(
format!("CANCEL {ruri} SIP/2.0"),
headers,
String::new(),
)
}
/// Transition the dialog to terminated state.
pub fn terminate(&mut self) {
self.state = DialogState::Terminated;
}
/// Resolve Request-URI from route set or remote target.
fn resolve_ruri(&self) -> &str {
if !self.route_set.is_empty() {
if let Some(top_route) = SipMessage::extract_uri(&self.route_set[0]) {
if top_route.contains(";lr") {
return &self.remote_target; // loose routing
}
return top_route; // strict routing
}
}
&self.remote_target
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::RequestOptions;
fn make_invite() -> SipMessage {
SipMessage::create_request(
"INVITE",
"sip:callee@host",
RequestOptions {
via_host: "192.168.1.1".to_string(),
via_port: 5070,
via_transport: None,
via_branch: Some("z9hG4bK-test".to_string()),
from_uri: "sip:caller@proxy".to_string(),
from_display_name: None,
from_tag: Some("from-tag".to_string()),
to_uri: "sip:callee@host".to_string(),
to_display_name: None,
to_tag: None,
call_id: Some("test-dialog-call".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,
},
)
}
#[test]
fn uac_dialog_lifecycle() {
let invite = make_invite();
let mut dialog = SipDialog::from_uac_invite(&invite, "192.168.1.1", 5070);
assert_eq!(dialog.state, DialogState::Early);
assert_eq!(dialog.call_id, "test-dialog-call");
assert_eq!(dialog.local_tag, "from-tag");
assert!(dialog.remote_tag.is_none());
// Simulate 200 OK
let response = SipMessage::create_response(
200,
"OK",
&invite,
Some(crate::message::ResponseOptions {
to_tag: Some("remote-tag".to_string()),
contact: Some("<sip:callee@10.0.0.1:5060>".to_string()),
..Default::default()
}),
);
dialog.process_response(&response);
assert_eq!(dialog.state, DialogState::Confirmed);
assert_eq!(dialog.remote_tag.as_deref(), Some("remote-tag"));
assert_eq!(dialog.remote_target, "sip:callee@10.0.0.1:5060");
}
#[test]
fn create_bye() {
let invite = make_invite();
let mut dialog = SipDialog::from_uac_invite(&invite, "192.168.1.1", 5070);
dialog.remote_tag = Some("remote-tag".to_string());
dialog.state = DialogState::Confirmed;
let bye = dialog.create_request("BYE", None, None, None);
assert_eq!(bye.method(), Some("BYE"));
assert_eq!(bye.call_id(), "test-dialog-call");
assert_eq!(dialog.local_cseq, 2);
let to = bye.get_header("To").unwrap();
assert!(to.contains("tag=remote-tag"));
}
#[test]
fn create_ack() {
let invite = make_invite();
let mut dialog = SipDialog::from_uac_invite(&invite, "192.168.1.1", 5070);
dialog.remote_tag = Some("remote-tag".to_string());
let ack = dialog.create_ack();
assert_eq!(ack.method(), Some("ACK"));
assert!(ack.get_header("CSeq").unwrap().contains("ACK"));
}
#[test]
fn create_cancel() {
let invite = make_invite();
let dialog = SipDialog::from_uac_invite(&invite, "192.168.1.1", 5070);
let cancel = dialog.create_cancel(&invite);
assert_eq!(cancel.method(), Some("CANCEL"));
assert!(cancel.get_header("CSeq").unwrap().contains("CANCEL"));
assert!(cancel.start_line.contains("sip:callee@host"));
}
}

View File

@@ -0,0 +1,331 @@
//! 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::<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 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<String> = 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<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 = &params[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 and connection address from an SDP body.
pub fn parse_sdp_endpoint(sdp: &str) -> Option<Endpoint> {
let mut addr: Option<&str> = None;
let mut port: Option<u16> = 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 ") {
let parts: Vec<&str> = rest.split_whitespace().collect();
if !parts.is_empty() {
port = parts[0].parse().ok();
}
}
}
match (addr, port) {
(Some(a), Some(p)) => Some(Endpoint {
address: a.to_string(),
port: p,
}),
_ => 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");
}
}

View File

@@ -0,0 +1,17 @@
//! SIP protocol library for the proxy engine.
//!
//! Provides SIP message parsing/serialization, dialog state management,
//! SDP handling, Digest authentication, and URI rewriting.
//! Ported from the TypeScript `ts/sip/` library.
pub mod message;
pub mod dialog;
pub mod helpers;
pub mod rewrite;
/// Network endpoint (address + port).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Endpoint {
pub address: String,
pub port: u16,
}

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());
}
}

View File

@@ -0,0 +1,130 @@
//! SIP URI and SDP body rewriting helpers.
//!
//! Ported from ts/sip/rewrite.ts.
use crate::Endpoint;
/// Replaces the host:port in every `sip:` / `sips:` URI found in `value`.
pub fn rewrite_sip_uri(value: &str, host: &str, port: u16) -> String {
let mut result = String::with_capacity(value.len());
let mut i = 0;
let bytes = value.as_bytes();
while i < bytes.len() {
// Look for "sip:" or "sips:"
let scheme_len = if i + 4 <= bytes.len()
&& (bytes[i..].starts_with(b"sip:") || bytes[i..].starts_with(b"SIP:"))
{
4
} else if i + 5 <= bytes.len()
&& (bytes[i..].starts_with(b"sips:") || bytes[i..].starts_with(b"SIPS:"))
{
5
} else {
result.push(value[i..].chars().next().unwrap());
i += value[i..].chars().next().unwrap().len_utf8();
continue;
};
let scheme = &value[i..i + scheme_len];
let rest = &value[i + scheme_len..];
// Check for userpart (contains '@')
let (userpart, host_start) = if let Some(at) = rest.find('@') {
// Make sure @ comes before any delimiters
let delim = rest.find(|c: char| c == '>' || c == ';' || c == ',' || c.is_whitespace());
if delim.is_none() || at < delim.unwrap() {
(&rest[..=at], at + 1)
} else {
("", 0)
}
} else {
("", 0)
};
// Find the end of the host:port portion
let host_rest = &rest[host_start..];
let end = host_rest
.find(|c: char| c == '>' || c == ';' || c == ',' || c.is_whitespace())
.unwrap_or(host_rest.len());
result.push_str(scheme);
result.push_str(userpart);
result.push_str(&format!("{host}:{port}"));
i += scheme_len + host_start + end;
}
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).
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 lines: Vec<String> = body
.replace("\r\n", "\n")
.split('\n')
.map(|line| {
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 ") {
let parts: Vec<&str> = line.split(' ').collect();
if parts.len() >= 2 {
orig_port = parts[1].parse().ok();
let mut rebuilt = parts[0].to_string();
rebuilt.push(' ');
rebuilt.push_str(&port.to_string());
for part in &parts[2..] {
rebuilt.push(' ');
rebuilt.push_str(part);
}
return rebuilt;
}
line.to_string()
} else {
line.to_string()
}
})
.collect();
let original = match (orig_addr, orig_port) {
(Some(a), Some(p)) => Some(Endpoint { address: a, port: p }),
_ => None,
};
(lines.join("\r\n"), original)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rewrite_sip_uri() {
let input = "<sip:user@10.0.0.1:5060>";
let result = rewrite_sip_uri(input, "192.168.1.1", 5070);
assert_eq!(result, "<sip:user@192.168.1.1:5070>");
}
#[test]
fn test_rewrite_sip_uri_no_port() {
let input = "sip:user@10.0.0.1";
let result = rewrite_sip_uri(input, "192.168.1.1", 5070);
assert_eq!(result, "sip:user@192.168.1.1:5070");
}
#[test]
fn test_rewrite_sdp() {
let sdp = "v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 5060 RTP/AVP 0 9\r\na=sendrecv\r\n";
let (rewritten, orig) = rewrite_sdp(sdp, "192.168.1.1", 20000);
assert!(rewritten.contains("c=IN IP4 192.168.1.1"));
assert!(rewritten.contains("m=audio 20000 RTP/AVP 0 9"));
let ep = orig.unwrap();
assert_eq!(ep.address, "10.0.0.1");
assert_eq!(ep.port, 5060);
}
}