feat(rust-proxy-engine): add a Rust SIP proxy engine with shared SIP and codec libraries
This commit is contained in:
408
rust/crates/sip-proto/src/dialog.rs
Normal file
408
rust/crates/sip-proto/src/dialog.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user