409 lines
13 KiB
Rust
409 lines
13 KiB
Rust
|
|
//! 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"));
|
||
|
|
}
|
||
|
|
}
|