diff --git a/src/dialog/invitation.rs b/src/dialog/invitation.rs index 80891310..7fc773b6 100644 --- a/src/dialog/invitation.rs +++ b/src/dialog/invitation.rs @@ -145,6 +145,13 @@ pub struct InviteOption { /// generated. Reuse the same value on transferred calls (REFER/Replaces) /// to keep the session identifiable across dialogs. pub session_id: Option, + /// RFC 3608: preloaded route set for this out-of-dialog request. Each entry + /// is emitted as a `Route` header, in order, ahead of the caller-supplied + /// headers. Typically obtained from + /// [`Registration::preloaded_route_set`](crate::dialog::registration::Registration::preloaded_route_set) + /// so the request follows the path an IMS S-CSCF advertised at + /// registration. Empty by default, leaving existing behaviour unchanged. + pub route_set: Vec, } pub struct DialogGuard { @@ -364,6 +371,15 @@ impl DialogLayer { call_id, ); + // RFC 3608: preload the Service-Route set learned at registration as + // Route headers, in order, so this out-of-dialog request traverses the + // proxies the registrar (e.g. an IMS S-CSCF) requires. Plain push, not + // unique_push, because a route set legitimately has several Route + // headers. + for route in &opt.route_set { + request.headers.push(route.clone().into()); + } + let contact = if let Some(ref addr) = transport_addr { let mut uri = opt.contact.clone(); uri.host_with_port = addr.addr.clone(); diff --git a/src/dialog/registration.rs b/src/dialog/registration.rs index d7b0d261..8b7e400c 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,34 @@ 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 + } + + /// Build the preloaded `Route` set for out-of-dialog requests from the + /// learned Service-Route set (RFC 3608 §5.2). + /// + /// The returned routes are in the order the registrar sent them and can be + /// assigned to [`InviteOption::route_set`] (or otherwise pushed as `Route` + /// headers) so an initial request such as an INVITE traverses the + /// registrar's required path. Returns an empty vector when the last + /// registration carried no Service-Route. + /// + /// [`InviteOption::route_set`]: crate::dialog::invitation::InviteOption::route_set + pub fn preloaded_route_set(&self) -> Vec { + self.service_route.iter().cloned().map(Into::into).collect() + } + /// Get the registration expiration time /// /// Returns the expiration time in seconds for the current registration. @@ -551,9 +586,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/dialog/tests/test_dialog_layer.rs b/src/dialog/tests/test_dialog_layer.rs index f7c805b6..b1b15adf 100644 --- a/src/dialog/tests/test_dialog_layer.rs +++ b/src/dialog/tests/test_dialog_layer.rs @@ -597,3 +597,78 @@ async fn test_make_invite_request_with_tls_transport_uses_sips_scheme() -> crate Ok(()) } + +#[tokio::test] +async fn test_make_invite_request_preloads_service_route() -> crate::Result<()> { + let token = CancellationToken::new(); + let tl = TransportLayer::new(token.child_token()); + + // A UDP address so get_via has something to work with. + let udp_conn = UdpConnection::create_connection("127.0.0.1:0".parse()?, None, None).await?; + tl.add_transport(crate::transport::SipConnection::Udp(udp_conn)); + + let endpoint = EndpointBuilder::new() + .with_user_agent("rsipstack-test") + .with_transport_layer(tl) + .build(); + let dialog_layer = DialogLayer::new(endpoint.inner.clone()); + + // Two-hop route set as an IMS S-CSCF would advertise via Service-Route. + let route_set = vec![ + crate::sip::typed::Route::parse("")?, + crate::sip::typed::Route::parse("")?, + ]; + + let opt = crate::dialog::invitation::InviteOption { + caller: crate::sip::Uri::try_from("sip:alice@example.com")?, + callee: crate::sip::Uri::try_from("sip:bob@example.com")?, + contact: crate::sip::Uri::try_from("sip:alice@192.168.1.10:5060")?, + route_set, + ..Default::default() + }; + + let request = dialog_layer.make_invite_request(&opt)?; + + // Both hops must be preloaded as Route headers, in the advertised order. + let routes = request.typed_route_headers()?; + assert_eq!( + routes.len(), + 2, + "both Service-Route hops should be preloaded" + ); + assert_eq!(routes[0].uri.to_string(), "sip:scscf.home.net;lr"); + assert_eq!(routes[1].uri.to_string(), "sip:pcscf.visited.net;lr"); + + Ok(()) +} + +#[tokio::test] +async fn test_make_invite_request_without_route_set_has_no_route() -> crate::Result<()> { + let token = CancellationToken::new(); + let tl = TransportLayer::new(token.child_token()); + let udp_conn = UdpConnection::create_connection("127.0.0.1:0".parse()?, None, None).await?; + tl.add_transport(crate::transport::SipConnection::Udp(udp_conn)); + + let endpoint = EndpointBuilder::new() + .with_user_agent("rsipstack-test") + .with_transport_layer(tl) + .build(); + let dialog_layer = DialogLayer::new(endpoint.inner.clone()); + + let opt = crate::dialog::invitation::InviteOption { + caller: crate::sip::Uri::try_from("sip:alice@example.com")?, + callee: crate::sip::Uri::try_from("sip:bob@example.com")?, + contact: crate::sip::Uri::try_from("sip:alice@192.168.1.10:5060")?, + ..Default::default() + }; + + let request = dialog_layer.make_invite_request(&opt)?; + + // Default (empty) route set must not add any Route header. + assert!( + request.route_headers().is_empty(), + "no Route header expected when route_set is empty" + ); + + Ok(()) +} 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..89e1ff4c --- /dev/null +++ b/src/sip/headers/typed/service_route.rs @@ -0,0 +1,183 @@ +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 std::convert::From for super::Route { + /// Convert a learned `Service-Route` entry into the `Route` header a user + /// agent preloads on subsequent requests (RFC 3608 §5.2). The name-addr is + /// carried over verbatim; only the header field name differs on the wire. + fn from(r: ServiceRoute) -> super::Route { + super::Route { + display_name: r.display_name, + uri: r.uri, + params: r.params, + } + } +} + +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); + } + + #[test] + fn service_route_into_route_preserves_name_addr() { + let sr = ServiceRoute::parse("").unwrap(); + let route: crate::sip::typed::Route = sr.clone().into(); + assert_eq!(route.uri, sr.uri); + assert_eq!(route.display_name, sr.display_name); + assert_eq!(route.params, sr.params); + assert!(route.has_lr()); + } +} 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};