diff --git a/crates/asterisk-sip/src/session/mod.rs b/crates/asterisk-sip/src/session/mod.rs index 1709e01..da0d420 100644 --- a/crates/asterisk-sip/src/session/mod.rs +++ b/crates/asterisk-sip/src/session/mod.rs @@ -380,12 +380,27 @@ impl SipSession { /// Build a 200 OK response (for UAS). pub fn build_200_ok(&self) -> Option { + self.build_200_ok_with_signaling_hostport(&self.signaling_hostport()) + } + + fn build_200_ok_with_signaling_hostport(&self, signaling_hostport: &str) -> Option { let invite = self.invite.as_ref()?; let mut response = invite.create_response(200, "OK").ok()?; + // RFC 3261 section 12.1.1: a response that establishes a dialog MUST + // copy every Record-Route value from the request, preserving order and + // all parameters. Without this the UAC builds an empty route set and + // its ACK may bypass the proxy path that delivered the INVITE. + for record_route in invite.get_headers(header_names::RECORD_ROUTE) { + response.headers.push(SipHeader { + name: header_names::RECORD_ROUTE.to_string(), + value: record_route.to_string(), + }); + } + // Add Contact (NAT-scoped toward the peer: external addr/port for a // peer outside local_net, internal otherwise — New-3). - let contact = format!("", self.signaling_hostport()); + let contact = format!(""); response.headers.push(SipHeader { name: header_names::CONTACT.to_string(), value: contact, @@ -925,6 +940,120 @@ mod cp1_tests { assert_eq!(s.in_dialog_next_hop(), Some(addr("10.0.0.9:5070"))); } + #[test] + fn inbound_twoxx_copies_production_shaped_route_set_and_public_contact() { + let invite = SipMessage::parse( + b"INVITE sip:service@pbx.example.invalid SIP/2.0\r\n\ + Record-Route: \r\n\ + Record-Route: \r\n\ + Via: SIP/2.0/UDP edge.example.invalid:5060;branch=z9hG4bKedge\r\n\ + Via: SIP/2.0/TCP 10.0.0.9;branch=z9hG4bKuac\r\n\ + From: ;tag=fixture\r\n\ + To: \r\n\ + Call-ID: fixture-call\r\n\ + CSeq: 100000001 INVITE\r\n\ + Contact: \r\n\ + Content-Length: 0\r\n\r\n", + ) + .unwrap(); + let session = SipSession::new_inbound( + &invite, + addr("192.0.2.10:45070"), + addr("198.51.100.7:5060"), + ) + .unwrap(); + + let ok = session + .build_200_ok_with_signaling_hostport("203.0.113.99:45070") + .unwrap(); + assert_eq!( + ok.get_header(header_names::CONTACT), + Some(""), + "the dialog Contact must advertise the public signaling target", + ); + assert_eq!( + ok.get_headers(header_names::RECORD_ROUTE), + invite.get_headers(header_names::RECORD_ROUTE), + "the 2xx must preserve every Record-Route value, parameter, and order", + ); + } + + #[test] + fn preserved_route_set_sends_uac_ack_to_proxy_before_public_contact() { + let mut uac = SipSession::new_outbound( + addr("192.0.2.20:5060"), + addr("198.51.100.10:5060"), + ); + let mut routed_invite = + uac.build_invite("sip:service@198.51.100.10:5060"); + routed_invite.add_header( + header_names::RECORD_ROUTE, + "", + ); + routed_invite.add_header( + header_names::RECORD_ROUTE, + "", + ); + let uas = SipSession::new_inbound( + &routed_invite, + addr("10.0.0.10:45070"), + addr("198.51.100.20:5060"), + ) + .unwrap(); + let ok = uas + .build_200_ok_with_signaling_hostport("203.0.113.99:45070") + .unwrap(); + + uac.on_response(&ok); + let ack = uac.build_ack().unwrap(); + assert_eq!( + request_uri(&ack), + "sip:asterisk@203.0.113.99:45070", + "the public Contact is the ACK's remote target", + ); + assert_eq!( + ack.get_headers(header_names::ROUTE), + vec![ + "", + "", + ], + "the UAC must reverse the copied Record-Route set for its ACK", + ); + assert_eq!( + uac.in_dialog_next_hop(), + Some(addr("198.51.100.21:5060")), + "the ACK must reach the route-set proxy rather than going directly to Contact", + ); + } + + #[test] + fn inbound_twoxx_does_not_invent_record_route() { + let invite = SipMessage::parse( + b"INVITE sip:service@192.0.2.10 SIP/2.0\r\n\ + Via: SIP/2.0/UDP 198.51.100.7;branch=z9hG4bKdirect\r\n\ + From: ;tag=direct\r\n\ + To: \r\n\ + Call-ID: direct-call\r\n\ + CSeq: 1 INVITE\r\n\ + Contact: \r\n\ + Content-Length: 0\r\n\r\n", + ) + .unwrap(); + let session = SipSession::new_inbound( + &invite, + addr("192.0.2.10:45070"), + addr("198.51.100.7:5060"), + ) + .unwrap(); + let ok = session + .build_200_ok_with_signaling_hostport("203.0.113.99:45070") + .unwrap(); + assert!( + ok.get_headers(header_names::RECORD_ROUTE).is_empty(), + "a direct dialog must not gain a proxy route set", + ); + } + #[test] fn bye_follows_route_set_to_target_with_incremented_cseq() { let mut s = established_after_challenge(); diff --git a/tests/e2e-trunk/chime_caller.py b/tests/e2e-trunk/chime_caller.py index 9c68ffe..31d3b5b 100755 --- a/tests/e2e-trunk/chime_caller.py +++ b/tests/e2e-trunk/chime_caller.py @@ -207,6 +207,35 @@ def parse_sdp_pts(body): return pcmu, tev, rip, rport +def parse_contact_target(message): + """Return the 200 Contact URI and its IPv4 UDP target. + + The hermetic UAC deliberately derives its ACK target from the response + instead of sending to the configured INVITE destination by coincidence. + """ + contact = next( + (line.split(":", 1)[1].strip() for line in message.split("\r\n") + if line.lower().startswith("contact:")), + None, + ) + if contact is None: + return None, None + match = re.search( + r"(?i)<\s*(sip:[^@>]+@(?P\[[^]]+\]|[^:;>]+)(?::(?P\d+))?[^>]*)>", + contact, + ) + if match is None: + return None, None + uri = match.group(1) + host = match.group("host").strip("[]") + port = int(match.group("port") or 5060) + try: + target = socket.getaddrinfo(host, port, socket.AF_INET, socket.SOCK_DGRAM)[0][4] + except OSError: + return uri, None + return uri, target + + def main(): ap = argparse.ArgumentParser() ap.add_argument("--dst-ip", required=True) @@ -274,6 +303,7 @@ def main(): totag = None ok_body = None + ok_message = None deadline = time.time() + 8 while time.time() < deadline: try: @@ -303,6 +333,7 @@ def main(): if l.lower().startswith("to:") and "tag=" in l.lower(): totag = l.split("tag=", 1)[1].strip() ok_body = msg.split("\r\n\r\n", 1)[1] if "\r\n\r\n" in msg else "" + ok_message = msg break # 100/180/183 -> keep waiting if ok_body is None: @@ -314,6 +345,27 @@ def main(): ) return 2 + contact_uri, contact_target = parse_contact_target(ok_message) + if contact_target is None: + log("200 OK has no resolvable SIP Contact; ACK cannot reach the dialog target") + _write_result(args, {"error": "unreachable_contact", "voice_rx": 0}, exit_code=2) + return 2 + if args.invite_fixture: + expected_routes = [ + line.split(":", 1)[1].strip() + for line in invite.split("\r\n") + if line.lower().startswith("record-route:") + ] + actual_routes = [ + line.split(":", 1)[1].strip() + for line in ok_message.split("\r\n") + if line.lower().startswith("record-route:") + ] + if actual_routes != expected_routes: + log("200 OK did not preserve the INVITE Record-Route set") + _write_result(args, {"error": "record_route_mismatch", "voice_rx": 0}, exit_code=2) + return 2 + pcmu_pt, tev_pt, rip, rport = parse_sdp_pts(ok_body) if not rip or rip == "0.0.0.0": rip = args.dst_ip @@ -329,12 +381,14 @@ def main(): "transport=udp;lr;nat=yes>\r\n" ) ack = ( - f"ACK {ruri} SIP/2.0\r\n" + f"ACK {contact_uri} SIP/2.0\r\n" f"Via: SIP/2.0/UDP {args.src_ip}:{args.sip_port};branch=z9hG4bK{random.randint(0, 1 << 32):08x};rport\r\n" f"Max-Forwards: 70\r\n{ack_route}From: ;tag={fromtag}\r\n" f"To: <{ruri}>;tag={totag}\r\nCall-ID: {callid}\r\nCSeq: {cseq} ACK\r\nContent-Length: 0\r\n\r\n" ) - sipsock.sendto(ack.encode(), (args.dst_ip, args.dst_port)) + # This harness represents the last proxy hop: Route remains on the wire, + # while the datagram is delivered to the UAS's advertised Contact target. + sipsock.sendto(ack.encode(), contact_target) # ---- media threads ---- stop = threading.Event() @@ -418,12 +472,12 @@ def send_dtmf(digit): stop.set() time.sleep(0.3) bye = ( - f"BYE {ruri} SIP/2.0\r\n" + f"BYE {contact_uri} SIP/2.0\r\n" f"Via: SIP/2.0/UDP {args.src_ip}:{args.sip_port};branch=z9hG4bK{random.randint(0, 1 << 32):08x};rport\r\n" f"Max-Forwards: 70\r\nFrom: ;tag={fromtag}\r\n" f"To: <{ruri}>;tag={totag}\r\nCall-ID: {callid}\r\nCSeq: {cseq + 1} BYE\r\nContent-Length: 0\r\n\r\n" ) - sipsock.sendto(bye.encode(), (args.dst_ip, args.dst_port)) + sipsock.sendto(bye.encode(), contact_target) # write RX wav (8k mono s16) from the measurement window with wave.open(args.rx_wav, "wb") as w: diff --git a/tests/e2e-trunk/run.sh b/tests/e2e-trunk/run.sh index 304ce6e..ae75b65 100755 --- a/tests/e2e-trunk/run.sh +++ b/tests/e2e-trunk/run.sh @@ -155,10 +155,10 @@ read = all write = system EOF - # NOTE: no external_media_address / external_signaling_address — for a - # loopback test rustisk then advertises its own bind IP ($RK_IP) as the - # media address (avoids the external-address blackhole documented in the - # rustisk SDP path). Each endpoint is identified by a DISTINCT /32 so an + # Exercise the external-signaling Contact path with a reachable loopback + # address/port. external_media_address remains unset so the media answer + # uses its routed loopback address (avoids an artificial media blackhole). + # Each endpoint is identified by a DISTINCT /32 so an # inbound INVITE from the caller maps to chime-trunk and the mock's # REGISTER/traffic maps to qa-bridge with no ambiguity. cat >"$CONFIG_DIR/pjsip.conf" <