From bc871355ecce49fa62732c148527de3cbf27a884 Mon Sep 17 00:00:00 2001 From: Repin Agent Date: Sat, 18 Jul 2026 17:11:00 -0600 Subject: [PATCH] fix(e2e): route exact E.164 Chime DID --- crates/asterisk-core/src/pbx/mod.rs | 11 ++ tests/e2e-trunk/README.md | 11 ++ tests/e2e-trunk/chime_caller.py | 140 ++++++++++++++++-- tests/e2e-trunk/fixtures/chime-invite.txt | 29 ++++ .../integration/config-live/extensions.conf | 20 +-- tests/e2e-trunk/run.sh | 59 +++++--- tests/e2e-trunk/test_chime_fixture.py | 39 +++++ 7 files changed, 262 insertions(+), 47 deletions(-) create mode 100644 tests/e2e-trunk/fixtures/chime-invite.txt create mode 100644 tests/e2e-trunk/test_chime_fixture.py diff --git a/crates/asterisk-core/src/pbx/mod.rs b/crates/asterisk-core/src/pbx/mod.rs index c2f6308..653f918 100644 --- a/crates/asterisk-core/src/pbx/mod.rs +++ b/crates/asterisk-core/src/pbx/mod.rs @@ -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"); diff --git a/tests/e2e-trunk/README.md b/tests/e2e-trunk/README.md index 7fc84e2..76e2362 100644 --- a/tests/e2e-trunk/README.md +++ b/tests/e2e-trunk/README.md @@ -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. @@ -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 diff --git a/tests/e2e-trunk/chime_caller.py b/tests/e2e-trunk/chime_caller.py index 21eee07..31a6b45 100755 --- a/tests/e2e-trunk/chime_caller.py +++ b/tests/e2e-trunk/chime_caller.py @@ -20,6 +20,7 @@ import math import os import random +import re import socket import struct import sys @@ -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) @@ -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 @@ -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: ;tag={fromtag}\r\n" - f"To: <{ruri}>\r\n" - f"Call-ID: {callid}\r\nCSeq: {cseq} INVITE\r\n" - f"Contact: \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: ;tag={fromtag}\r\n" + f"To: <{ruri}>\r\n" + f"Call-ID: {callid}\r\nCSeq: {cseq} INVITE\r\n" + f"Contact: \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}") @@ -190,7 +284,21 @@ 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() @@ -198,8 +306,12 @@ def main(): 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) diff --git a/tests/e2e-trunk/fixtures/chime-invite.txt b/tests/e2e-trunk/fixtures/chime-invite.txt new file mode 100644 index 0000000..85e0b9d --- /dev/null +++ b/tests/e2e-trunk/fixtures/chime-invite.txt @@ -0,0 +1,29 @@ +INVITE sip:+19709601891@voice.murphytek.com:45070;transport=UDP SIP/2.0 +Record-Route: +Record-Route: +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: ;tag=fixtureTag001 +To: ;transport=UDP +Call-ID: 00000000-0000-4000-8000-000000000000 +CSeq: 100000001 INVITE +Contact: +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 diff --git a/tests/e2e-trunk/integration/config-live/extensions.conf b/tests/e2e-trunk/integration/config-live/extensions.conf index b716e82..5956342 100644 --- a/tests/e2e-trunk/integration/config-live/extensions.conf +++ b/tests/e2e-trunk/integration/config-live/extensions.conf @@ -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. ; @@ -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) diff --git a/tests/e2e-trunk/run.sh b/tests/e2e-trunk/run.sh index e0371f7..304ce6e 100755 --- a/tests/e2e-trunk/run.sh +++ b/tests/e2e-trunk/run.sh @@ -39,6 +39,13 @@ RK_IP=127.0.0.60; RK_SIP=35060; RK_AMI=35038 RTP_START=36000; RTP_END=36100 CALLER_IP=127.0.0.61; CALLER_SIP=35062; CALLER_RTP=36200 MOCK_IP=127.0.0.62; MOCK_SIP=35064; MOCK_RTP=36300 +UNTRUSTED_IP=127.0.0.63; UNTRUSTED_SIP=35066; UNTRUSTED_RTP=36400 + +# The one production DID this least-privilege trunk accepts. Chime presents it +# in canonical E.164 form, including the leading '+'. +CHIME_DID="+19709601891" +WRONG_CHIME_DID="+19709601892" +CHIME_INVITE_FIXTURE="$HARNESS_DIR/fixtures/chime-invite.txt" # --- TEST-ONLY secrets (never the real PIN / qa-bridge password) --- TEST_PIN="246813" @@ -88,19 +95,19 @@ preflight_ports() { udp="$(ss -Huan 2>/dev/null || true)" tcp="$(ss -Htan 2>/dev/null || true)" local p - for p in "$RK_SIP" "$CALLER_SIP" "$MOCK_SIP" "$CALLER_RTP" "$MOCK_RTP" "$RTP_START" "$RTP_END"; do + for p in "$RK_SIP" "$CALLER_SIP" "$MOCK_SIP" "$UNTRUSTED_SIP" "$CALLER_RTP" "$MOCK_RTP" "$UNTRUSTED_RTP" "$RTP_START" "$RTP_END"; do grep -qE "[.:]$p( |\$)|:$p " <<<"$udp" && busy+="udp/$p " done grep -qE "[.:]$RK_AMI( |\$)|:$RK_AMI " <<<"$tcp" && busy+="tcp/$RK_AMI " # Also assert we are NOT about to touch any known live/m0/m9 port. local forbidden="45070 25060 25038 25062 21000 21100 15060 15038" - for p in "$RK_SIP" "$RK_AMI" "$CALLER_SIP" "$MOCK_SIP" "$RTP_START" "$RTP_END" "$CALLER_RTP" "$MOCK_RTP"; do + for p in "$RK_SIP" "$RK_AMI" "$CALLER_SIP" "$MOCK_SIP" "$UNTRUSTED_SIP" "$RTP_START" "$RTP_END" "$CALLER_RTP" "$MOCK_RTP" "$UNTRUSTED_RTP"; do for f in $forbidden; do [[ "$p" == "$f" ]] && fail "port $p collides with a reserved live/m0/m9 port" done done [[ -z "$busy" ]] || fail "ports already in use: $busy (is a stray rustisk/mock running?)" - log "PREFLIGHT: OK — SIP $RK_SIP / AMI $RK_AMI / RTP $RTP_START-$RTP_END + caller $CALLER_SIP/$CALLER_RTP + mock $MOCK_SIP/$MOCK_RTP all free" + log "PREFLIGHT: OK — SIP $RK_SIP / AMI $RK_AMI / RTP $RTP_START-$RTP_END + caller $CALLER_SIP/$CALLER_RTP + mock $MOCK_SIP/$MOCK_RTP + untrusted $UNTRUSTED_SIP/$UNTRUSTED_RTP all free" } ensure_binary() { @@ -197,22 +204,11 @@ match = $MOCK_IP/32 EOF cat >"$CONFIG_DIR/extensions.conf" < PIN gate. On GRANTED, Dial(PJSIP/qa-bridge) bridges +; Owned Chime DID -> PIN gate. On GRANTED, Dial(PJSIP/qa-bridge) bridges ; the caller into the registered mock endpoint. On REJECTED, play + hang up ; WITHOUT dialing (the bridge must never be INVITEd). [pin-gate] -exten => _X.,1,Answer() - same => n,PinGate($PROMPT_DIR/pin-prompt.wav,20,7) - same => n,GotoIf(\$["\${PINGATESTATUS}" = "GRANTED"]?10:20) - same => 10,Verbose(0,PIN_GATE_RESULT=GRANTED) - same => n,Playback($PROMPT_DIR/granted.wav) - same => n,Dial(PJSIP/$BRIDGE_USER,30) - same => n,Hangup() - same => 20,Verbose(0,PIN_GATE_RESULT=REJECTED) - same => n,Playback($PROMPT_DIR/rejected.wav) - same => n,Hangup() - -exten => 9000,1,Answer() +exten => $CHIME_DID,1,Answer() same => n,PinGate($PROMPT_DIR/pin-prompt.wav,20,7) same => n,GotoIf(\$["\${PINGATESTATUS}" = "GRANTED"]?10:20) same => 10,Verbose(0,PIN_GATE_RESULT=GRANTED) @@ -270,12 +266,27 @@ run_caller() { python3 "$HARNESS_DIR/chime_caller.py" \ --dst-ip "$RK_IP" --dst-port "$RK_SIP" \ --src-ip "$CALLER_IP" --sip-port "$CALLER_SIP" --rtp-port "$CALLER_RTP" \ - --exten 9000 --pin "$pin" \ + --exten "$CHIME_DID" --invite-fixture "$CHIME_INVITE_FIXTURE" --pin "$pin" \ --tone "$CALLER_TONE" --detect "$BRIDGE_TONE" \ --call-secs "$callsecs" --rx-wav "$rxwav" --result-file "$result" \ 2>"$RUNTIME_DIR/$(basename "$result").caller.log" || true } +run_status_probe() { + local src_ip="$1" sip_port="$2" rtp_port="$3" exten="$4" status="$5" result="$6" + rm -f "$result" + python3 "$HARNESS_DIR/chime_caller.py" \ + --dst-ip "$RK_IP" --dst-port "$RK_SIP" \ + --src-ip "$src_ip" --sip-port "$sip_port" --rtp-port "$rtp_port" \ + --exten "$exten" --invite-fixture "$CHIME_INVITE_FIXTURE" \ + --expect-status "$status" --pin "$WRONG_PIN" \ + --call-secs 0.1 --rx-wav "$RUNTIME_DIR/probe-unused.wav" --result-file "$result" \ + 2>"$result.caller.log" || true + assert_json "$result" "captured INVITE expected SIP $status" \ + "d.get('sip_status') == $status" >/dev/null \ + || fail "captured INVITE probe from $src_ip to $exten did not receive SIP $status" +} + # JSON assertion helper: python3 predicate over a result file. Exits non-zero # (with a message) on failure so `set -e` propagates. assert_json() { @@ -306,11 +317,25 @@ PY main() { require python3; require ss; require stdbuf log "=== rustisk e2e-trunk (hermetic) ===" + python3 "$HARNESS_DIR/test_chime_fixture.py" preflight_ports ensure_binary write_configs start_rustisk + # ---------- ingress policy: exact DID + source ACL ---------- + # These are the two negative controls for the sanitized production-derived + # Chime request shape. A neighboring E.164 DID must not enter the gate, + # and even the owned DID must be rejected when its source is not identified. + log "" + log "--- INGRESS POLICY: exact E.164 DID + allowlisted Chime source ---" + run_status_probe "$CALLER_IP" "$CALLER_SIP" "$CALLER_RTP" \ + "$WRONG_CHIME_DID" 404 "$RUNTIME_DIR/wrong-did.json" + log "WRONG DID: PASS ($WRONG_CHIME_DID -> SIP 404)" + run_status_probe "$UNTRUSTED_IP" "$UNTRUSTED_SIP" "$UNTRUSTED_RTP" \ + "$CHIME_DID" 403 "$RUNTIME_DIR/untrusted-source.json" + log "UNTRUSTED SOURCE: PASS ($UNTRUSTED_IP -> SIP 403)" + # ---------- CASE 1: REJECTED (wrong PIN -> NO Dial) ---------- log "" log "--- CASE 1: REJECTED (wrong PIN must NOT reach the bridge) ---" diff --git a/tests/e2e-trunk/test_chime_fixture.py b/tests/e2e-trunk/test_chime_fixture.py new file mode 100644 index 0000000..60becec --- /dev/null +++ b/tests/e2e-trunk/test_chime_fixture.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Focused regression for the production-derived Chime INVITE transformer.""" +import pathlib +import types +import unittest + +import chime_caller + + +class ChimeFixtureTest(unittest.TestCase): + def test_shape_and_wire_framing_survive_hermetic_rewrite(self): + fixture = pathlib.Path(__file__).with_name("fixtures") / "chime-invite.txt" + args = types.SimpleNamespace( + src_ip="127.0.0.61", + rtp_port=36200, + exten="+19709601891", + ) + invite, ruri = chime_caller.build_invite_from_capture( + fixture, + args, + "fixture-call@example.invalid", + "fixture-from-tag", + "z9hG4bKfixture", + ) + + chime_caller.validate_wire_message(invite) + self.assertEqual( + ruri, "sip:+19709601891@voice.murphytek.com:45070;transport=UDP" + ) + self.assertEqual(invite.count("\r\nRecord-Route:"), 2) + self.assertEqual(invite.count("\r\nVia:"), 2) + self.assertIn("alias=10.0.35.192~44933~2", invite) + self.assertIn("Call-ID: fixture-call@example.invalid\r\n", invite) + self.assertIn("c=IN IP4 127.0.0.61", invite) + self.assertIn("m=audio 36200 RTP/AVP 0 101", invite) + + +if __name__ == "__main__": + unittest.main()