diff --git a/src/dialog/registration.rs b/src/dialog/registration.rs index d7b0d261..dc9604d7 100644 --- a/src/dialog/registration.rs +++ b/src/dialog/registration.rs @@ -121,6 +121,12 @@ pub struct Registration { /// domain in SIP headers. Used for NAT traversal with load-balanced /// proxy clusters where DNS may resolve to different IPs. pub outbound_proxy: Option, + /// Service-Route set (RFC 3608) learned from the last successful + /// registration `200 OK`. These entries are the proxies the registrar + /// (e.g. an IMS S-CSCF) wants traversed on subsequent requests; a UA + /// preloads them as `Route` headers on later out-of-dialog requests. + /// Populated on each `200 OK`; empty when the response carried none. + pub service_route: Vec, } impl Registration { @@ -173,6 +179,7 @@ impl Registration { public_address: None, call_id, outbound_proxy: None, + service_route: Vec::new(), } } @@ -209,6 +216,20 @@ impl Registration { self.public_address.clone() } + /// Get the Service-Route set (RFC 3608) from the last successful + /// registration. + /// + /// Returns the ordered list of routes the registrar asked the user agent + /// to traverse on subsequent requests. In IMS this is the originating + /// route set advertised by the S-CSCF. The slice is empty when the last + /// `200 OK` carried no `Service-Route` header. + /// + /// This accessor only exposes the learned set; applying it to outgoing + /// requests (preloading `Route` headers) is left to the caller. + pub fn service_route(&self) -> &[crate::sip::typed::ServiceRoute] { + &self.service_route + } + /// Get the registration expiration time /// /// Returns the expiration time in seconds for the current registration. @@ -551,9 +572,17 @@ impl Registration { ); self.public_address = received; } + + // RFC 3608: adopt the Service-Route set advertised by + // the registrar as the preloaded route set for later + // out-of-dialog requests. Malformed values are ignored + // rather than failing the registration. + self.service_route = resp.typed_service_route_headers().unwrap_or_default(); + debug!( status = %resp.status_code, contact = ?self.contact.as_ref().map(|c| c.uri.to_string()), + service_route = self.service_route.len(), "registration do_request done" ); return Ok(resp); diff --git a/src/sip/headers/mod.rs b/src/sip/headers/mod.rs index 6895105d..d0aea326 100644 --- a/src/sip/headers/mod.rs +++ b/src/sip/headers/mod.rs @@ -40,6 +40,7 @@ pub enum Header { Require(Require), RetryAfter(RetryAfter), Route(Route), + ServiceRoute(ServiceRoute), Server(Server), Subject(Subject), SubscriptionState(SubscriptionState), @@ -108,6 +109,7 @@ impl std::fmt::Display for Header { Self::Require(inner) => write!(f, "{}", inner), Self::RetryAfter(inner) => write!(f, "{}", inner), Self::Route(inner) => write!(f, "{}", inner), + Self::ServiceRoute(inner) => write!(f, "{}", inner), Self::Server(inner) => write!(f, "{}", inner), Self::Subject(inner) => write!(f, "{}", inner), Self::SubscriptionState(inner) => write!(f, "{}", inner), @@ -178,6 +180,7 @@ impl Header { Self::Require(_) => "Require", Self::RetryAfter(_) => "Retry-After", Self::Route(_) => "Route", + Self::ServiceRoute(_) => "Service-Route", Self::Server(_) => "Server", Self::Subject(_) => "Subject", Self::SubscriptionState(_) => "Subscription-State", @@ -246,6 +249,7 @@ impl Header { Self::Require(h) => h.value(), Self::RetryAfter(h) => h.value(), Self::Route(h) => h.value(), + Self::ServiceRoute(h) => h.value(), Self::Server(h) => h.value(), Self::Subject(h) => h.value(), Self::SubscriptionState(h) => h.value(), @@ -459,6 +463,9 @@ pub fn make_header(name: &str, value: String) -> Header { n if n.eq_ignore_ascii_case("Require") => Header::Require(Require::new(value)), n if n.eq_ignore_ascii_case("Retry-After") => Header::RetryAfter(RetryAfter::new(value)), n if n.eq_ignore_ascii_case("Route") => Header::Route(Route::new(value)), + n if n.eq_ignore_ascii_case("Service-Route") => { + Header::ServiceRoute(ServiceRoute::new(value)) + } n if n.eq_ignore_ascii_case("Server") => Header::Server(Server::new(value)), n if n.eq_ignore_ascii_case("Subject") || n.eq_ignore_ascii_case("s") => { Header::Subject(Subject::new(value)) diff --git a/src/sip/headers/typed/mod.rs b/src/sip/headers/typed/mod.rs index 0329f9e0..fb4f4101 100644 --- a/src/sip/headers/typed/mod.rs +++ b/src/sip/headers/typed/mod.rs @@ -31,6 +31,7 @@ pub use proxy_authenticate::ProxyAuthenticate; pub use proxy_authorization::ProxyAuthorization; pub use record_route::RecordRoute; pub use route::Route; +pub use service_route::ServiceRoute; pub use to::To; pub use via::Via; pub use www_authenticate::WwwAuthenticate; @@ -46,6 +47,7 @@ pub mod proxy_authenticate; pub mod proxy_authorization; pub mod record_route; pub mod route; +pub mod service_route; pub mod to; pub mod via; pub mod www_authenticate; diff --git a/src/sip/headers/typed/service_route.rs b/src/sip/headers/typed/service_route.rs new file mode 100644 index 00000000..5f406a50 --- /dev/null +++ b/src/sip/headers/typed/service_route.rs @@ -0,0 +1,160 @@ +use super::parse_helpers::parse_display_uri_params_str; +use crate::sip::{uri::Param, uri::ParamsExt, Error, Header, Uri}; + +/// Typed `Service-Route` header (RFC 3608). +/// +/// A registrar returns one or more `Service-Route` headers in a REGISTER +/// `200 OK` to tell the user agent which proxies to traverse for subsequent +/// requests within the registration. In IMS these entries form the originating +/// route set advertised by the S-CSCF; the UA preloads them as `Route` headers +/// on later requests such as the initial INVITE. +/// +/// The grammar mirrors `Route`/`Record-Route`: a comma-separated list of +/// name-addr values, each with optional URI and header parameters. +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct ServiceRoute { + pub display_name: Option, + pub uri: Uri, + pub params: Vec, +} + +fn split_service_route_values(s: &str) -> Vec { + let mut values = Vec::new(); + let mut current = String::new(); + let mut angle_depth = 0usize; + let mut in_quotes = false; + for ch in s.chars() { + match ch { + '"' => { + in_quotes = !in_quotes; + current.push(ch); + } + '<' if !in_quotes => { + angle_depth += 1; + current.push(ch); + } + '>' if !in_quotes => { + angle_depth = angle_depth.saturating_sub(1); + current.push(ch); + } + ',' if !in_quotes && angle_depth == 0 => { + let v = current.trim().to_string(); + if !v.is_empty() { + values.push(v); + } + current.clear(); + } + _ => current.push(ch), + } + } + let v = current.trim().to_string(); + if !v.is_empty() { + values.push(v); + } + values +} + +impl ServiceRoute { + pub fn parse(s: &str) -> Result { + let (display_name, uri, params) = parse_display_uri_params_str(s)?; + Ok(ServiceRoute { + display_name, + uri, + params, + }) + } + + /// Parse a single `Service-Route` header value that may contain several + /// comma-separated entries into one `ServiceRoute` per entry. + pub fn parse_header_list(s: &str) -> Result, Error> { + split_service_route_values(s) + .into_iter() + .map(|v| Self::parse(&v)) + .collect() + } + + pub fn has_lr(&self) -> bool { + self.uri.has_lr() + } +} + +impl std::fmt::Display for ServiceRoute { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.display_name { + Some(name) => write!(f, "\"{}\" <{}>", name, self.uri)?, + None => write!(f, "<{}>", self.uri)?, + } + for p in &self.params { + write!(f, "{}", p)?; + } + Ok(()) + } +} + +impl std::convert::From for ServiceRoute { + fn from(uri: Uri) -> Self { + Self { + display_name: None, + uri, + params: vec![], + } + } +} + +impl std::convert::From for String { + fn from(r: ServiceRoute) -> String { + r.to_string() + } +} + +impl std::convert::From for Header { + fn from(r: ServiceRoute) -> Header { + Header::ServiceRoute(crate::sip::headers::untyped::ServiceRoute::new( + r.to_string(), + )) + } +} + +impl<'a> super::TypedHeader<'a> for ServiceRoute {} + +#[cfg(test)] +mod tests { + use super::ServiceRoute; + + #[test] + fn service_route_single_lr() { + let sr = ServiceRoute::parse("").unwrap(); + assert_eq!(sr.uri.to_string(), "sip:scscf.home.net;lr"); + assert!(sr.has_lr()); + } + + #[test] + fn service_route_display_roundtrip() { + let s = ""; + let sr = ServiceRoute::parse(s).unwrap(); + assert_eq!(sr.to_string(), s); + } + + #[test] + fn service_route_multi_uri() { + // 3GPP TS 24.229 style: S-CSCF followed by P-CSCF in the route set. + let routes = + ServiceRoute::parse_header_list(", ") + .unwrap(); + assert_eq!(routes.len(), 2); + assert_eq!(routes[0].uri.to_string(), "sip:scscf.home.net;lr"); + assert_eq!(routes[1].uri.to_string(), "sip:pcscf.visited.net;lr"); + assert!(routes[0].has_lr()); + assert!(routes[1].has_lr()); + } + + #[test] + fn service_route_to_header_and_back() { + let sr = ServiceRoute::parse("").unwrap(); + let header: crate::sip::Header = sr.clone().into(); + // The untyped header value drops the surrounding header name but keeps + // the name-addr, so re-parsing yields the same typed value. + let reparsed = ServiceRoute::parse(header.value()).unwrap(); + assert_eq!(sr, reparsed); + } +} diff --git a/src/sip/headers/untyped.rs b/src/sip/headers/untyped.rs index 1d6e40e8..3fa7f973 100644 --- a/src/sip/headers/untyped.rs +++ b/src/sip/headers/untyped.rs @@ -171,6 +171,7 @@ untyped_header!(RSeq, "RSeq", Header::RSeq); untyped_header!(RAck, "RAck", Header::RAck); untyped_header!(Privacy, "Privacy", Header::Privacy); untyped_header!(Path, "Path", Header::Path); +untyped_header!(ServiceRoute, "Service-Route", Header::ServiceRoute); untyped_header!(Identity, "Identity", Header::Identity); untyped_header!(UserToUser, "User-to-User", Header::UserToUser); untyped_header!(SessionId, "Session-ID", Header::SessionId); diff --git a/src/sip/message.rs b/src/sip/message.rs index dd35ba3b..fb309a48 100644 --- a/src/sip/message.rs +++ b/src/sip/message.rs @@ -232,6 +232,21 @@ pub trait HeadersExt: HasHeaders { } Ok(routes) } + fn service_route_headers(&self) -> Vec<&ServiceRoute> { + all_headers!(self.headers().iter(), Header::ServiceRoute) + } + fn service_route_header(&self) -> Option<&ServiceRoute> { + header_opt!(self.headers().iter(), Header::ServiceRoute) + } + fn typed_service_route_headers(&self) -> Result, Error> { + let mut routes = Vec::new(); + for r in self.service_route_headers() { + routes.extend(crate::sip::typed::ServiceRoute::parse_header_list( + r.value(), + )?); + } + Ok(routes) + } fn user_agent_header(&self) -> Option<&UserAgent> { header_opt!(self.headers().iter(), Header::UserAgent) } @@ -1174,6 +1189,38 @@ mod tests { assert!(paths[0].value().contains("edge.restsend.com")); } + #[test] + fn new_headers_service_route_header() { + // REGISTER 200 OK carrying an IMS-style Service-Route set. RFC 3608 + // allows the entries to arrive either as one comma-separated header or + // as several header lines; both must fold into the same route set. + let msg: SipMessage = concat!( + "SIP/2.0 200 OK\r\n", + "Via: SIP/2.0/TCP edge.home.net;branch=z9hG4bKtest\r\n", + "From: ;tag=abc\r\n", + "To: ;tag=xyz\r\n", + "Call-ID: sr-test@edge.home.net\r\n", + "CSeq: 1 REGISTER\r\n", + "Service-Route: \r\n", + "Service-Route: \r\n", + "Contact: ;expires=600\r\n", + "Content-Length: 0\r\n", + "\r\n" + ) + .try_into() + .unwrap(); + + let raw = msg.service_route_headers(); + assert_eq!(raw.len(), 2); + + let routes = msg.typed_service_route_headers().unwrap(); + assert_eq!(routes.len(), 2); + assert_eq!(routes[0].uri.to_string(), "sip:scscf.home.net;lr"); + assert_eq!(routes[1].uri.to_string(), "sip:pcscf.visited.net;lr"); + assert!(routes[0].has_lr()); + assert!(routes[1].has_lr()); + } + #[test] fn header_value_helper_case_insensitive() { let req: Request = invite_request().try_into().unwrap(); diff --git a/src/sip/mod.rs b/src/sip/mod.rs index 7aef9eb1..bef0e1b0 100644 --- a/src/sip/mod.rs +++ b/src/sip/mod.rs @@ -31,8 +31,8 @@ pub mod param { pub mod typed { pub use super::headers::typed::{ Allow, Authorization, CSeq, Contact, From, HistoryInfo, HistoryInfoEntry, Identity, - ProxyAuthenticate, ProxyAuthorization, RecordRoute, Route, To, Tokenize, TypedHeader, Via, - WwwAuthenticate, + ProxyAuthenticate, ProxyAuthorization, RecordRoute, Route, ServiceRoute, To, Tokenize, + TypedHeader, Via, WwwAuthenticate, }; pub mod tokenizers { pub use crate::sip::headers::typed::tokenizers::{AuthTokenizer, CseqTokenizer};