Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/dialog/invitation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<crate::sip::typed::Route>,
}

pub struct DialogGuard {
Expand Down Expand Up @@ -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();
Expand Down
43 changes: 43 additions & 0 deletions src/dialog/registration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::net::SocketAddr>,
/// 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<crate::sip::typed::ServiceRoute>,
}

impl Registration {
Expand Down Expand Up @@ -173,6 +179,7 @@ impl Registration {
public_address: None,
call_id,
outbound_proxy: None,
service_route: Vec::new(),
}
}

Expand Down Expand Up @@ -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<crate::sip::typed::Route> {
self.service_route.iter().cloned().map(Into::into).collect()
}

/// Get the registration expiration time
///
/// Returns the expiration time in seconds for the current registration.
Expand Down Expand Up @@ -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);
Expand Down
75 changes: 75 additions & 0 deletions src/dialog/tests/test_dialog_layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("<sip:scscf.home.net;lr>")?,
crate::sip::typed::Route::parse("<sip:pcscf.visited.net;lr>")?,
];

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(())
}
7 changes: 7 additions & 0 deletions src/sip/headers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ pub enum Header {
Require(Require),
RetryAfter(RetryAfter),
Route(Route),
ServiceRoute(ServiceRoute),
Server(Server),
Subject(Subject),
SubscriptionState(SubscriptionState),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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))
Expand Down
2 changes: 2 additions & 0 deletions src/sip/headers/typed/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Loading