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 7c2a4223..03a4e361 100644 --- a/src/dialog/registration.rs +++ b/src/dialog/registration.rs @@ -230,6 +230,20 @@ impl Registration { &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. 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/typed/service_route.rs b/src/sip/headers/typed/service_route.rs index 5f406a50..89e1ff4c 100644 --- a/src/sip/headers/typed/service_route.rs +++ b/src/sip/headers/typed/service_route.rs @@ -115,6 +115,19 @@ impl std::convert::From for Header { } } +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)] @@ -157,4 +170,14 @@ mod tests { 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()); + } }