Skip to content
Merged
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
131 changes: 130 additions & 1 deletion crates/asterisk-sip/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -380,12 +380,27 @@ impl SipSession {

/// Build a 200 OK response (for UAS).
pub fn build_200_ok(&self) -> Option<SipMessage> {
self.build_200_ok_with_signaling_hostport(&self.signaling_hostport())
}

fn build_200_ok_with_signaling_hostport(&self, signaling_hostport: &str) -> Option<SipMessage> {
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!("<sip:asterisk@{}>", self.signaling_hostport());
let contact = format!("<sip:asterisk@{signaling_hostport}>");
response.headers.push(SipHeader {
name: header_names::CONTACT.to_string(),
value: contact,
Expand Down Expand Up @@ -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: <sip:edge.example.invalid:5060;r2=on;lr;ftag=fixture;did=fixture;nat=yes>\r\n\
Record-Route: <sip:edge.example.invalid;transport=tcp;r2=on;lr;ftag=fixture;did=fixture;nat=yes>\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: <sip:caller@10.0.0.9>;tag=fixture\r\n\
To: <sip:service@pbx.example.invalid>\r\n\
Call-ID: fixture-call\r\n\
CSeq: 100000001 INVITE\r\n\
Contact: <sip:caller@10.0.0.9>\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("<sip:asterisk@203.0.113.99:45070>"),
"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,
"<sip:198.51.100.20:5060;r2=on;lr;nat=yes>",
);
routed_invite.add_header(
header_names::RECORD_ROUTE,
"<sip:198.51.100.21:5060;transport=tcp;r2=on;lr;nat=yes>",
);
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![
"<sip:198.51.100.21:5060;transport=tcp;r2=on;lr;nat=yes>",
"<sip:198.51.100.20:5060;r2=on;lr;nat=yes>",
],
"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: <sip:caller@198.51.100.7>;tag=direct\r\n\
To: <sip:service@192.0.2.10>\r\n\
Call-ID: direct-call\r\n\
CSeq: 1 INVITE\r\n\
Contact: <sip:caller@198.51.100.7>\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();
Expand Down
62 changes: 58 additions & 4 deletions tests/e2e-trunk/chime_caller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<host>\[[^]]+\]|[^:;>]+)(?::(?P<port>\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)
Expand Down Expand Up @@ -274,6 +303,7 @@ def main():

totag = None
ok_body = None
ok_message = None
deadline = time.time() + 8
while time.time() < deadline:
try:
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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: <sip:chime@{args.src_ip}>;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()
Expand Down Expand Up @@ -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: <sip:chime@{args.src_ip}>;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:
Expand Down
10 changes: 6 additions & 4 deletions tests/e2e-trunk/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -155,17 +155,19 @@ 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" <<EOF
[transport-udp]
type = transport
protocol = udp
bind = $RK_IP:$RK_SIP
external_signaling_address = $RK_IP
external_signaling_port = $RK_SIP

[chime-trunk]
type = endpoint
Expand Down
Loading