From 9495edbd3e4ad3c2762a043807ef44fc2397ec50 Mon Sep 17 00:00:00 2001 From: rizwan alam <12540033+rizwan3659@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:20:55 +0530 Subject: [PATCH 1/2] feat: add typed Service-Route (RFC 3608) header Model Service-Route as a typed header alongside Route/Record-Route: untyped storage, the Header enum wiring and parser dispatch, a typed `ServiceRoute` (display-name, uri, params) reusing the shared name-addr parser and list splitter, and `service_route_headers()` / `typed_service_route_headers()` accessors on HeadersExt. Registration now records the Service-Route set from a REGISTER 200 OK and exposes it via `Registration::service_route()`. This is the route set an IMS S-CSCF advertises for originating requests. Applying it to outgoing requests (preloading Route headers) is intentionally left to the caller, so this change adds no behavioural change to existing dialogs. Adds unit tests for single/multi-value parsing, display round-trip, the typed<->header conversion, and end-to-end extraction from a 200 OK. --- src/dialog/registration.rs | 29 +++++ src/sip/headers/mod.rs | 7 ++ src/sip/headers/typed/mod.rs | 2 + src/sip/headers/typed/service_route.rs | 160 +++++++++++++++++++++++++ src/sip/headers/untyped.rs | 1 + src/sip/message.rs | 47 ++++++++ src/sip/mod.rs | 4 +- 7 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 src/sip/headers/typed/service_route.rs 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}; From b585246a277b01e9dcac1cedd6319451f9f5b7b3 Mon Sep 17 00:00:00 2001 From: rizwan alam <12540033+rizwan3659@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:28:56 +0530 Subject: [PATCH 2/2] feat: preload the Service-Route set as Route headers on out-of-dialog requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the Service-Route parsing added previously. Adds a `ServiceRoute -> Route` conversion (RFC 3608 §5.2) and `Registration::preloaded_route_set()`, which turns the learned Service-Route set into the Route set a UA preloads on later requests. `InviteOption` gains a `route_set` field; `make_invite_request` emits those entries as Route headers, in order, ahead of caller-supplied headers. The field defaults to empty, so requests that do not set it are unchanged. Tests cover the conversion, ordered Route preloading in `make_invite_request`, and the empty-route-set no-op. --- src/dialog/invitation.rs | 16 ++++++ src/dialog/registration.rs | 14 +++++ src/dialog/tests/test_dialog_layer.rs | 75 ++++++++++++++++++++++++++ src/sip/headers/typed/service_route.rs | 23 ++++++++ 4 files changed, 128 insertions(+) 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 dc9604d7..8b7e400c 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()); + } }