From 752d51afea0b5c455bc481daff85125631e71992 Mon Sep 17 00:00:00 2001 From: mycollablab <41121792+mycollablab@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:37:41 +0000 Subject: [PATCH] fix(dialog): keep a caller-built INVITE Contact on stream transports make_invite_request replaced the Contact host and port with the local address of the outgoing stream connection, and switched the scheme to sips over TLS, even when the caller had built the Contact deliberately. Behind NAT the caller usually needs the address a registrar reported (the Contact in the REGISTER 200 OK), so overwriting it sent an unreachable Contact, and some registrars reject a sips Contact paired with a sip Request-URI. A Contact that already names its transport is now treated as caller-built and sent unchanged. Contacts without a transport parameter keep the existing behavior, so current callers are unaffected. Refs #209 Co-Authored-By: Claude Opus 5.5 --- src/dialog/invitation.rs | 17 +++++++++- src/dialog/tests/test_dialog_layer.rs | 48 +++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/dialog/invitation.rs b/src/dialog/invitation.rs index 45179d5e..c8125dc8 100644 --- a/src/dialog/invitation.rs +++ b/src/dialog/invitation.rs @@ -136,6 +136,11 @@ pub struct InviteOption { pub destination: Option, pub content_type: Option, pub offer: Option>, + /// Local Contact. On stream transports its host and port are normally + /// replaced with the outgoing connection's local address (and the scheme + /// becomes `sips` over TLS). A Contact that already carries a `transport` + /// parameter is treated as deliberately built by the caller, for example + /// from the address a registrar reported behind NAT, and is sent unchanged. pub contact: crate::sip::Uri, pub credential: Option, pub headers: Option>, @@ -421,7 +426,17 @@ impl DialogLayer { request.headers.push(route.clone().into()); } - let contact = if let Some(ref addr) = transport_addr { + // A Contact that names its transport was built deliberately by the + // caller (e.g. with the public address learned from a REGISTER + // response behind NAT); keep it rather than rewriting it to the local + // socket address. + let caller_built_contact = opt + .contact + .params + .iter() + .any(|p| matches!(p, crate::sip::Param::Transport(_))); + let contact = if let Some(addr) = transport_addr.as_ref().filter(|_| !caller_built_contact) + { let mut uri = opt.contact.clone(); uri.host_with_port = addr.addr.clone(); if !uri diff --git a/src/dialog/tests/test_dialog_layer.rs b/src/dialog/tests/test_dialog_layer.rs index cec219bf..205c3fd2 100644 --- a/src/dialog/tests/test_dialog_layer.rs +++ b/src/dialog/tests/test_dialog_layer.rs @@ -639,6 +639,54 @@ async fn test_make_invite_request_with_tls_transport_uses_sips_scheme() -> crate Ok(()) } +#[cfg(feature = "rustls")] +#[tokio::test] +async fn test_make_invite_request_keeps_caller_built_contact() -> crate::Result<()> { + let token = CancellationToken::new(); + let tl = TransportLayer::new(token.child_token()); + + let tls_addr = "192.168.1.10:5061".parse()?; + let tls_listener = TlsListenerConnection::new(tls_addr, None, TlsConfig::default()).await?; + tl.add_transport(crate::transport::SipConnection::TlsListener(tls_listener)); + + let endpoint = EndpointBuilder::new() + .with_user_agent("rsipstack-test") + .with_transport_layer(tl) + .build(); + let dialog_layer = DialogLayer::new(endpoint.inner.clone()); + + let destination = SipAddr { + r#type: Some(Transport::Tls), + addr: HostWithPort { + host: crate::sip::Host::IpAddr(std::net::IpAddr::V4(std::net::Ipv4Addr::new( + 10, 0, 0, 1, + ))), + port: Some(5061.into()), + }, + }; + + // A Contact built from the address a registrar reported behind NAT. + let public_contact = crate::sip::Uri::try_from("sip:alice@203.0.113.7:40000;transport=tls")?; + 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: public_contact.clone(), + destination: Some(destination), + ..Default::default() + }; + + let request = dialog_layer.make_invite_request(&opt)?; + let contact = request.contact_header()?.typed()?; + + assert_eq!( + contact.uri, public_contact, + "a Contact with an explicit transport should be sent unchanged" + ); + assert_eq!(contact.uri.scheme, Some(crate::sip::Scheme::Sip)); + + Ok(()) +} + #[tokio::test] async fn test_make_invite_request_preloads_service_route() -> crate::Result<()> { let token = CancellationToken::new();