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] 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};