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
11 changes: 11 additions & 0 deletions crates/asterisk-core/src/pbx/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,17 @@ mod tests {
assert!(!ext.matches("10")); // too short
}

#[test]
fn test_e164_plus_requires_literal_extension() {
let digit_pattern = Extension::new("_X.");
assert!(digit_pattern.matches("19709601891"));
assert!(!digit_pattern.matches("+19709601891"));

let owned_did = Extension::new("+19709601891");
assert!(owned_did.matches("+19709601891"));
assert!(!owned_did.matches("+19709601892"));
}

#[test]
fn test_pattern_n() {
let ext = Extension::new("_NXX");
Expand Down
11 changes: 11 additions & 0 deletions tests/e2e-trunk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ harness (which drove a real Amazon Chime UAC into the real qa-sip Mumble bridge)

## What it asserts (receiver-side, like M0/M2)

**INGRESS POLICY — captured Chime INVITE**
- Loads a sanitized, shape-preserving 1,141-byte production-derived source
fixture with placeholder caller/resource IDs, then rewrites transaction IDs
and loopback media coordinates and recalculates the emitted Content-Length.
The replay retains the hostname R-URI, E.164 `+19709601891`, duplicate
Record-Route/Via, and `~` Contact alias.
- the owned DID from the allowlisted Chime source reaches the PIN gate.
- a neighboring E.164 DID gets 404, and the owned DID from an unallowlisted
packet source gets 403.

**CASE 1 — REJECTED (wrong PIN must not reach the bridge)**
- rustisk log contains `Verbose: PIN_GATE_RESULT=REJECTED`.
- the mock endpoint's `invite_count == 0` — rustisk **never** Dialed the bridge.
Expand Down Expand Up @@ -59,6 +69,7 @@ Everything binds fresh loopback IPs + high ports, asserted free by a preflight
| rustisk (SUT) | `127.0.0.60` | `35060` | `36000-36100` | `35038` |
| synthetic Chime caller | `127.0.0.61` | `35062` | `36200` | — |
| mock qa-bridge | `127.0.0.62` | `35064` | `36300` | — |
| untrusted negative control | `127.0.0.63` | `35066` | `36400` | — |

None of these touch the reserved live/arming ports (`:45070` live trunk,
`:25060/:25038/:25062/:21000-21100` M9 arming, `:15060/:15038` M0) — the
Expand Down
140 changes: 126 additions & 14 deletions tests/e2e-trunk/chime_caller.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import math
import os
import random
import re
import socket
import struct
import sys
Expand All @@ -28,6 +29,85 @@
import wave


CAPTURED_INVITE_SHA256 = "dd90ae2091827e471ad41173954c0b37b46a9e644f8642c27311437b0646758f"


def validate_wire_message(invite):
"""Fail closed if a transformed fixture is not a valid CRLF-framed SIP
message with an exact byte Content-Length."""
wire = invite.encode("ascii")
if b"\n" in wire.replace(b"\r\n", b""):
raise ValueError("transformed INVITE contains a bare LF")
try:
header_bytes, body_bytes = wire.split(b"\r\n\r\n", 1)
except ValueError as exc:
raise ValueError("transformed INVITE has no CRLF header terminator") from exc
lengths = [
line.split(b":", 1)[1].strip()
for line in header_bytes.split(b"\r\n")
if line.lower().startswith(b"content-length:")
]
if len(lengths) != 1:
raise ValueError("transformed INVITE must have exactly one Content-Length")
if int(lengths[0]) != len(body_bytes):
raise ValueError(
f"transformed INVITE Content-Length {int(lengths[0])} != body bytes {len(body_bytes)}"
)


def build_invite_from_capture(path, args, callid, fromtag, branch):
"""Load the shape-preserving sanitized Chime capture, then rewrite only
values that must be local/unique for a hermetic replay.

The hash is over the 1,141 CRLF fixture bytes. Keeping the production
hostname R-URI, duplicate Record-Route/Via grammar, and Ribbon Contact
alias in a fixture prevents the synthetic UAC from drifting back to an
unrealistically simple request. Caller and carrier resource identifiers
are placeholders and contain no production identity.
"""
import hashlib

with open(path, "rb") as f:
fixture = f.read().replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")
digest = hashlib.sha256(fixture).hexdigest()
if digest != CAPTURED_INVITE_SHA256:
raise ValueError(
f"captured INVITE fixture hash mismatch: {digest} != {CAPTURED_INVITE_SHA256}"
)

text = fixture.decode("ascii")
header_text, body = text.split("\r\n\r\n", 1)
if header_text.count("Record-Route:") != 2 or header_text.count("Via:") != 2:
raise ValueError("captured INVITE must retain both Record-Route and Via pairs")
if "alias=10.0.35.192~44933~2" not in header_text:
raise ValueError("captured INVITE lost the Ribbon/Kamailio Contact alias")

# Preserve the public signaling header shapes. Only the offered media
# endpoint must be reachable inside this loopback-only test.
body = body.replace("99.77.253.139", args.src_ip)
body = body.replace("m=audio 28948", f"m=audio {args.rtp_port}")
body = body.replace("a=rtcp:28949", f"a=rtcp:{args.rtp_port + 1}")

header_lines = header_text.split("\r\n")
top_via_rewritten = False
for index, line in enumerate(header_lines):
line = line.replace("+19709601891", args.exten)
line = line.replace("fixtureTag001", fromtag)
if line.startswith("Call-ID:"):
line = f"Call-ID: {callid}"
elif line.startswith("Content-Length:"):
line = f"Content-Length: {len(body.encode('ascii'))}"
elif line.startswith("Via: SIP/2.0/UDP 99.77.253.6:5060;") and not top_via_rewritten:
line = re.sub(r"(?<=;branch=)[^;]+", branch, line, count=1)
top_via_rewritten = True
header_lines[index] = line
header_text = "\r\n".join(header_lines)
invite = f"{header_text}\r\n\r\n{body}"
validate_wire_message(invite)
ruri = invite.split("\r\n", 1)[0].split()[1]
return invite, ruri


def log(*a):
print("[caller]", *a, file=sys.stderr, flush=True)

Expand Down Expand Up @@ -135,6 +215,8 @@ def main():
ap.add_argument("--sip-port", type=int, default=35062)
ap.add_argument("--rtp-port", type=int, default=36200)
ap.add_argument("--exten", default="9000")
ap.add_argument("--invite-fixture")
ap.add_argument("--expect-status", type=int, default=200)
ap.add_argument("--pin", required=True)
ap.add_argument("--tone", type=int, default=440) # A -> far-end tone
ap.add_argument("--detect", type=int, default=660) # far-end -> A tone we expect back
Expand Down Expand Up @@ -164,17 +246,29 @@ def main():
f"a=rtpmap:0 PCMU/8000\r\na=rtpmap:101 telephone-event/8000\r\n"
f"a=fmtp:101 0-15\r\na=ptime:20\r\na=sendrecv\r\n"
)
ruri = f"sip:{args.exten}@{args.dst_ip}:{args.dst_port}"
invite = (
f"INVITE {ruri} SIP/2.0\r\n"
f"Via: SIP/2.0/UDP {args.src_ip}:{args.sip_port};branch={branch};rport\r\n"
f"Max-Forwards: 70\r\n"
f"From: <sip:chime@{args.src_ip}>;tag={fromtag}\r\n"
f"To: <{ruri}>\r\n"
f"Call-ID: {callid}\r\nCSeq: {cseq} INVITE\r\n"
f"Contact: <sip:chime@{args.src_ip}:{args.sip_port}>\r\n"
f"Content-Type: application/sdp\r\nContent-Length: {len(sdp)}\r\n\r\n{sdp}"
)
if args.invite_fixture:
invite, ruri = build_invite_from_capture(
args.invite_fixture, args, callid, fromtag, branch
)
else:
ruri = f"sip:{args.exten}@{args.dst_ip}:{args.dst_port}"
invite = (
f"INVITE {ruri} SIP/2.0\r\n"
f"Via: SIP/2.0/UDP {args.src_ip}:{args.sip_port};branch={branch};rport\r\n"
f"Max-Forwards: 70\r\n"
f"From: <sip:chime@{args.src_ip}>;tag={fromtag}\r\n"
f"To: <{ruri}>\r\n"
f"Call-ID: {callid}\r\nCSeq: {cseq} INVITE\r\n"
f"Contact: <sip:chime@{args.src_ip}:{args.sip_port}>\r\n"
f"Content-Type: application/sdp\r\nContent-Length: {len(sdp)}\r\n\r\n{sdp}"
)
# The production capture uses a large carrier-generated CSeq rather than
# the synthetic default of 1. ACK must reuse it and BYE must advance it or
# rustisk correctly rejects those in-dialog requests.
cseq_match = re.search(r"(?m)^CSeq: (\d+) INVITE\r?$", invite)
if not cseq_match:
raise ValueError("INVITE fixture has no numeric INVITE CSeq")
cseq = int(cseq_match.group(1))
sipsock.sendto(invite.encode(), (args.dst_ip, args.dst_port))
log(f"INVITE -> {ruri}")

Expand All @@ -190,16 +284,34 @@ def main():
msg = data.decode("latin1")
first = msg.split("\r\n", 1)[0]
log("SIP <=", first)
if " 200 " in first:
parts = first.split()
status = int(parts[1]) if len(parts) >= 2 and parts[0] == "SIP/2.0" else None
if status is not None and status >= 200 and status != args.expect_status:
log(f"unexpected final status: wanted {args.expect_status}, got {status}")
_write_result(
args,
{"error": "unexpected_status", "sip_status": status, "voice_rx": 0},
exit_code=2,
)
return 2
if status == args.expect_status and status != 200:
log(f"expected SIP {status} received")
_write_result(args, {"sip_status": status, "voice_rx": 0}, exit_code=0)
return 0
if status == 200:
for l in msg.split("\r\n"):
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 ""
break
# 100/180/183 -> keep waiting
if ok_body is None:
log("no 200 OK; aborting")
_write_result(args, {"error": "no_200_ok", "voice_rx": 0}, exit_code=2)
log(f"no expected SIP {args.expect_status}; aborting")
_write_result(
args,
{"error": "no_expected_status", "expected_status": args.expect_status, "voice_rx": 0},
exit_code=2,
)
return 2

pcmu_pt, tev_pt, rip, rport = parse_sdp_pts(ok_body)
Expand Down
29 changes: 29 additions & 0 deletions tests/e2e-trunk/fixtures/chime-invite.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
INVITE sip:+19709601891@voice.murphytek.com:45070;transport=UDP SIP/2.0
Record-Route: <sip:99.77.253.6:5060;r2=on;lr;ftag=fixtureTag001;did=0000.0000;nat=yes>
Record-Route: <sip:99.77.253.6;transport=tcp;r2=on;lr;ftag=fixtureTag001;did=0000.0000;nat=yes>
Via: SIP/2.0/UDP 99.77.253.6:5060;branch=z9hG4bKaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.0;i=000
Via: SIP/2.0/UDP 10.0.35.192;received=10.0.35.192;rport=44933;branch=z9hG4bKfixtureBranch
Max-Forwards: 69
From: <sip:+15555550100@10.0.35.192:5060>;tag=fixtureTag001
To: <sip:+19709601891@voice.murphytek.com:45070>;transport=UDP
Call-ID: 00000000-0000-4000-8000-000000000000
CSeq: 100000001 INVITE
Contact: <sip:10.0.35.192:5060;alias=10.0.35.192~44933~2>
Content-Type: application/sdp
Content-Length: 250
X-Vine-ID: 00000000-0000-4000-8000-000000000000
X-VoiceConnector-ID: fixture000000000000000
User-Agent: VineProx-v2.3

v=0
o=Sonus_UAC 000000 00000 IN IP4 99.77.253.139
s=SIP Media Capabilities
t=0 0
m=audio 28948 RTP/AVP 0 101
c=IN IP4 99.77.253.139
a=rtpmap:0 PCMU/8000
a=rtpmap:101 telephone-event/8000
a=fmtp:101 0-15
a=sendrecv
a=rtcp:28949
a=ptime:20
20 changes: 4 additions & 16 deletions tests/e2e-trunk/integration/config-live/extensions.conf
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
; ============================================================================
; rustisk M9 dialplan — extensions.conf
; Chime INVITE (any DID) -> [pin-gate] -> PinGate -> on GRANTED,
; Owned Chime DID -> [pin-gate] -> PinGate -> on GRANTED,
; Dial(PJSIP/qa-bridge) two-way-bridges the caller into qa-sip via the
; registered pymumble bridge. On REJECTED/TIMEOUT/ERROR -> play + hangup.
;
Expand All @@ -13,21 +13,9 @@
; ============================================================================

[pin-gate]
; Catch-all: whatever DID Chime dials routes to the PIN gate. rustisk supports
; Asterisk-style pattern extensions (_X. = one+ digits). Explicit 9000 kept for
; the synthetic caller / manual origination.
exten => _X.,1,Answer()
same => n,PinGate(/mnt/data/herodevs-agents/m9-arm/prompts/pin-prompt.wav,20,7)
same => n,GotoIf($["${PINGATESTATUS}" = "GRANTED"]?10:20)
same => 10,Verbose(0,PIN_GATE_RESULT=GRANTED)
same => n,Playback(/mnt/data/herodevs-agents/m9-arm/prompts/granted.wav)
same => n,Dial(PJSIP/qa-bridge,30)
same => n,Hangup()
same => 20,Verbose(0,PIN_GATE_RESULT=REJECTED)
same => n,Playback(/mnt/data/herodevs-agents/m9-arm/prompts/rejected.wav)
same => n,Hangup()

exten => 9000,1,Answer()
; Least privilege: accept only the owned Chime DID. Chime sends canonical E.164
; with a literal leading '+', which `_X.` does not (and must not) match.
exten => +19709601891,1,Answer()
same => n,PinGate(/mnt/data/herodevs-agents/m9-arm/prompts/pin-prompt.wav,20,7)
same => n,GotoIf($["${PINGATESTATUS}" = "GRANTED"]?10:20)
same => 10,Verbose(0,PIN_GATE_RESULT=GRANTED)
Expand Down
Loading
Loading