From a4e0a48094fd61ab8186b3d3cb25dd426f89932f Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Wed, 25 Mar 2026 16:46:08 +0100 Subject: [PATCH 1/9] Refactor protocol structure --- .pre-commit-config.yaml | 12 - CONTRIBUTING.md | 5 +- README.md | 40 +- docs/cookbook.md | 8 +- docs/{calls.md => sessions.md} | 4 +- docs/sip.md | 6 + mkdocs.yml | 2 +- tests/sip/test_messages.py | 9 + tests/sip/test_protocol.py | 2366 -------------------------------- tests/sip/test_types.py | 9 + tests/test_audio.py | 16 - tests/test_main.py | 875 ------------ tests/test_rtp.py | 50 +- voip/__main__.py | 298 ++-- voip/audio.py | 22 +- voip/rtp.py | 31 +- voip/sdp/messages.py | 2 +- voip/sdp/types.py | 14 +- voip/sip/__init__.py | 3 + voip/sip/exceptions.py | 9 + voip/sip/messages.py | 133 +- voip/sip/protocol.py | 1027 +++----------- voip/sip/transactions.py | 590 ++++++++ voip/sip/types.py | 59 +- voip/stun.py | 8 +- voip/types.py | 24 + 26 files changed, 1200 insertions(+), 4422 deletions(-) rename docs/{calls.md => sessions.md} (62%) delete mode 100644 tests/sip/test_protocol.py delete mode 100644 tests/test_main.py create mode 100644 voip/sip/exceptions.py create mode 100644 voip/sip/transactions.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7098f8d..b5afb2c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -48,18 +48,6 @@ repos: rev: v0.21.0 hooks: - id: yamlfmt - - repo: local - hooks: - - id: ty - name: ty - description: An extremely fast Python type checker and language server, written in Rust. - entry: ty check - language: python - additional_dependencies: [ty, '.[cli]'] - types_or: [python, pyi, jupyter] - require_serial: true - exclude: ^tests\/.*\.py$ ci: skip: - no-commit-to-branch - - ty diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ed83d62..2338831 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,10 +13,13 @@ To run the tests, use the following command: uv run pytest ``` +Avoid mocking in your tests and instead use real dependencies to ensure that your tests are as close to real-world scenarios as possible. +You may only mock transports to avoid network IO or to mimic network counterparts. + Before your first commit, ensure that the pre-commit hooks are installed by running: ```bash -uv pre-commit install +uvx prek install ``` ## Testing with Extra Dependencies diff --git a/README.md b/README.md index 8c86910..546fd87 100644 --- a/README.md +++ b/README.md @@ -25,20 +25,20 @@ Async VoIP Python library for the AI age. Answer calls and transcribe them live from the terminal: ```console -SIP_PASSWORD=******** uvx 'voip[cli]' sip sips:alice@sip.example.com transcribe +uvx 'voip[cli]' sip sips:alice:********@sip.example.com transcribe ``` A simple echo server can be started with: ````console ```console -SIP_PASSWORD=******** uvx 'voip[cli]' sip sips:alice@sip.example.com echo +uvx 'voip[cli]' sip sips:alice:********@sip.example.com echo ```` You can also talk to a local agent (needs [Ollama]): ```console -SIP_PASSWORD=******** uvx 'voip[cli]' sip sips:alice@sip.example.com agent +uvx 'voip[cli]' sip sips:alice:********@sip.example.com agent ``` ### Python API @@ -52,29 +52,43 @@ Pass it as `call_class` when answering an incoming call: ```python import asyncio +import dataclasses import ssl from voip.ai import TranscribeCall from voip.sip.protocol import SIP +from voip.sip.types import SipUri +from voip.sip.transactions import InviteTransaction +from voip.rtp import RealtimeTransportProtocol +from faster_whisper import WhisperModel -class MyCall(TranscribeCall): - def transcription_received(self, text: str) -> None: - print(f"[{self.caller}] {text}") +@dataclasses.dataclass(kw_only=True, slots=True) +class TranscribingCall(TranscribeCall): + def transcription_received(self, text) -> None: + print(text) -class MySession(SIP): - def call_received(self, request) -> None: - asyncio.create_task(self.answer(request=request, call_class=MyCall)) +class TranscribeInviteTransaction(InviteTransaction): + def invite_received(self, request) -> None: + self.ringing() + self.answer( + call_class=TranscribingCall, + stt_model=WhisperModel("kyutai/stt-1b-en_fr-trfs", device="cuda"), + ) async def main(): loop = asyncio.get_running_loop() + _, rtp_protocol = await loop.create_datagram_endpoint( + RealtimeTransportProtocol, + local_addr=("0.0.0.0", 0), + ) ssl_context = ssl.create_default_context() await loop.create_connection( - lambda: MySession( - aor="sips:alice@example.com", - username="alice", - password="secret", # noqa: S106 + lambda: SIP( + rtp=rtp_protocol, + aor=SipUri.parse("sips:alice:********@example.com"), + transaction_class=TranscribeInviteTransaction, ), host="sip.example.com", port=5061, diff --git a/docs/cookbook.md b/docs/cookbook.md index acfb71c..ef508e3 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -154,14 +154,14 @@ class GreetingCall(AudioCall): ## Low-Level RTP Packet Handling -For protocols other than audio, subclass \[`RTPCall`\][voip.rtp.RTPCall] -directly and override \[`packet_received`\]\[voip.rtp.RTPCall.packet_received\]: +For protocols other than audio, subclass \[`Session`\][voip.rtp.Session] +directly and override \[`packet_received`\]\[voip.rtp.Session.packet_received\]: ```python -from voip.rtp import RTPCall, RTPPacket +from voip.rtp import Session, RTPPacket -class EchoCall(RTPCall): +class EchoCall(Session): def packet_received(self, packet: RTPPacket, addr: tuple[str, int]) -> None: # Echo every packet straight back to the sender. self.send_packet(packet, addr) diff --git a/docs/calls.md b/docs/sessions.md similarity index 62% rename from docs/calls.md rename to docs/sessions.md index 8bb99e8..9cc1ef3 100644 --- a/docs/calls.md +++ b/docs/sessions.md @@ -1,6 +1,6 @@ -# Call legs +# Multimedia Dessions / Call Leg Handlers -[RTPCall][voip.rtp.RTPCall] is the base class for all call leg handlers. +[Session][voip.rtp.Session] is the base class for all call leg handlers. ## Audio Handling diff --git a/docs/sip.md b/docs/sip.md index 6f1e7d6..d2565fa 100644 --- a/docs/sip.md +++ b/docs/sip.md @@ -1,3 +1,9 @@ # Session Initiation Protocol (SIP) ::: voip.sip + +## Transactions + +::: voip.sip.transactions.InviteTransaction + +::: voip.sip.transactions.RegistrationTransaction diff --git a/mkdocs.yml b/mkdocs.yml index 25d9246..13b2a5f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,7 +11,7 @@ nav: - Feature Roadmap: feature_roadmap.md - RFC Implementation Status: rfc_status.md - API Reference: - - Calls: calls.md + - Sessions: sessions.md - Codecs: codecs.md - RTP: rtp.md - SDP: sdp.md diff --git a/tests/sip/test_messages.py b/tests/sip/test_messages.py index b70b185..9dc41ed 100644 --- a/tests/sip/test_messages.py +++ b/tests/sip/test_messages.py @@ -159,6 +159,15 @@ def test_request__bytes__with_sdp_body(self): assert b"Content-Length:" in serialized assert b"v=0" in serialized + def test_via_branch__with_branch(self): + """via_branch returns the branch parameter from the Via header.""" + request = Request( + method="INVITE", + uri="sip:bob@biloxi.com", + headers={"Via": "SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bKabc123"}, + ) + assert request.branch == "z9hG4bKabc123" + class TestResponse: def test_response__bytes(self): diff --git a/tests/sip/test_protocol.py b/tests/sip/test_protocol.py deleted file mode 100644 index c63f9ff..0000000 --- a/tests/sip/test_protocol.py +++ /dev/null @@ -1,2366 +0,0 @@ -"""Tests for the SIP asyncio protocol handler.""" - -import asyncio -import dataclasses -import datetime -import hashlib -import ipaddress -import re -from unittest.mock import MagicMock, patch - -import pytest -from voip.rtp import RealtimeTransportProtocol, RTPCall -from voip.sdp.messages import SessionDescription -from voip.sdp.types import Timing -from voip.sip.messages import Message, Request, Response -from voip.sip.protocol import ( - SIP, - RegistrationError, - SessionInitiationProtocol, - _format_host, - _mask_caller, -) -from voip.sip.types import CallerID, DigestAlgorithm, SIPStatus - -INVITE_WITH_PCMA = ( - b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" - b"Via: SIP/2.0/UDP pc33.atlanta.com\r\n" - b"From: sip:alice@atlanta.com\r\n" - b"To: sip:bob@biloxi.com\r\n" - b"Call-ID: test-call-id-1\r\n" - b"CSeq: 1 INVITE\r\n" - b"Content-Type: application/sdp\r\n" - b"Content-Length: 72\r\n" - b"\r\n" - b"v=0\r\n" - b"o=- 0 0 IN IP4 192.0.2.1\r\n" - b"s=-\r\n" - b"c=IN IP4 192.0.2.1\r\n" - b"t=0 0\r\n" - b"m=audio 49170 RTP/AVP 8 0\r\n" -) - -INVITE_WITH_PCMU_ONLY = ( - b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" - b"Via: SIP/2.0/UDP pc33.atlanta.com\r\n" - b"From: sip:alice@atlanta.com\r\n" - b"To: sip:bob@biloxi.com\r\n" - b"Call-ID: test-call-id-2\r\n" - b"CSeq: 1 INVITE\r\n" - b"Content-Type: application/sdp\r\n" - b"Content-Length: 68\r\n" - b"\r\n" - b"v=0\r\n" - b"o=- 0 0 IN IP4 192.0.2.1\r\n" - b"s=-\r\n" - b"c=IN IP4 192.0.2.1\r\n" - b"t=0 0\r\n" - b"m=audio 49170 RTP/AVP 0\r\n" -) - -INVITE_WITH_UNKNOWN_CODEC = ( - b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" - b"Via: SIP/2.0/UDP pc33.atlanta.com\r\n" - b"From: sip:alice@atlanta.com\r\n" - b"To: sip:bob@biloxi.com\r\n" - b"Call-ID: test-call-id-3\r\n" - b"CSeq: 1 INVITE\r\n" - b"Content-Type: application/sdp\r\n" - b"Content-Length: 100\r\n" - b"\r\n" - b"v=0\r\n" - b"o=- 0 0 IN IP4 192.0.2.1\r\n" - b"s=-\r\n" - b"c=IN IP4 192.0.2.1\r\n" - b"t=0 0\r\n" - b"m=audio 49170 RTP/AVP 126\r\n" - b"a=rtpmap:126 telephone-event/8000\r\n" -) - -INVITE_NO_SDP = ( - b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" - b"Via: SIP/2.0/UDP pc33.atlanta.com\r\n" - b"From: sip:alice@atlanta.com\r\n" - b"To: sip:bob@biloxi.com\r\n" - b"Call-ID: test-call-id-4\r\n" - b"CSeq: 1 INVITE\r\n" - b"\r\n" -) - - -class FakeTransport: - """Fake TCP/TLS transport for testing.""" - - def __init__( - self, - local_addr: tuple[str, int] = ("127.0.0.1", 5061), - peer_addr: tuple[str, int] = ("192.0.2.1", 5061), - ): - self._local_addr = local_addr - self._peer_addr = peer_addr - self.sent: list[bytes] = [] - - def write(self, data: bytes) -> None: - self.sent.append(data) - - def get_extra_info(self, key, default=None): - if key == "sockname": - return self._local_addr - if key == "peername": - return self._peer_addr - if key == "ssl_object": - return object() # non-None signals TLS - return default - - -class FakeProtocol(SessionInitiationProtocol): - """Fake SIP protocol that captures sent messages.""" - - def __init__(self): - super().__init__(outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com") - self.connection_made(FakeTransport()) - self._sent_responses: list[tuple[Response, None]] = [] - - def send(self, message): - if isinstance(message, Response): - self._sent_responses.append((message, None)) - super().send(message) - - -class ConcreteProtocol(SessionInitiationProtocol): - """Concrete subclass for testing that records received messages.""" - - def __init__(self): - super().__init__(outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com") - self.requests = [] - self.responses = [] - - def request_received(self, request, addr): - self.requests.append((request, addr)) - - def response_received(self, response, addr): - self.responses.append((response, addr)) - - -class TestMaskCaller: - def test_full_from_header_with_display_name(self): - """Mask all but the last 4 chars of a 12-digit display name (8 asterisks).""" - header = '"08001234567" ;tag=abc123' - assert _mask_caller(header) == "*******4567" - - def test_bare_sip_uri(self): - """Extract user part from a bare SIP URI and mask all but the last 4 chars.""" - assert _mask_caller("sip:alice@example.com") == "*lice" - - def test_short_caller__no_masking(self): - """Identifiers with 4 or fewer characters are returned as-is.""" - assert _mask_caller("") == "bob" - - def test_strips_tag_parameter(self): - """The tag= and any subsequent parameters are stripped before masking.""" - header = '"08001234567" ;tag=xyz;other=1' - result = _mask_caller(header) - assert "tag" not in result - assert result.endswith("4567") - - def test_angle_bracket_uri_without_display_name(self): - """Parse style without a display name.""" - assert _mask_caller("") == "*lice" - - -class TestCallerID: - def test_str__returns_raw_header(self): - """str() returns the original SIP header value unchanged.""" - raw = '"08001234567" ;tag=abc' - assert str(CallerID(raw)) == raw - - def test_repr__masks_display_name_and_includes_domain(self): - """repr() shows last 4 chars of display name and the carrier domain.""" - caller = CallerID('"08001234567" ;tag=abc') - assert repr(caller) == "*******4567@telefonica.de" - - def test_repr__bare_sip_uri(self): - """repr() masks the user part of a bare SIP URI and includes the domain.""" - assert repr(CallerID("sip:alice@example.com")) == "*lice@example.com" - - def test_repr__angle_bracket_uri(self): - """repr() handles without a display name.""" - assert repr(CallerID("")) == "bob@biloxi.com" - - def test_user__phone_number(self): - """User property extracts the SIP user part from a phone number URI.""" - caller = CallerID('"08001234567" ') - assert caller.user == "08001234567" - - def test_user__bare_uri(self): - """User property extracts the username from a bare SIP URI.""" - assert CallerID("sip:alice@example.com").user == "alice" - - def test_host__returns_carrier_domain(self): - """Host property returns the domain part of the SIP URI.""" - assert CallerID("sip:alice@carrier.example.com").host == "carrier.example.com" - - def test_display_name__quoted(self): - """display_name returns the quoted display name.""" - assert CallerID('"Alice" ').display_name == "Alice" - - def test_display_name__absent(self): - """display_name is None when no display name is present.""" - assert CallerID("sip:alice@example.com").display_name is None - - def test_tag__present(self): - """Tag property extracts the tag parameter value.""" - assert CallerID("sip:alice@example.com;tag=abc123").tag == "abc123" - - def test_tag__absent(self): - """Tag is None when no tag parameter is present.""" - assert CallerID("sip:alice@example.com").tag is None - - def test_is_str_subclass(self): - """CallerID is a str, so it passes isinstance checks transparently.""" - assert isinstance(CallerID("sip:alice@example.com"), str) - - def test_equality_with_plain_string(self): - """CallerID compares equal to a plain str with the same value.""" - assert CallerID("sip:alice@example.com") == "sip:alice@example.com" - - def test_data_received__request(self): - """Dispatch a received SIP request to request_received via TCP stream.""" - protocol = ConcreteProtocol() - transport = FakeTransport(peer_addr=("192.0.2.1", 5060)) - protocol.transport = transport - data = ( - b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" - b"Via: SIP/2.0/TLS pc33.atlanta.com\r\n" - b"\r\n" - ) - protocol.data_received(data) - assert len(protocol.requests) == 1 - request, called_addr = protocol.requests[0] - assert isinstance(request, Request) - assert request.method == "INVITE" - assert called_addr == ("192.0.2.1", 5060) - - def test_data_received__response(self): - """Dispatch a received SIP response to response_received via TCP stream.""" - protocol = ConcreteProtocol() - transport = FakeTransport(peer_addr=("192.0.2.1", 5060)) - protocol.transport = transport - data = b"SIP/2.0 200 OK\r\nVia: SIP/2.0/TLS pc33.atlanta.com\r\n\r\n" - protocol.data_received(data) - assert len(protocol.responses) == 1 - response, called_addr = protocol.responses[0] - assert isinstance(response, Response) - assert response.status_code == 200 - assert called_addr == ("192.0.2.1", 5060) - - def test_connection_lost__no_exception(self): - """Handle a clean connection close without raising.""" - protocol = SessionInitiationProtocol( - outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" - ) - protocol.connection_lost(None) # should not raise - - def test_connection_lost__with_exception(self): - """Log an exception on connection lost without re-raising.""" - protocol = SessionInitiationProtocol( - outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" - ) - protocol.connection_lost(Exception("Connection reset")) # should not raise - - -class TestWithToTag: - def test__with_to_tag__adds_tag(self): - """Append the To tag to the To header for a known Call-ID.""" - protocol = SessionInitiationProtocol( - outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" - ) - protocol._to_tags["call-1"] = "abc123" - result = protocol._with_to_tag({"To": "sip:bob@biloxi.com"}, "call-1") - assert result["To"] == "sip:bob@biloxi.com;tag=abc123" - - def test__with_to_tag__unknown_call_id(self): - """Leave the To header unchanged when the Call-ID has no stored tag.""" - protocol = SessionInitiationProtocol( - outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" - ) - result = protocol._with_to_tag({"To": "sip:bob@biloxi.com"}, "unknown") - assert result["To"] == "sip:bob@biloxi.com" - - def test__with_to_tag__missing_to_header(self): - """Return an empty To header when none is present and no tag exists.""" - protocol = SessionInitiationProtocol( - outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com" - ) - result = protocol._with_to_tag({}, "unknown") - assert result["To"] == "" - - -class TestRinging: - def test_ringing__includes_to_tag(self): - """Include the To tag in a 180 Ringing response (RFC 3261 §8.2.6.2).""" - protocol = FakeProtocol() - addr = ("192.0.2.1", 5060) - request = Request( - method="INVITE", - uri="sip:bob@biloxi.com", - headers={ - "Via": "SIP/2.0/UDP pc33.atlanta.com", - "From": "sip:alice@atlanta.com", - "To": "sip:bob@biloxi.com", - "Call-ID": "ring-test-1", - "CSeq": "1 INVITE", - }, - ) - protocol.request_received(request, addr) - protocol.ringing(request) - assert len(protocol._sent_responses) == 1 - response, _ = protocol._sent_responses[0] - assert response.status_code == 180 - to_header = response.headers.get("To", "") - assert ";tag=" in to_header - - def test_ringing__no_address(self, caplog): - """Log an error and send nothing when no pending INVITE is stored for the Call-ID.""" - protocol = FakeProtocol() - request = Request( - method="INVITE", - uri="sip:bob@biloxi.com", - headers={"Call-ID": "nonexistent"}, - ) - with caplog.at_level("ERROR"): - protocol.ringing(request) - assert not protocol._sent_responses - assert "No pending INVITE found" in caplog.text - - -class TestReject: - def test_reject__includes_to_tag(self): - """Include the To tag in a reject response (RFC 3261 §8.2.6.2).""" - protocol = FakeProtocol() - addr = ("192.0.2.1", 5060) - request = Request( - method="INVITE", - uri="sip:bob@biloxi.com", - headers={ - "Via": "SIP/2.0/UDP pc33.atlanta.com", - "From": "sip:alice@atlanta.com", - "To": "sip:bob@biloxi.com", - "Call-ID": "reject-test-1", - "CSeq": "1 INVITE", - }, - ) - protocol.request_received(request, addr) - protocol.reject(request) - assert len(protocol._sent_responses) == 1 - response, _ = protocol._sent_responses[0] - assert response.status_code == 486 - assert ";tag=" in response.headers.get("To", "") - - def test_reject__cleans_up_to_tag(self): - """Remove the To tag after rejecting (no lingering state).""" - protocol = FakeProtocol() - addr = ("192.0.2.1", 5060) - request = Request( - method="INVITE", - uri="sip:bob@biloxi.com", - headers={"Call-ID": "reject-cleanup-1", "To": "sip:bob@biloxi.com"}, - ) - protocol.request_received(request, addr) - assert "reject-cleanup-1" in protocol._to_tags - protocol.reject(request) - assert "reject-cleanup-1" not in protocol._to_tags - - def test_reject__no_address(self, caplog): - """Log an error and send nothing when no pending INVITE is stored for the Call-ID.""" - protocol = FakeProtocol() - request = Request( - method="INVITE", - uri="sip:bob@biloxi.com", - headers={"Call-ID": "nonexistent"}, - ) - with caplog.at_level("ERROR"): - protocol.reject(request) - assert not protocol._sent_responses - assert "No pending INVITE found" in caplog.text - - -class TestBYEHandler: - def test_bye__includes_to_tag_when_present(self): - """Include the stored To tag in a 200 OK BYE response.""" - protocol = FakeProtocol() - addr = ("192.0.2.1", 5060) - invite = Request( - method="INVITE", - uri="sip:bob@biloxi.com", - headers={ - "Via": "SIP/2.0/UDP pc33.atlanta.com", - "From": "sip:alice@atlanta.com", - "To": "sip:bob@biloxi.com", - "Call-ID": "bye-tag-test-1", - "CSeq": "1 INVITE", - }, - ) - protocol.request_received(invite, addr) - tag = protocol._to_tags["bye-tag-test-1"] - bye = Request( - method="BYE", - uri="sip:bob@biloxi.com", - headers={ - "Via": "SIP/2.0/UDP pc33.atlanta.com", - "From": "sip:alice@atlanta.com", - "To": "sip:bob@biloxi.com", - "Call-ID": "bye-tag-test-1", - "CSeq": "2 BYE", - }, - ) - protocol.request_received(bye, addr) - assert len(protocol._sent_responses) == 1 - response, _ = protocol._sent_responses[0] - assert response.status_code == 200 - assert f";tag={tag}" in response.headers.get("To", "") - - def test_bye__cleans_up_to_tag(self): - """Remove the To tag from state after processing BYE.""" - protocol = FakeProtocol() - addr = ("192.0.2.1", 5060) - invite = Request( - method="INVITE", - uri="sip:bob@biloxi.com", - headers={"Call-ID": "bye-cleanup-1", "To": "sip:bob@biloxi.com"}, - ) - protocol.request_received(invite, addr) - assert "bye-cleanup-1" in protocol._to_tags - bye = Request( - method="BYE", - uri="sip:bob@biloxi.com", - headers={"Call-ID": "bye-cleanup-1", "To": "sip:bob@biloxi.com"}, - ) - protocol.request_received(bye, addr) - assert "bye-cleanup-1" not in protocol._to_tags - - def test_bye__without_prior_to_tag(self): - """Send a 200 OK BYE response without tag when no To tag is stored.""" - protocol = FakeProtocol() - addr = ("192.0.2.1", 5060) - bye = Request( - method="BYE", - uri="sip:bob@biloxi.com", - headers={ - "To": "sip:bob@biloxi.com", - "Call-ID": "bye-no-tag-1", - "CSeq": "2 BYE", - }, - ) - protocol.request_received(bye, addr) - assert len(protocol._sent_responses) == 1 - response, _ = protocol._sent_responses[0] - assert response.status_code == 200 - assert ";tag=" not in response.headers.get("To", "") - - -class TestAnswer: - @pytest.fixture() - def fake_rtp_transport(self): - """Provide a fake RTP transport with a fixed local address.""" - return FakeTransport(("127.0.0.1", 12000)) - - def _make_invite( - self, - call_id: str, - sdp_body: SessionDescription | None = None, - *, - record_route: str | None = None, - ) -> Request: - """Build a minimal INVITE request.""" - headers = { - "Via": "SIP/2.0/UDP pc33.atlanta.com", - "From": "sip:alice@atlanta.com", - "To": "sip:bob@biloxi.com", - "Call-ID": call_id, - "CSeq": "1 INVITE", - } - if sdp_body: - headers["Content-Type"] = "application/sdp" - if record_route: - headers["Record-Route"] = record_route - return Request( - method="INVITE", uri="sip:bob@biloxi.com", headers=headers, body=sdp_body - ) - - async def _run_answer(self, protocol, invite, fake_rtp_transport): - """Run _answer coroutine with a pre-populated shared RTP mux.""" - loop = asyncio.get_running_loop() - # Pre-populate the shared RTP mux so _answer() skips socket creation. - mux = RealtimeTransportProtocol() - mux.public_address = loop.create_future() - mux.public_address.set_result((ipaddress.IPv4Address("127.0.0.1"), 12000)) - protocol._rtp_protocol = mux - protocol._rtp_transport = fake_rtp_transport - # Resolve the SIP protocol's own local address (for Contact header). - protocol.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - await protocol.answer(invite, call_class=_CodecAwareCall) - - @pytest.mark.asyncio - async def test_answer__selects_pcma_from_offer(self, fake_rtp_transport): - """Select PCMA (8) when the remote SDP offers both PCMA and PCMU.""" - protocol = FakeProtocol() - addr = ("192.0.2.1", 5060) - sdp_body = SessionDescription.parse( - b"v=0\r\n" - b"o=- 0 0 IN IP4 192.0.2.1\r\n" - b"s=-\r\n" - b"c=IN IP4 192.0.2.1\r\n" - b"t=0 0\r\n" - b"m=audio 49170 RTP/AVP 8 0\r\n" - ) - invite = self._make_invite("answer-pcma-1", sdp_body) - protocol.request_received(invite, addr) - await self._run_answer(protocol, invite, fake_rtp_transport) - assert protocol._sent_responses - response, _ = protocol._sent_responses[-1] - assert response.status_code == 200 - assert response.body.origin is not None - assert response.body.timings == [Timing(start_time=0, stop_time=0)] - assert response.body.media[0].fmt[0].payload_type == 8 - assert any(a.name == "sendrecv" for a in response.body.media[0].attributes) - assert response.body.media[0].fmt[0].encoding_name.startswith("PCMA") - - @pytest.mark.asyncio - async def test_answer__selects_pcmu_when_only_option(self, fake_rtp_transport): - """Select PCMU (0) when the remote SDP offers only PCMU.""" - protocol = FakeProtocol() - addr = ("192.0.2.1", 5060) - sdp_body = SessionDescription.parse( - b"v=0\r\n" - b"o=- 0 0 IN IP4 192.0.2.1\r\n" - b"s=-\r\n" - b"c=IN IP4 192.0.2.1\r\n" - b"t=0 0\r\n" - b"m=audio 49170 RTP/AVP 0\r\n" - ) - invite = self._make_invite("answer-pcmu-1", sdp_body) - protocol.request_received(invite, addr) - await self._run_answer(protocol, invite, fake_rtp_transport) - response, _ = protocol._sent_responses[-1] - assert response.body.media[0].fmt[0].payload_type == 0 # PCMU when only option - - @pytest.mark.asyncio - async def test_answer__selects_opus_from_offer(self, fake_rtp_transport): - """Select Opus (111) when the remote SDP offers Opus alongside PCMA and PCMU.""" - protocol = FakeProtocol() - addr = ("192.0.2.1", 5060) - sdp_body = SessionDescription.parse( - b"v=0\r\n" - b"o=- 0 0 IN IP4 192.0.2.1\r\n" - b"s=-\r\n" - b"c=IN IP4 192.0.2.1\r\n" - b"t=0 0\r\n" - b"m=audio 49170 RTP/AVP 111 8 0\r\n" - b"a=rtpmap:111 opus/48000/2\r\n" - ) - invite = self._make_invite("answer-opus-1", sdp_body) - protocol.request_received(invite, addr) - await self._run_answer(protocol, invite, fake_rtp_transport) - response, _ = protocol._sent_responses[-1] - assert response.body.media[0].fmt[0].payload_type == 111 - assert response.body.media[0].fmt[0].encoding_name.lower().startswith("opus") - - @pytest.mark.asyncio - async def test_answer__selects_g722_when_no_opus(self, fake_rtp_transport): - """Select G.722 (9) when the remote SDP offers G.722 and PCMA but not Opus.""" - protocol = FakeProtocol() - addr = ("192.0.2.1", 5060) - sdp_body = SessionDescription.parse( - b"v=0\r\n" - b"o=- 0 0 IN IP4 192.0.2.1\r\n" - b"s=-\r\n" - b"c=IN IP4 192.0.2.1\r\n" - b"t=0 0\r\n" - b"m=audio 49170 RTP/AVP 9 8\r\n" - ) - invite = self._make_invite("answer-g722-1", sdp_body) - protocol.request_received(invite, addr) - await self._run_answer(protocol, invite, fake_rtp_transport) - response, _ = protocol._sent_responses[-1] - assert response.body.media[0].fmt[0].payload_type == 9 # G.722 - - @pytest.mark.asyncio - async def test_answer__selects_opus_by_name_match_with_different_pt( - self, fake_rtp_transport - ): - """Select Opus by codec name match when remote uses a non-standard payload type.""" - protocol = FakeProtocol() - addr = ("192.0.2.1", 5060) - sdp_body = SessionDescription.parse( - b"v=0\r\n" - b"o=- 0 0 IN IP4 192.0.2.1\r\n" - b"s=-\r\n" - b"c=IN IP4 192.0.2.1\r\n" - b"t=0 0\r\n" - b"m=audio 49170 RTP/AVP 100 8\r\n" - b"a=rtpmap:100 opus/48000/2\r\n" - ) - invite = self._make_invite("answer-opus-name-1", sdp_body) - protocol.request_received(invite, addr) - await self._run_answer(protocol, invite, fake_rtp_transport) - response, _ = protocol._sent_responses[-1] - assert ( - response.body.media[0].fmt[0].payload_type == 100 - ) # Opus at non-standard PT - - @pytest.mark.asyncio - async def test_answer__unsupported_codec__raises(self, fake_rtp_transport): - """Raise NotImplementedError when the INVITE offers only unsupported codecs.""" - protocol = FakeProtocol() - addr = ("192.0.2.1", 5060) - sdp_body = SessionDescription.parse( - b"v=0\r\n" - b"o=- 0 0 IN IP4 192.0.2.1\r\n" - b"s=-\r\n" - b"c=IN IP4 192.0.2.1\r\n" - b"t=0 0\r\n" - b"m=audio 49170 RTP/AVP 126\r\n" - b"a=rtpmap:126 telephone-event/8000\r\n" - ) - invite = self._make_invite("answer-fallback-1", sdp_body) - protocol.request_received(invite, addr) - with pytest.raises(NotImplementedError): - await self._run_answer(protocol, invite, fake_rtp_transport) - - @pytest.mark.asyncio - async def test_answer__no_sdp_falls_back_to_default(self, fake_rtp_transport): - """Use payload type 0 (PCMU) with SAVP when the INVITE has no SDP body.""" - protocol = FakeProtocol() - addr = ("192.0.2.1", 5060) - invite = self._make_invite("answer-no-sdp-1") - protocol.request_received(invite, addr) - await self._run_answer(protocol, invite, fake_rtp_transport) - response, _ = protocol._sent_responses[-1] - assert response.body.media[0].fmt[0].payload_type == 0 # PCMU default - - @pytest.mark.asyncio - async def test_answer__includes_to_tag_in_200_ok(self, fake_rtp_transport): - """Include the locally generated To tag in the 200 OK response.""" - protocol = FakeProtocol() - addr = ("192.0.2.1", 5060) - sdp_body = SessionDescription.parse( - b"v=0\r\n" - b"o=- 0 0 IN IP4 192.0.2.1\r\n" - b"s=-\r\n" - b"c=IN IP4 192.0.2.1\r\n" - b"t=0 0\r\n" - b"m=audio 49170 RTP/AVP 8\r\n" - ) - invite = self._make_invite("answer-tag-1", sdp_body) - protocol.request_received(invite, addr) - stored_tag = protocol._to_tags["answer-tag-1"] - await self._run_answer(protocol, invite, fake_rtp_transport) - response, _ = protocol._sent_responses[-1] - assert f";tag={stored_tag}" in response.headers.get("To", "") - - @pytest.mark.asyncio - async def test_answer__no_address_logs_error(self, caplog): - """Log an error and return early when no pending INVITE is tracked for the Call-ID.""" - protocol = FakeProtocol() - invite = self._make_invite("no-addr-answer-1") - - with caplog.at_level("ERROR"): - await protocol.answer(invite, call_class=RTPCall) - assert "No pending INVITE found" in caplog.text - assert not protocol._sent_responses - - @pytest.mark.asyncio - async def test_answer__includes_contact_header(self, fake_rtp_transport): - """Include a Contact header with the local SIP address in 200 OK.""" - protocol = FakeProtocol() - addr = ("192.0.2.1", 5060) - invite = self._make_invite("answer-contact-1") - protocol.request_received(invite, addr) - await self._run_answer(protocol, invite, fake_rtp_transport) - response, _ = protocol._sent_responses[-1] - assert "Contact" in response.headers - # FakeProtocol AOR is sip:test@example.com → sip: Contact. - assert response.headers["Contact"].startswith(" Request: - """Return an INVITE request with default dialog headers and no SDP body.""" - return Request( - method="INVITE", - uri="sip:alice@atlanta.com", - headers={ - "Via": "SIP/2.0/UDP pc33.atlanta.com", - "To": "sip:alice@atlanta.com", - "From": "sip:bob@biloxi.com", - "Call-ID": "1234@pc33", - "CSeq": "1 INVITE", - **(headers or {}), - }, - ) - - -def make_register_session( - server_addr=("192.0.2.2", 5060), - aor="sip:alice@example.com", - username="alice", - password="secret", # noqa: S107 -) -> SessionInitiationProtocol: - """Return a SessionInitiationProtocol session without triggering connection_made.""" - return SessionInitiationProtocol( - outbound_proxy=server_addr, - aor=aor, - username=username, - password=password, - ) - - -def make_mock_transport( - host: str = "127.0.0.1", - port: int = 5061, - peer: tuple[str, int] = ("192.0.2.1", 5061), -): - """Return a MagicMock transport with get_extra_info configured for TLS.""" - from unittest.mock import MagicMock # noqa: PLC0415 - - transport = MagicMock() - - def _get_extra_info(key, default=None): - if key == "sockname": - return (host, port) - if key == "peername": - return peer - if key == "ssl_object": - return object() # non-None signals TLS - return default - - transport.get_extra_info.side_effect = _get_extra_info - return transport - - -@dataclasses.dataclass -class _MinimalCall(RTPCall): - """Minimal Call subclass for SIP protocol tests that require codec negotiation.""" - - @classmethod - def negotiate_codec(cls, remote_media): - from voip.sdp.types import MediaDescription, RTPPayloadFormat # noqa: PLC0415 - - return MediaDescription( - media="audio", - port=0, - proto="RTP/AVP", - fmt=[RTPPayloadFormat.from_pt(0)], - ) - - -@dataclasses.dataclass -class _CodecAwareCall(RTPCall): - """Call subclass that performs real codec negotiation for SIP answer tests. - - Mirrors AudioCall.PREFERRED_CODECS without importing voip.audio. - """ - - @classmethod - def negotiate_codec(cls, remote_media): - from voip.rtp import RTPPayloadType # noqa: PLC0415 - from voip.sdp.types import MediaDescription, RTPPayloadFormat # noqa: PLC0415 - - preferred = [ - RTPPayloadFormat( - payload_type=RTPPayloadType.OPUS, - encoding_name="opus", - sample_rate=48000, - channels=2, - ), - RTPPayloadFormat(payload_type=RTPPayloadType.G722), - RTPPayloadFormat(payload_type=RTPPayloadType.PCMA), - RTPPayloadFormat(payload_type=RTPPayloadType.PCMU), - ] - if not remote_media.fmt: - raise NotImplementedError("Remote SDP offer contains no audio formats") - remote_pts = {f.payload_type for f in remote_media.fmt} - for codec in preferred: - if codec.payload_type in remote_pts: - remote_fmt = remote_media.get_format(codec.payload_type) - chosen = ( - remote_fmt if remote_fmt and remote_fmt.encoding_name else codec - ) - return MediaDescription( - media="audio", port=0, proto="RTP/AVP", fmt=[chosen] - ) - for rfmt in remote_media.fmt: - if ( - rfmt.encoding_name - and rfmt.encoding_name.lower() - == (codec.encoding_name or "").lower() - ): - return MediaDescription( - media="audio", port=0, proto="RTP/AVP", fmt=[rfmt] - ) - raise NotImplementedError( - f"No supported codec in {[f.payload_type for f in remote_media.fmt]!r}" - ) - - -# --------------------------------------------------------------------------- -# Tests for the SIP protocol's call answering / rejection / transport layer -# --------------------------------------------------------------------------- - - -class TestSIPProtocol: - """Tests for SIP protocol connection, dispatching, answer and reject.""" - - class _CapturingSIP(SIP): - """SIP subclass that captures sent messages without monkey-patching slots.""" - - def __init__(self): - super().__init__( - outbound_proxy=("127.0.0.1", 5061), - aor="sip:test@example.com", - ) - self._sent: list[tuple] = [] - - def send(self, message): - self._sent.append((message, None)) - - async def test_connection_made__stores_transport(self): - """Store the transport when a connection is established.""" - protocol = SIP( - outbound_proxy=("127.0.0.1", 5061), - aor="sip:test@example.com", - rtp_stun_server_address=None, - ) - transport = make_mock_transport() - protocol.connection_made(transport) - assert protocol.transport is transport - - async def test_send__serializes_and_forwards_to_transport(self): - """Serialize the message and forward it to the underlying TCP transport.""" - protocol = SIP( - outbound_proxy=("127.0.0.1", 5061), - aor="sip:test@example.com", - rtp_stun_server_address=None, - ) - transport = make_mock_transport() - protocol.connection_made(transport) - transport.write.reset_mock() # clear any calls made during connection_made - response = Response(status_code=200, phrase="OK") - protocol.send(response) - protocol.transport.write.assert_called_once_with(bytes(response)) - - async def test_request_received__invite__tracks_pending_call(self): - """Dispatch an INVITE to call_received and track the Call-ID as pending.""" - received = [] - - class MySIP(SIP): - def call_received(self, request): - received.append(request) - - protocol = MySIP(outbound_proxy=("127.0.0.1", 5060), aor="sip:test@example.com") - protocol.connection_made(make_mock_transport()) - request = make_invite() - addr = ("192.0.2.1", 5060) - protocol.request_received(request, addr) - assert len(received) == 1 - assert received[0] is request - assert request.headers["Call-ID"] in protocol._pending_invites - - async def test_call_received__noop_by_default(self): - """call_received is a no-op in the base class.""" - protocol = SIP(outbound_proxy=("127.0.0.1", 5060), aor="sip:test@example.com") - protocol.connection_made(make_mock_transport()) - protocol.call_received(make_invite()) # must not raise - - async def test_answer__sends_200_ok(self): - """Send a 200 OK response with an SDP body when answering.""" - loop = asyncio.get_running_loop() - protocol = self._CapturingSIP() - protocol.transport = make_mock_transport() - protocol.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - mux = RealtimeTransportProtocol() - mux.public_address = loop.create_future() - mux.public_address.set_result((ipaddress.IPv4Address("127.0.0.1"), 12000)) - mock_rtp_transport = MagicMock() - mock_rtp_transport.get_extra_info.return_value = ("127.0.0.1", 12000) - protocol._rtp_protocol = mux - protocol._rtp_transport = mock_rtp_transport - request = make_invite() - protocol._pending_invites.add(request.headers["Call-ID"]) - await protocol.answer(request, call_class=RTPCall) - assert len(protocol._sent) == 1 - response, _ = protocol._sent[0] - assert response.status_code == 200 - assert response.phrase == "OK" - - async def test_answer__sdp_contains_opus_audio_line(self): - """Include an audio media line in the SDP body of the 200 OK.""" - loop = asyncio.get_running_loop() - protocol = self._CapturingSIP() - protocol.transport = make_mock_transport() - protocol.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - mux = RealtimeTransportProtocol() - mux.public_address = loop.create_future() - mux.public_address.set_result((ipaddress.IPv4Address("127.0.0.1"), 12000)) - mock_rtp_transport = MagicMock() - mock_rtp_transport.get_extra_info.return_value = ("127.0.0.1", 12000) - protocol._rtp_protocol = mux - protocol._rtp_transport = mock_rtp_transport - request = make_invite() - protocol._pending_invites.add(request.headers["Call-ID"]) - await protocol.answer(request, call_class=RTPCall) - response, _ = protocol._sent[0] - assert b"m=audio" in bytes(response.body) - assert b"RTP/SAVP 0" in bytes(response.body) - - async def _setup_answer_protocol(self): - """Return a _CapturingSIP with a live mux, ready to answer an INVITE.""" - loop = asyncio.get_running_loop() - protocol = self._CapturingSIP() - protocol.transport = make_mock_transport() - protocol.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - mux = RealtimeTransportProtocol() - mux.public_address = loop.create_future() - mux.public_address.set_result((ipaddress.IPv4Address("127.0.0.1"), 12000)) - mock_rtp_transport = MagicMock() - mock_rtp_transport.get_extra_info.return_value = ("127.0.0.1", 12000) - protocol._rtp_protocol = mux - protocol._rtp_transport = mock_rtp_transport - return protocol - - async def test_answer__rtp_avp_offer_returns_rtp_avp(self): - """When the remote offers RTP/AVP, respond with RTP/AVP (no SRTP).""" - protocol = await self._setup_answer_protocol() - - # Build an INVITE with a plain RTP/AVP offer (no crypto). - invite_bytes = ( - b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" - b"Via: SIP/2.0/UDP pc33.atlanta.com\r\n" - b"From: sip:alice@atlanta.com\r\n" - b"To: sip:bob@biloxi.com\r\n" - b"Call-ID: avp-offer-1\r\n" - b"CSeq: 1 INVITE\r\n" - b"Content-Type: application/sdp\r\n" - b"Content-Length: 68\r\n" - b"\r\n" - b"v=0\r\n" - b"o=- 0 0 IN IP4 192.0.2.1\r\n" - b"s=-\r\n" - b"c=IN IP4 192.0.2.1\r\n" - b"t=0 0\r\n" - b"m=audio 49170 RTP/AVP 0\r\n" - ) - from voip.sip.messages import Request # noqa: PLC0415 - - request = Request.parse(invite_bytes) - protocol._pending_invites.add(request.headers["Call-ID"]) - AudioCall = pytest.importorskip("voip.audio").AudioCall - - await protocol.answer(request, call_class=AudioCall) - response, _ = protocol._sent[0] - body = bytes(response.body) - assert b"RTP/AVP" in body - assert b"RTP/SAVP" not in body - assert b"crypto" not in body - - # The registered call handler must not have an SRTP session. - handler = next(iter(protocol._rtp_protocol.calls.values())) - assert handler.srtp is None - - async def test_answer__rtp_savp_offer_returns_rtp_savp(self): - """When the remote offers RTP/SAVP, respond with RTP/SAVP (with SRTP).""" - protocol = await self._setup_answer_protocol() - - invite_bytes = ( - b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" - b"Via: SIP/2.0/UDP pc33.atlanta.com\r\n" - b"From: sip:alice@atlanta.com\r\n" - b"To: sip:bob@biloxi.com\r\n" - b"Call-ID: savp-offer-1\r\n" - b"CSeq: 1 INVITE\r\n" - b"Content-Type: application/sdp\r\n" - b"Content-Length: 69\r\n" - b"\r\n" - b"v=0\r\n" - b"o=- 0 0 IN IP4 192.0.2.1\r\n" - b"s=-\r\n" - b"c=IN IP4 192.0.2.1\r\n" - b"t=0 0\r\n" - b"m=audio 49170 RTP/SAVP 0\r\n" - ) - from voip.sip.messages import Request # noqa: PLC0415 - - request = Request.parse(invite_bytes) - protocol._pending_invites.add(request.headers["Call-ID"]) - AudioCall = pytest.importorskip("voip.audio").AudioCall - - await protocol.answer(request, call_class=AudioCall) - response, _ = protocol._sent[0] - body = bytes(response.body) - assert b"RTP/SAVP" in body - assert b"crypto" in body - - handler = next(iter(protocol._rtp_protocol.calls.values())) - assert handler.srtp is not None - - async def test_answer__copies_dialog_headers(self): - """Copy Via, To, From, Call-ID, and CSeq headers into the 200 OK.""" - loop = asyncio.get_running_loop() - protocol = self._CapturingSIP() - protocol.transport = make_mock_transport() - protocol.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - mux = RealtimeTransportProtocol() - mux.public_address = loop.create_future() - mux.public_address.set_result((ipaddress.IPv4Address("127.0.0.1"), 12000)) - mock_rtp_transport = MagicMock() - mock_rtp_transport.get_extra_info.return_value = ("127.0.0.1", 12000) - protocol._rtp_protocol = mux - protocol._rtp_transport = mock_rtp_transport - request = make_invite() - protocol._pending_invites.add(request.headers["Call-ID"]) - await protocol.answer(request, call_class=RTPCall) - response, _ = protocol._sent[0] - assert response.headers["Via"] == "SIP/2.0/UDP pc33.atlanta.com" - assert response.headers["To"] == "sip:alice@atlanta.com" - assert response.headers["From"] == "sip:bob@biloxi.com" - assert response.headers["Call-ID"] == "1234@pc33" - assert response.headers["CSeq"] == "1 INVITE" - - async def test_answer__instantiates_call_class_with_caller(self): - """The call_class is instantiated with the caller from the From header.""" - created: list[str] = [] - - @dataclasses.dataclass - class MyCall(RTPCall): - def __post_init__(self) -> None: - created.append(str(self.caller)) - - loop = asyncio.get_running_loop() - protocol = self._CapturingSIP() - protocol.transport = make_mock_transport() - protocol.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - mux = RealtimeTransportProtocol() - mux.public_address = loop.create_future() - mux.public_address.set_result((ipaddress.IPv4Address("127.0.0.1"), 12000)) - mock_rtp_transport = MagicMock() - mock_rtp_transport.get_extra_info.return_value = ("127.0.0.1", 12000) - protocol._rtp_protocol = mux - protocol._rtp_transport = mock_rtp_transport - request = make_invite() - protocol._pending_invites.add(request.headers["Call-ID"]) - await protocol.answer(request, call_class=MyCall) - assert created == ["sip:bob@biloxi.com"] - - async def test_answer__rtp_receives_audio(self): - """Deliver SRTP payloads to the call handler's packet_received (decrypted).""" - from voip.rtp import RTPPacket # noqa: PLC0415 - - received_payloads: list[bytes] = [] - - @dataclasses.dataclass - class PacketCapture(RTPCall): - def packet_received(self, packet: RTPPacket, addr) -> None: - received_payloads.append(packet.payload) - - loop = asyncio.get_running_loop() - protocol = self._CapturingSIP() - protocol.transport = make_mock_transport() - protocol.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - mux = RealtimeTransportProtocol(stun_server_address=None) - rtp_transport, _ = await loop.create_datagram_endpoint( - lambda: mux, local_addr=("127.0.0.1", 0) - ) - protocol._rtp_protocol = mux - protocol._rtp_transport = rtp_transport - request = make_invite() - protocol._pending_invites.add(request.headers["Call-ID"]) - try: - await protocol.answer(request, call_class=PacketCapture) - response, _ = protocol._sent[0] - sdp_line = next( - line - for line in bytes(response.body).decode().splitlines() - if line.startswith("m=audio") - ) - rtp_port = int(sdp_line.split()[1]) - - send_transport, _ = await loop.create_datagram_endpoint( - asyncio.DatagramProtocol, - remote_addr=("127.0.0.1", rtp_port), - ) - # Get the SRTP session from the registered call handler and encrypt. - call_handler = mux.calls.get(None) - rtp_packet = b"\x80\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00audio" - srtp_packet = call_handler.srtp.encrypt(rtp_packet) - send_transport.sendto(srtp_packet) - await asyncio.sleep(0.05) - send_transport.close() - assert received_payloads == [b"audio"] - finally: - rtp_transport.close() - - async def test_answer__rtp_receives_multiple_packets(self): - """Call packet_received for each SRTP packet that arrives (decrypted).""" - from voip.rtp import RTPPacket # noqa: PLC0415 - - received_payloads: list[bytes] = [] - - @dataclasses.dataclass - class PacketCapture(RTPCall): - def packet_received(self, packet: RTPPacket, addr) -> None: - received_payloads.append(packet.payload) - - loop = asyncio.get_running_loop() - protocol = self._CapturingSIP() - protocol.transport = make_mock_transport() - protocol.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - mux = RealtimeTransportProtocol(stun_server_address=None) - rtp_transport, _ = await loop.create_datagram_endpoint( - lambda: mux, local_addr=("127.0.0.1", 0) - ) - protocol._rtp_protocol = mux - protocol._rtp_transport = rtp_transport - request = make_invite() - protocol._pending_invites.add(request.headers["Call-ID"]) - try: - await protocol.answer(request, call_class=PacketCapture) - response, _ = protocol._sent[0] - sdp_line = next( - line - for line in bytes(response.body).decode().splitlines() - if line.startswith("m=audio") - ) - rtp_port = int(sdp_line.split()[1]) - - send_transport, _ = await loop.create_datagram_endpoint( - asyncio.DatagramProtocol, - remote_addr=("127.0.0.1", rtp_port), - ) - # Get the SRTP session from the registered call handler and encrypt. - call_handler = mux.calls.get(None) - header = b"\x80\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00" - send_transport.sendto(call_handler.srtp.encrypt(header + b"chunk1")) - send_transport.sendto(call_handler.srtp.encrypt(header + b"chunk2")) - await asyncio.sleep(0.05) - send_transport.close() - assert received_payloads == [b"chunk1", b"chunk2"] - finally: - rtp_transport.close() - - async def test_answer__content_length_serialized(self): - """Content-Length is automatically included when the response is serialized.""" - loop = asyncio.get_running_loop() - protocol = self._CapturingSIP() - protocol.transport = make_mock_transport() - protocol.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - mux = RealtimeTransportProtocol() - mux.public_address = loop.create_future() - mux.public_address.set_result((ipaddress.IPv4Address("127.0.0.1"), 12000)) - mock_rtp_transport = MagicMock() - mock_rtp_transport.get_extra_info.return_value = ("127.0.0.1", 12000) - protocol._rtp_protocol = mux - protocol._rtp_transport = mock_rtp_transport - request = make_invite() - protocol._pending_invites.add(request.headers["Call-ID"]) - await protocol.answer(request, call_class=RTPCall) - response, _ = protocol._sent[0] - serialized = bytes(response) - parsed = Message.parse(serialized) - assert "Content-Length" in parsed.headers - - async def test_answer__reuses_shared_rtp_socket_for_second_call(self): - """A second _answer() reuses the same shared RTP socket (one port for all calls).""" - from voip.sdp.messages import SessionDescription # noqa: PLC0415 - from voip.sip.messages import Request as SIPRequest # noqa: PLC0415 - - loop = asyncio.get_running_loop() - protocol = self._CapturingSIP() - protocol.transport = make_mock_transport() - protocol.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - mux = RealtimeTransportProtocol(stun_server_address=None) - rtp_transport, _ = await loop.create_datagram_endpoint( - lambda: mux, local_addr=("127.0.0.1", 0) - ) - protocol._rtp_protocol = mux - protocol._rtp_transport = rtp_transport - - sdp_body1 = SessionDescription.parse( - b"v=0\r\no=- 0 0 IN IP4 1.2.3.4\r\ns=-\r\nc=IN IP4 1.2.3.4\r\nt=0 0\r\nm=audio 5000 RTP/AVP 0\r\n" - ) - invite1 = SIPRequest( - method="INVITE", - uri="sip:alice@atlanta.com", - headers={ - "Via": "SIP/2.0/UDP pc33.atlanta.com", - "To": "sip:alice@atlanta.com", - "From": "sip:bob@biloxi.com", - "Call-ID": "call-1@test", - "CSeq": "1 INVITE", - }, - body=sdp_body1, - ) - protocol._pending_invites.add("call-1@test") - await protocol.answer(invite1, call_class=_MinimalCall) - rtp_proto_1 = protocol._rtp_protocol - rtp_transport_1 = protocol._rtp_transport - - sdp_body2 = SessionDescription.parse( - b"v=0\r\no=- 0 0 IN IP4 5.6.7.8\r\ns=-\r\nc=IN IP4 5.6.7.8\r\nt=0 0\r\nm=audio 6000 RTP/AVP 0\r\n" - ) - invite2 = SIPRequest( - method="INVITE", - uri="sip:alice@atlanta.com", - headers={ - "Via": "SIP/2.0/UDP pc33.atlanta.com", - "To": "sip:alice@atlanta.com", - "From": "sip:charlie@biloxi.com", - "Call-ID": "call-2@test", - "CSeq": "1 INVITE", - }, - body=sdp_body2, - ) - protocol._pending_invites.add("call-2@test") - await protocol.answer(invite2, call_class=_MinimalCall) - - assert protocol._rtp_protocol is rtp_proto_1 - assert protocol._rtp_transport is rtp_transport_1 - assert ("1.2.3.4", 5000) in rtp_proto_1.calls - assert ("5.6.7.8", 6000) in rtp_proto_1.calls - - rtp_transport.close() - - async def test_answer__bye_unregisters_call_from_rtp_mux(self): - """BYE for an active call removes its handler from the shared RTP mux.""" - loop = asyncio.get_running_loop() - protocol = self._CapturingSIP() - protocol.transport = make_mock_transport() - protocol.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - mux = RealtimeTransportProtocol(stun_server_address=None) - rtp_transport, _ = await loop.create_datagram_endpoint( - lambda: mux, local_addr=("127.0.0.1", 0) - ) - protocol._rtp_protocol = mux - protocol._rtp_transport = rtp_transport - request = make_invite() - protocol._pending_invites.add(request.headers["Call-ID"]) - try: - await protocol.answer(request, call_class=RTPCall) - assert None in mux.calls - - bye = Request( - method="BYE", - uri="sip:alice@atlanta.com", - headers={ - "Via": "SIP/2.0/UDP pc33.atlanta.com", - "To": "sip:alice@atlanta.com", - "From": "sip:bob@biloxi.com", - "Call-ID": request.headers["Call-ID"], - "CSeq": "2 BYE", - }, - ) - protocol.request_received(bye, ("192.0.2.1", 5060)) - assert None not in mux.calls - finally: - rtp_transport.close() - - async def test_answer__logs_info(self, caplog): - """Log an info message when answering a call.""" - import logging - - loop = asyncio.get_running_loop() - protocol = self._CapturingSIP() - protocol.transport = make_mock_transport() - protocol.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - mux = RealtimeTransportProtocol() - mux.public_address = loop.create_future() - mux.public_address.set_result((ipaddress.IPv4Address("127.0.0.1"), 12000)) - mock_rtp_transport = MagicMock() - mock_rtp_transport.get_extra_info.return_value = ("127.0.0.1", 12000) - protocol._rtp_protocol = mux - protocol._rtp_transport = mock_rtp_transport - request = make_invite() - protocol._pending_invites.add(request.headers["Call-ID"]) - with caplog.at_level(logging.INFO, logger="voip.sip"): - await protocol.answer(request, call_class=RTPCall) - assert any("call_answered" in r.message for r in caplog.records) - - def test_reject__sends_busy_here_by_default(self): - """Send a 486 Busy Here response when no status code is given.""" - protocol = self._CapturingSIP() - request = make_invite() - protocol._pending_invites.add(request.headers["Call-ID"]) - protocol.reject(request) - assert len(protocol._sent) == 1 - response, _ = protocol._sent[0] - assert isinstance(response, Response) - assert response.status_code == 486 - assert response.phrase == "Busy Here" - - def test_reject__custom_status(self): - """Send the specified status code and reason.""" - protocol = self._CapturingSIP() - request = make_invite() - protocol._pending_invites.add(request.headers["Call-ID"]) - protocol.reject(request, status_code=SIPStatus.DECLINE) - response, _ = protocol._sent[0] - assert response.status_code == 603 - assert response.phrase == "Decline" - - def test_reject__copies_dialog_headers(self): - """Copy Via, To, From, Call-ID, and CSeq headers into the response.""" - protocol = self._CapturingSIP() - request = make_invite() - protocol._pending_invites.add(request.headers["Call-ID"]) - protocol.reject(request) - response, _ = protocol._sent[0] - assert response.headers["Via"] == "SIP/2.0/UDP pc33.atlanta.com" - assert response.headers["To"] == "sip:alice@atlanta.com" - assert response.headers["From"] == "sip:bob@biloxi.com" - assert response.headers["Call-ID"] == "1234@pc33" - assert response.headers["CSeq"] == "1 INVITE" - - @pytest.mark.parametrize("extra_header", ["X-Custom"]) - def test_reject__excludes_extra_headers(self, extra_header): - """Exclude non-dialog headers from the reject response.""" - protocol = self._CapturingSIP() - request = make_invite({extra_header: "value"}) - protocol._pending_invites.add(request.headers["Call-ID"]) - protocol.reject(request) - response, _ = protocol._sent[0] - assert extra_header not in response.headers - - def test_reject__logs_info(self, caplog): - """Log an info message when rejecting a call.""" - import logging - - with caplog.at_level(logging.INFO, logger="voip.sip"): - protocol = self._CapturingSIP() - request = make_invite() - protocol._pending_invites.add(request.headers["Call-ID"]) - protocol.reject(request) - assert any("call_rejected" in r.message for r in caplog.records) - - async def test_data_received__keepalive__sends_pong(self): - """Double-CRLF keepalive (RFC 5626 §4.4.1) is answered with a single-CRLF pong.""" - protocol = SIP( - outbound_proxy=("127.0.0.1", 5061), - aor="sip:test@example.com", - rtp_stun_server_address=None, - ) - transport = make_mock_transport() - protocol.connection_made(transport) - transport.write.reset_mock() - protocol.data_received(b"\r\n\r\n") - transport.write.assert_called_once_with(b"\r\n") - - async def test_request_received__unsupported_method__raises(self): - """Raise NotImplementedError for any non-INVITE SIP request method.""" - protocol = SIP(outbound_proxy=("127.0.0.1", 5060), aor="sip:test@example.com") - protocol.connection_made(make_mock_transport()) - request = Request(method="OPTIONS", uri="sip:alice@atlanta.com") - with pytest.raises(NotImplementedError, match="OPTIONS"): - protocol.request_received(request, ("192.0.2.1", 5060)) - - async def test_answer__via_call_received__schedules_answer(self): - """answer() is async; wrapping it in create_task from call_received works.""" - - class MySIP(self._CapturingSIP): - def call_received(self, request): - asyncio.create_task( - self.answer(request=request, call_class=_MinimalCall) - ) - - loop = asyncio.get_running_loop() - protocol = MySIP() - protocol.transport = make_mock_transport() - protocol.local_address = ("127.0.0.1", 5061) - mux = RealtimeTransportProtocol() - mux.public_address = loop.create_future() - mux.public_address.set_result(("127.0.0.1", 12000)) - mock_rtp_transport = MagicMock() - mock_rtp_transport.get_extra_info.return_value = ("127.0.0.1", 12000) - protocol._rtp_protocol = mux - protocol._rtp_transport = mock_rtp_transport - request = make_invite() - protocol._pending_invites.add(request.headers["Call-ID"]) - protocol.call_received(request) - - await asyncio.sleep(0.05) - assert len(protocol._sent) == 1 - - async def test_run_keepalive__sends_double_crlf(self): - """Keep-alive task sends double-CRLF after the configured interval.""" - protocol = SIP( - outbound_proxy=("127.0.0.1", 5061), - aor="sip:test@example.com", - rtp_stun_server_address=None, - keepalive_interval=datetime.timedelta(seconds=0.01), - ) - transport = make_mock_transport() - protocol.connection_made(transport) - transport.write.reset_mock() - await asyncio.sleep(0.05) - transport.write.assert_any_call(b"\r\n\r\n") - protocol._keepalive_task.cancel() - protocol._initialize_task.cancel() - - async def test_run_keepalive__stops_when_transport_cleared(self): - """Keep-alive loop exits cleanly when the transport is set to None.""" - protocol = SIP( - outbound_proxy=("127.0.0.1", 5061), - aor="sip:test@example.com", - rtp_stun_server_address=None, - keepalive_interval=datetime.timedelta(seconds=0.01), - ) - transport = make_mock_transport() - protocol.connection_made(transport) - transport.write.reset_mock() - protocol.transport = None - await asyncio.sleep(0.05) - transport.write.assert_not_called() - protocol._initialize_task.cancel() - - async def test_connection_lost__cancels_and_clears_keepalive_task(self): - """connection_lost cancels the keepalive task and clears _keepalive_task.""" - protocol = SIP( - outbound_proxy=("127.0.0.1", 5061), - aor="sip:test@example.com", - rtp_stun_server_address=None, - ) - - async def _long_running() -> None: - await asyncio.sleep(100) - - task = asyncio.get_running_loop().create_task(_long_running()) - protocol._keepalive_task = task - protocol.connection_lost(None) - assert protocol._keepalive_task is None - await asyncio.sleep(0) - assert task.done() - - async def test_connection_lost__sets_disconnected_event(self): - """connection_lost sets the disconnected_event.""" - protocol = SIP(outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com") - assert not protocol.disconnected_event.is_set() - protocol.connection_lost(None) - assert protocol.disconnected_event.is_set() - - async def test_disconnected_event__resolves_after_connection_lost(self): - """disconnected_event resolves once connection_lost is called.""" - protocol = SIP(outbound_proxy=("127.0.0.1", 5061), aor="sip:test@example.com") - - async def lose_connection() -> None: - await asyncio.sleep(0.01) - protocol.connection_lost(None) - - asyncio.create_task(lose_connection()) - await asyncio.wait_for(protocol.disconnected_event.wait(), timeout=1.0) - assert protocol.disconnected_event.is_set() - - -# --------------------------------------------------------------------------- -# Tests for SIP REGISTER / digest-auth / response handling -# --------------------------------------------------------------------------- - - -class TestFormatHost: - def test_format_host__ipv4__unchanged(self): - """IPv4 addresses are returned unchanged.""" - assert _format_host("192.0.2.1") == "192.0.2.1" - - def test_format_host__ipv6__bracketed(self): - """IPv6 addresses are wrapped in square brackets.""" - assert _format_host("2001:db8::1") == "[2001:db8::1]" - - def test_format_host__hostname__unchanged(self): - """Hostnames (non-IP strings) are returned unchanged.""" - assert _format_host("example.com") == "example.com" - - -class TestRegistration: - def test_registrar_uri__strips_user_from_aor(self): - """Derive registrar URI from AOR by stripping the user part.""" - p = make_register_session(aor="sip:alice@example.com") - assert p.registrar_uri == "sip:example.com" - - def test_registrar_uri__preserves_port(self): - """Preserve a non-default port in the derived registrar URI.""" - p = make_register_session(aor="sip:alice@example.com:5080") - assert p.registrar_uri == "sip:example.com:5080" - - def test_registrar_uri__preserves_sips_scheme(self): - """sips: AOR produces sips: registrar URI (RFC 3261 §10.2).""" - p = make_register_session(aor="sips:alice@example.com") - assert p.registrar_uri == "sips:example.com" - - def test_registrar_uri__preserves_sip_scheme(self): - """sip: AOR produces sip: registrar URI regardless of transport.""" - p = make_register_session(aor="sip:alice@example.com") - p._is_tls = True # TLS transport should not change the scheme - assert p.registrar_uri == "sip:example.com" - - async def test_connection_made__sends_register(self): - """Send a REGISTER request when the connection is established.""" - - class _SessionNoRTP(SessionInitiationProtocol): - async def _initialize(self): - # Skip real socket creation and just register directly. - await self.register() - - p = _SessionNoRTP( - outbound_proxy=("192.0.2.2", 5061), - aor="sip:alice@example.com", - username="alice", - password="secret", # noqa: S106 - ) - transport = make_mock_transport() - p.connection_made(transport) - await asyncio.sleep(0.05) - transport.write.assert_called() - (data,) = transport.write.call_args[0] - # sip: AOR → sip: registrar URI even over TLS. - assert b"REGISTER sip:example.com SIP/2.0" in data - - async def test_initialize__ipv6_local_address_binds_rtp_to_double_colon(self): - """When the SIP connection is IPv6, RTP is bound to '::' instead of '0.0.0.0'.""" - bound_addresses: list[tuple] = [] - - class _TrackingSession(SessionInitiationProtocol): - pass - - p = _TrackingSession( - outbound_proxy=("2001:db8::1", 5061), - aor="sips:alice@example.com", - rtp_stun_server_address=None, - ) - p.local_address = (ipaddress.IPv6Address("2001:db8::2"), 5061) - p._is_tls = True - - loop = asyncio.get_running_loop() - - async def fake_create_datagram(factory, *, local_addr=None, **kwargs): - if local_addr is not None: - bound_addresses.append(local_addr) - transport = MagicMock() - transport.get_extra_info.return_value = local_addr or ("::1", 0) - proto = factory() - proto.connection_made(transport) - return transport, proto - - with patch.object(loop, "create_datagram_endpoint", fake_create_datagram): - p.transport = make_mock_transport("2001:db8::2", 5061) - await p._initialize() - - assert bound_addresses, "create_datagram_endpoint was not called" - assert bound_addresses[0][0] == "::" - - async def test_register__includes_required_headers(self): - """REGISTER request includes From, To, Call-ID, CSeq, Contact and Expires.""" - p = make_register_session() - transport = make_mock_transport() - p.transport = transport - p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - p._is_tls = True - await p.register() - (data,) = transport.write.call_args[0] - assert b"From: sip:alice@example.com" in data - assert b"To: sip:alice@example.com" in data - # sip: AOR over TLS → sip:;transport=tls Contact with RFC 5626 ;ob parameter - assert b"Contact: " in data - assert b"Expires: 3600" in data - # RFC 5626 §5 outbound keep-alive support advertised - assert b"Supported: outbound" in data - - async def test_register__increments_cseq(self): - """CSeq increments with each REGISTER sent.""" - p = make_register_session() - p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - p.transport = make_mock_transport() - await p.register() - assert p.cseq == 1 - await p.register() - assert p.cseq == 2 - - @pytest.mark.asyncio - async def test_register__with_authorization(self): - """Authorization header is included when credentials are provided.""" - p = make_register_session() - transport = make_mock_transport() - p.transport = transport - p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - await p.register(authorization='Digest username="alice"') - (data,) = transport.write.call_args[0] - assert b'Authorization: Digest username="alice"' in data - - async def test_register__with_proxy_authorization(self): - """Proxy-Authorization header is included for proxy challenges.""" - p = make_register_session() - transport = make_mock_transport() - p.transport = transport - p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - await p.register(proxy_authorization='Digest username="alice"') - (data,) = transport.write.call_args[0] - assert b'Proxy-Authorization: Digest username="alice"' in data - - async def test_response_received__200_ok_calls_registered(self): - """Receiving 200 OK for REGISTER triggers registered().""" - calls = [] - - class ConcreteSession(SessionInitiationProtocol): - def registered(self): - calls.append(True) - - p = ConcreteSession( - outbound_proxy=("192.0.2.2", 5060), - aor="sip:alice@example.com", - username="a", - password="b", # noqa: S106 - ) - p.connection_made(make_mock_transport()) - p.response_received( - Response(status_code=200, phrase="OK", headers={"CSeq": "1 REGISTER"}), - ("192.0.2.2", 5060), - ) - assert calls == [True] - - async def test_response_received__200_non_register_raises(self): - """Receiving 200 OK for a non-REGISTER method raises RegistrationError.""" - p = make_register_session() - p.connection_made(make_mock_transport()) - with pytest.raises(RegistrationError): - p.response_received( - Response(status_code=200, phrase="OK", headers={"CSeq": "1 INVITE"}), - ("192.0.2.2", 5060), - ) - - async def test_response_received__401_retries_with_authorization(self): - """Receiving 401 triggers a re-REGISTER with an Authorization header.""" - p = make_register_session(username="alice", password="secret") # noqa: S106 - transport = make_mock_transport() - p.transport = transport - p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - challenge = 'Digest realm="example.com", nonce="abc123"' - p.response_received( - Response( - status_code=401, - phrase="Unauthorized", - headers={"WWW-Authenticate": challenge, "CSeq": "1 REGISTER"}, - ), - ("192.0.2.2", 5061), - ) - await asyncio.sleep(0.05) - (data,) = transport.write.call_args[0] - assert b"Authorization: Digest" in data - assert b'username="alice"' in data - assert b'realm="example.com"' in data - assert b'nonce="abc123"' in data - assert b'algorithm="SHA-256"' in data - - async def test_response_received__407_retries_with_proxy_authorization(self): - """Receiving 407 triggers a re-REGISTER with a Proxy-Authorization header.""" - p = make_register_session(username="alice", password="secret") # noqa: S106 - transport = make_mock_transport() - p.transport = transport - p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - challenge = 'Digest realm="example.com", nonce="xyz"' - p.response_received( - Response( - status_code=407, - phrase="Proxy Auth Required", - headers={"Proxy-Authenticate": challenge, "CSeq": "1 REGISTER"}, - ), - ("192.0.2.2", 5061), - ) - await asyncio.sleep(0.05) - (data,) = transport.write.call_args[0] - assert b"Proxy-Authorization: Digest" in data - assert b'username="alice"' in data - - async def test_response_received__401_with_qop_auth_includes_nc_cnonce(self): - """401 with qop=auth causes the retry to include nc and cnonce fields.""" - p = make_register_session() - transport = make_mock_transport() - p.transport = transport - p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - challenge = 'Digest realm="example.com", nonce="n", qop="auth"' - p.response_received( - Response( - status_code=401, - phrase="Unauthorized", - headers={"WWW-Authenticate": challenge, "CSeq": "1 REGISTER"}, - ), - ("192.0.2.2", 5061), - ) - await asyncio.sleep(0.05) - (data,) = transport.write.call_args[0] - assert b"qop=auth" in data - assert b"nc=00000001" in data - assert b"cnonce=" in data - - async def test_response_received__401_with_opaque_echoes_opaque(self): - """The opaque field from the challenge is echoed back in the Authorization.""" - p = make_register_session() - transport = make_mock_transport() - p.transport = transport - p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - challenge = 'Digest realm="example.com", nonce="n", opaque="secret-opaque"' - p.response_received( - Response( - status_code=401, - phrase="Unauthorized", - headers={"WWW-Authenticate": challenge, "CSeq": "1 REGISTER"}, - ), - ("192.0.2.2", 5061), - ) - await asyncio.sleep(0.05) - (data,) = transport.write.call_args[0] - assert b'opaque="secret-opaque"' in data - - async def test_register__via_header_has_rport(self): - """REGISTER request includes a Via header with the rport parameter.""" - p = make_register_session() - p.local_address = (ipaddress.IPv4Address("192.0.2.10"), 5061) - transport = make_mock_transport("192.0.2.10", 5061) - p.transport = transport - p._is_tls = True - await p.register() - (data,) = transport.write.call_args[0] - assert b"Via: SIP/2.0/TLS 192.0.2.10:5061;rport;branch=z9hG4bK" in data - assert re.search(rb"branch=z9hG4bK[0-9a-f]{32}", data) - - async def test_register__via_branch_is_unique_per_request(self): - """Each REGISTER generates a unique Via branch.""" - p = make_register_session() - p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - transport = make_mock_transport() - p.transport = transport - await p.register() - (data1,) = transport.write.call_args[0] - transport.reset_mock() - await p.register() - (data2,) = transport.write.call_args[0] - branch1 = re.search(rb"branch=(z9hG4bK[0-9a-f]{32})", data1).group(1) - branch2 = re.search(rb"branch=(z9hG4bK[0-9a-f]{32})", data2).group(1) - assert branch1 != branch2 - - async def test_register__contact_uses_local_addr(self): - """Contact header uses sip:;transport=tls;ob when AOR is sip: over TLS.""" - p = make_register_session() - p.local_address = (ipaddress.IPv4Address("10.0.0.5"), 5061) - transport = make_mock_transport("10.0.0.5", 5061) - p.transport = transport - p._is_tls = True - await p.register() - (data,) = transport.write.call_args[0] - assert b"Contact: " in data - - async def test_register__contact_uses_sips_when_aor_is_sips(self): - """Contact header uses sips: with ;ob when AOR scheme is sips:.""" - p = make_register_session(aor="sips:alice@example.com") - p.local_address = (ipaddress.IPv4Address("10.0.0.5"), 5061) - transport = make_mock_transport("10.0.0.5", 5061) - p.transport = transport - p._is_tls = True - await p.register() - (data,) = transport.write.call_args[0] - assert b"Contact: " in data - - async def test_register__contact_wraps_ipv6_in_brackets(self): - """Contact header wraps an IPv6 local address in square brackets.""" - p = make_register_session(aor="sips:alice@example.com") - p.local_address = (ipaddress.IPv6Address("2001:db8::1"), 5061) - transport = make_mock_transport("2001:db8::1", 5061) - p.transport = transport - p._is_tls = True - await p.register() - (data,) = transport.write.call_args[0] - assert b"Contact: " in data - - async def test_register__via_wraps_ipv6_in_brackets(self): - """Via header wraps an IPv6 local address in square brackets.""" - p = make_register_session() - p.local_address = (ipaddress.IPv6Address("::1"), 5061) - transport = make_mock_transport("::1", 5061) - p.transport = transport - p._is_tls = True - await p.register() - (data,) = transport.write.call_args[0] - assert b"Via: SIP/2.0/TLS [::1]:5061" in data - - async def test_response_received__403_raises_registration_error(self): - """403 Forbidden for REGISTER raises RegistrationError with the response message.""" - p = make_register_session() - p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - p.transport = make_mock_transport() - with pytest.raises(RegistrationError, match="403 Forbidden"): - p.response_received( - Response( - status_code=403, - phrase="Forbidden", - headers={"CSeq": "1 REGISTER"}, - ), - ("192.0.2.2", 5061), - ) - - async def test_response_received__unexpected_raises_registration_error(self): - """Any unexpected REGISTER response raises RegistrationError.""" - p = make_register_session() - p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - p.transport = make_mock_transport() - with pytest.raises(RegistrationError, match="500 Server Error"): - p.response_received( - Response( - status_code=500, - phrase="Server Error", - headers={"CSeq": "1 REGISTER"}, - ), - ("192.0.2.2", 5061), - ) - - async def test_data_received__sip_response__calls_response_received(self): - """data_received routes SIP messages to response_received via TCP stream.""" - received = [] - - class ConcreteSession(SessionInitiationProtocol): - def response_received(self, response, addr): - received.append(response) - - p = ConcreteSession( - outbound_proxy=("192.0.2.2", 5061), - aor="sip:alice@example.com", - username="a", - password="b", # noqa: S106 - ) - p.connection_made(make_mock_transport()) - sip_data = b"SIP/2.0 200 OK\r\nCSeq: 1 REGISTER\r\n\r\n" - p.data_received(sip_data) - assert len(received) == 1 - assert received[0].status_code == 200 - - async def test_invite_received_after_register(self): - """INVITE dispatching still works after registration.""" - received = [] - - class ConcreteSession(SessionInitiationProtocol): - def call_received(self, request): - received.append(request) - - p = ConcreteSession( - outbound_proxy=("192.0.2.2", 5060), - aor="sip:alice@example.com", - username="a", - password="b", # noqa: S106 - ) - p.connection_made(make_mock_transport()) - request = Request( - method="INVITE", - uri="sip:alice@example.com", - headers={"From": "sip:bob@example.com", "Call-ID": "test@pc"}, - ) - p.request_received(request, ("192.0.2.1", 5060)) - assert len(received) == 1 - assert received[0] is request - - async def test_response_received__200_ok__logs_info(self, caplog): - """Receiving 200 OK logs an info message.""" - import logging - - p = make_register_session() - p.connection_made(make_mock_transport()) - with caplog.at_level(logging.INFO, logger="voip.sip"): - p.response_received( - Response(status_code=200, phrase="OK", headers={"CSeq": "1 REGISTER"}), - ("192.0.2.2", 5060), - ) - assert any("Registration successful" in r.message for r in caplog.records) - - async def test_response_received__unexpected_status__raises_registration_error( - self, caplog - ): - """An unhandled status code raises RegistrationError with status and reason.""" - import logging - - p = make_register_session() - p.connection_made(make_mock_transport()) - with caplog.at_level(logging.WARNING, logger="voip.sip"): - with pytest.raises(RegistrationError, match="500 Server Error"): - p.response_received( - Response( - status_code=500, - phrase="Server Error", - headers={"CSeq": "1 REGISTER"}, - ), - ("192.0.2.2", 5060), - ) - - def test_build_contact__default__no_ob_param(self): - """Contact without ob=True has no ;ob parameter.""" - p = make_register_session() - p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - p._is_tls = True - contact = p._build_contact("alice") - assert ";ob" not in contact - - def test_build_contact__ob_true__includes_ob_uri_param(self): - """Contact with ob=True includes the ;ob URI parameter (RFC 5626 §5).""" - p = make_register_session() - p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - p._is_tls = True - assert p._build_contact("alice", ob=True) == ( - "" - ) - - def test_build_contact__sips_with_ob__includes_ob_before_closing_bracket(self): - """sips: Contact with ob=True places ;ob inside the angle brackets.""" - p = make_register_session(aor="sips:alice@example.com") - p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - p._is_tls = True - assert p._build_contact("alice", ob=True) == "" - - -# --------------------------------------------------------------------------- -# Tests for digest_response (RFC 3261 §22, RFC 8760) -# --------------------------------------------------------------------------- - - -class TestDigestResponse: - """Unit tests for SessionInitiationProtocol.digest_response.""" - - def test_default_algorithm_is_sha256(self): - """digest_response uses SHA-256 by default (RFC 8760).""" - result = SessionInitiationProtocol.digest_response( - username="alice", - password="secret", # noqa: S106 - realm="example.com", - nonce="abc123", - method="REGISTER", - uri="sip:example.com", - ) - # SHA-256 produces a 64-character hex digest - assert len(result) == 64 - - def test_sha256_response_is_correct(self): - """SHA-256 digest is computed from the correct input per RFC 8760.""" - username, password, realm, nonce, method, uri = ( - "alice", - "secret", - "example.com", - "abc123", - "REGISTER", - "sip:example.com", - ) - ha1 = hashlib.sha256(f"{username}:{realm}:{password}".encode()).hexdigest() - ha2 = hashlib.sha256(f"{method}:{uri}".encode()).hexdigest() - expected = hashlib.sha256(f"{ha1}:{nonce}:{ha2}".encode()).hexdigest() - - result = SessionInitiationProtocol.digest_response( - username=username, - password=password, # noqa: S106 - realm=realm, - nonce=nonce, - method=method, - uri=uri, - algorithm=DigestAlgorithm.SHA_256, - ) - assert result == expected - - def test_md5_response_is_32_hex_chars(self): - """MD5 algorithm still produces a valid 32-character hex digest.""" - result = SessionInitiationProtocol.digest_response( - username="alice", - password="secret", # noqa: S106 - realm="example.com", - nonce="abc123", - method="REGISTER", - uri="sip:example.com", - algorithm=DigestAlgorithm.MD5, - ) - assert len(result) == 32 - - def test_sha512_256_response_is_64_hex_chars(self): - """SHA-512-256 produces a 64-character hex digest.""" - result = SessionInitiationProtocol.digest_response( - username="alice", - password="secret", # noqa: S106 - realm="example.com", - nonce="abc123", - method="REGISTER", - uri="sip:example.com", - algorithm=DigestAlgorithm.SHA_512_256, - ) - assert len(result) == 64 - - def test_algorithms_produce_distinct_responses(self): - """Different algorithms produce distinct digest values.""" - digest_params = { - "username": "alice", - "password": "secret", # noqa: S106 - "realm": "example.com", - "nonce": "abc123", - "method": "REGISTER", - "uri": "sip:example.com", - } - r_md5 = SessionInitiationProtocol.digest_response( - **digest_params, algorithm=DigestAlgorithm.MD5 - ) - r_sha256 = SessionInitiationProtocol.digest_response( - **digest_params, algorithm=DigestAlgorithm.SHA_256 - ) - r_sha512 = SessionInitiationProtocol.digest_response( - **digest_params, algorithm=DigestAlgorithm.SHA_512_256 - ) - assert r_md5 != r_sha256 - assert r_sha256 != r_sha512 - - async def test_response_received__401_uses_server_algorithm(self): - """401 challenge with algorithm=SHA-256 causes Authorization to echo SHA-256.""" - p = make_register_session(username="alice", password="secret") # noqa: S106 - transport = make_mock_transport() - p.transport = transport - p.local_address = (ipaddress.IPv4Address("127.0.0.1"), 5061) - challenge = 'Digest realm="example.com", nonce="abc123", algorithm="SHA-256"' - p.response_received( - Response( - status_code=401, - phrase="Unauthorized", - headers={"WWW-Authenticate": challenge, "CSeq": "1 REGISTER"}, - ), - ("192.0.2.2", 5061), - ) - await asyncio.sleep(0.05) - (data,) = transport.write.call_args[0] - assert b'algorithm="SHA-256"' in data - assert b'algorithm="MD5"' not in data - - def test_sess_algorithm_incorporates_cnonce(self): - """SHA-256-sess and MD5-sess include cnonce in HA1, changing the result.""" - base = { - "username": "alice", - "password": "secret", # noqa: S106 - "realm": "example.com", - "nonce": "abc123", - "method": "REGISTER", - "uri": "sip:example.com", - } - r1 = SessionInitiationProtocol.digest_response( - **base, algorithm=DigestAlgorithm.SHA_256_SESS, cnonce="cnonce-A" - ) - r2 = SessionInitiationProtocol.digest_response( - **base, algorithm=DigestAlgorithm.SHA_256_SESS, cnonce="cnonce-B" - ) - # Different cnonce values must yield different responses - assert r1 != r2 - # A -sess result must differ from the non-sess result for the same inputs - r_plain = SessionInitiationProtocol.digest_response( - **base, algorithm=DigestAlgorithm.SHA_256 - ) - assert r1 != r_plain - - def test_md5_sess_incorporates_cnonce(self): - """MD5-sess includes cnonce in HA1.""" - base = { - "username": "alice", - "password": "secret", # noqa: S106 - "realm": "example.com", - "nonce": "abc123", - "method": "REGISTER", - "uri": "sip:example.com", - } - r1 = SessionInitiationProtocol.digest_response( - **base, algorithm=DigestAlgorithm.MD5_SESS, cnonce="cnonce-A" - ) - r2 = SessionInitiationProtocol.digest_response( - **base, algorithm=DigestAlgorithm.MD5_SESS, cnonce="cnonce-B" - ) - assert r1 != r2 - - def test_sess_algorithm_without_cnonce_raises(self): - """Calling a *-sess algorithm without cnonce raises ValueError.""" - with pytest.raises(ValueError, match="cnonce"): - SessionInitiationProtocol.digest_response( - username="alice", - password="secret", # noqa: S106 - realm="example.com", - nonce="abc123", - method="REGISTER", - uri="sip:example.com", - algorithm=DigestAlgorithm.SHA_256_SESS, - cnonce=None, - ) - - def test_unsupported_algorithm_raises(self): - """An unrecognised algorithm string raises ValueError.""" - with pytest.raises(ValueError, match="Unsupported digest algorithm"): - SessionInitiationProtocol.digest_response( - username="alice", - password="secret", # noqa: S106 - realm="example.com", - nonce="abc123", - method="REGISTER", - uri="sip:example.com", - algorithm="BLAKE2b", - ) diff --git a/tests/sip/test_types.py b/tests/sip/test_types.py index 9c22eb5..c57db9a 100644 --- a/tests/sip/test_types.py +++ b/tests/sip/test_types.py @@ -4,6 +4,7 @@ import pytest from voip.sip import SipUri +from voip.sip.messages import Response class TestSipUri: @@ -269,3 +270,11 @@ def test_parse__flag_parameter_and_valueless_header( ): """Parse flag URI parameters and valueless headers.""" assert SipUri.parse(uri_str) == expected_uri_obj + + +def _ok() -> Response: + return Response(status_code=200, phrase="OK") + + +def _trying() -> Response: + return Response(status_code=100, phrase="Trying") diff --git a/tests/test_audio.py b/tests/test_audio.py index bd2954f..0561b41 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -119,22 +119,6 @@ def test_init__default_payload_type_without_media(self): """Default payload_type is 8 (PCMA) when using the default test media.""" assert make_audio_call().payload_type == 8 - def test_init__logs_codec_info(self, caplog): - """Log codec name, sample rate and payload type at INFO level on init.""" - import logging # noqa: PLC0415 - - media = MediaDescription( - media="audio", - port=49170, - proto="RTP/AVP", - fmt=[ - RTPPayloadFormat(payload_type=8, encoding_name="PCMA", sample_rate=8000) - ], - ) - with caplog.at_level(logging.INFO, logger="voip.audio"): - make_audio_call(media=media) - assert any("PCMA" in r.message and "8000" in r.message for r in caplog.records) - @pytest.mark.asyncio async def test_packet_received__dispatches_audio_for_non_empty_payload(self): """packet_received schedules audio decoding when the packet has a payload.""" diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index 9accfc5..0000000 --- a/tests/test_main.py +++ /dev/null @@ -1,875 +0,0 @@ -"""Tests for the CLI commands.""" - -from __future__ import annotations - -import asyncio -import ipaddress -import sys -from unittest.mock import MagicMock, patch - -import pytest - -pytest.importorskip("numpy") -_click_testing = pytest.importorskip("click.testing") -from voip.__main__ import voip # noqa: E402 - -CliRunner = _click_testing.CliRunner - -# Stub out optional heavy dependencies so the CLI can be imported without them. -_WHISPER_STUBS = { - "numpy": MagicMock(), - "whisper": MagicMock(), - "av": MagicMock(), - "faster_whisper": MagicMock(), - "ollama": MagicMock(), - "pocket_tts": MagicMock(), - "voip.audio": MagicMock( - EchoCall=MagicMock, VoiceActivityCall=MagicMock, AudioCall=MagicMock - ), - "voip.ai": MagicMock(TranscribeCall=MagicMock, AgentCall=MagicMock), -} - - -def make_runner(): - """Return a Click test runner.""" - return CliRunner() - - -def make_mock_transport(host: str = "127.0.0.1", port: int = 5060) -> MagicMock: - """Return a MagicMock transport with a pre-configured sockname.""" - transport = MagicMock() - transport.get_extra_info.return_value = (host, port) - return transport - - -class TestParseStunServer: - def test_parse_stun_server__none_disables_stun(self): - """Return None when STUN server is not configured.""" - from voip.__main__ import _parse_stun_server - - assert _parse_stun_server(None, None, None) is None - - def test_parse_stun_server__string_none_disables_stun(self): - """Return None when STUN server is explicitly disabled.""" - from voip.__main__ import _parse_stun_server - - assert _parse_stun_server(None, None, "none") is None - - def test_parse_stun_server__without_port_uses_stun_default(self): - """Return port 3478 when no port is specified for STUN.""" - from voip.__main__ import _parse_stun_server - - assert _parse_stun_server(None, None, "stun.example.com") == ( - "stun.example.com", - 3478, - ) - - -class TestParseHostport: - def test_parse_hostport__bracketed_ipv6_without_port_uses_default(self): - """Return default port and IPv6Address when bracketed IPv6 address has no port.""" - from voip.__main__ import _parse_hostport - - assert _parse_hostport(None, None, "[::1]", default_port=5061) == ( - ipaddress.IPv6Address("::1"), - 5061, - ) - - def test_parse_hostport__bracketed_ipv6_with_port(self): - """Return explicit port and IPv6Address when bracketed IPv6 address includes a port.""" - from voip.__main__ import _parse_hostport - - assert _parse_hostport(None, None, "[::1]:5061") == ( - ipaddress.IPv6Address("::1"), - 5061, - ) - - def test_parse_hostport__unbracketed_ipv6_raises_bad_parameter(self): - """Raise BadParameter when an unbracketed IPv6 literal is given.""" - import click - from voip.__main__ import _parse_hostport - - with pytest.raises(click.BadParameter, match="enclosed in brackets"): - _parse_hostport(None, None, "::1") - - -class TestVoIPCommand: - def test_voip__verbose_flag(self): - """Accept -v flag without error.""" - result = make_runner().invoke(voip, ["-v", "--help"]) - assert result.exit_code == 0 - - def test_sip__aor_without_user_raises_error(self): - """Raise BadParameter when AOR has no user part.""" - from voip.__main__ import voip - - result = make_runner().invoke( - voip, - ["sip", "--password=p", "--stun-server=none", "sip:example.com", "echo"], - ) - assert result.exit_code != 0 - assert "AOR must contain a user part" in (result.output or "") - - -class TestTranscribeCLI: - def test_transcribe__sips_aor_uses_tls(self): - """sips: AOR without explicit port defaults to TLS on port 5061.""" - captured = {} - - async def fake_connection(factory, *, host, port, ssl): - captured["host"] = host - captured["port"] = port - captured["ssl"] = ssl - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=secret", - "sips:alice@sip.example.com", - "transcribe", - ], - catch_exceptions=False, - ) - assert captured.get("host") == "sip.example.com" - assert captured.get("port") == 5061 - assert captured.get("ssl") is not None - - def test_transcribe__port_5060_uses_tcp(self): - """Port 5060 in the AOR triggers plain TCP (no TLS).""" - captured = {} - - async def fake_connection(factory, *, host, port, ssl): - captured["ssl"] = ssl - captured["port"] = port - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=secret", - "sip:alice@example.com:5060", - "transcribe", - ], - catch_exceptions=False, - ) - assert captured.get("ssl") is None - assert captured.get("port") == 5060 - - def test_transcribe__sip_aor_defaults_to_port_5060(self): - """sip: AOR without explicit port defaults to port 5060 and plain TCP.""" - captured = {} - - async def fake_connection(factory, *, host, port, ssl): - captured["ssl"] = ssl - captured["port"] = port - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=secret", - "sip:alice@example.com", - "transcribe", - ], - catch_exceptions=False, - ) - assert captured.get("ssl") is None - assert captured.get("port") == 5060 - - def test_transcribe__no_tls_forces_tcp_on_sips_aor(self): - """--no-tls forces plain TCP even when the AOR uses sips:.""" - captured = {} - - async def fake_connection(factory, *, host, port, ssl): - captured["ssl"] = ssl - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=secret", - "--no-tls", - "sips:alice@example.com", - "transcribe", - ], - catch_exceptions=False, - ) - assert captured.get("ssl") is None - - def test_transcribe__aor_sets_protocol_aor(self): - """The AOR positional argument sets the normalized aor on the protocol.""" - captured = {} - - async def fake_connection(factory, *, host, port, ssl): - protocol = factory() - captured["aor"] = protocol.aor - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=p", - "sips:alice@carrier.example.com", - "transcribe", - ], - catch_exceptions=False, - ) - # AOR stored on protocol must NOT include port (RFC 3261 §10) - assert captured.get("aor") == "sips:alice@carrier.example.com" - - def test_transcribe__username_override(self): - """--username overrides the user part from the AOR.""" - captured = {} - - async def fake_connection(factory, *, host, port, ssl): - protocol = factory() - captured["aor"] = protocol.aor - captured["username"] = protocol.username - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=p", - "--username=bob", - "sip:alice@carrier.example.com", - "transcribe", - ], - catch_exceptions=False, - ) - assert captured.get("username") == "bob" - assert captured.get("aor") == "sip:bob@carrier.example.com" - - def test_transcribe__proxy_overrides_outbound_proxy(self): - """--proxy overrides the outbound proxy address derived from AOR.""" - captured = {} - - async def fake_connection(factory, *, host, port, ssl): - protocol = factory() - captured["proxy"] = protocol.outbound_proxy - captured["host"] = host - captured["port"] = port - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=p", - "--proxy=proxy.carrier.com:5061", - "sips:alice@carrier.com", - "transcribe", - ], - catch_exceptions=False, - ) - assert captured.get("proxy") == ("proxy.carrier.com", 5061) - assert captured.get("host") == "proxy.carrier.com" - assert captured.get("port") == 5061 - - def test_transcribe__aor_with_port_parsed_as_outbound_proxy(self): - """Port in AOR sets the outbound proxy port on the protocol.""" - captured = {} - - async def fake_connection(factory, *, host, port, ssl): - protocol = factory() - captured["proxy"] = protocol.outbound_proxy - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=p", - "sips:alice@carrier.example.com:5080", - "transcribe", - ], - catch_exceptions=False, - ) - assert captured.get("proxy") == ("carrier.example.com", 5080) - - def test_transcribe__stun_none_disables_stun(self): - """Disable RTP STUN when --stun-server=none is passed.""" - captured = {} - - async def fake_connection(factory, *, host, port, ssl): - protocol = factory() - captured["stun"] = protocol.rtp_stun_server_address - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=p", - "--stun-server=none", - "sips:alice@example.com", - "transcribe", - ], - catch_exceptions=False, - ) - assert captured.get("stun") is None - - def test_transcribe__registered_logs_and_echoes(self): - """Log and echo a message when registration succeeds.""" - protocol_holder = {} - - async def fake_connection(factory, *, host, port, ssl): - protocol = factory() - protocol_holder["protocol"] = protocol - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=p", - "--stun-server=none", - "sips:alice@example.com", - "transcribe", - ], - catch_exceptions=False, - ) - protocol = protocol_holder["protocol"] - protocol.registered() # exercises TranscribingProtocol.registered - - def test_transcribe__call_received_answers_call(self): - """Answer the call when call_received is invoked.""" - protocol_holder = {} - - async def fake_connection(factory, *, host, port, ssl): - protocol = factory() - protocol_holder["protocol"] = protocol - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=p", - "--stun-server=none", - "sips:alice@example.com", - "transcribe", - ], - catch_exceptions=False, - ) - protocol = protocol_holder["protocol"] - from voip.sip.messages import Request - - request = Request( - method="INVITE", - uri="sip:u@example.com", - headers={"From": "sip:caller@example.com", "Call-ID": "test@pc"}, - ) - - async def run(): - with patch.object(protocol, "answer") as mock_answer: - protocol.connection_made(make_mock_transport()) - protocol._pending_invites.add(request.headers["Call-ID"]) - protocol.call_received(request) - mock_answer.assert_called_once() - - asyncio.run(run()) - - def test_transcribe__call_received_uses_whisper_call_class(self): - """call_received answers with a TranscribeCall subclass.""" - protocol_holder = {} - - async def fake_connection(factory, *, host, port, ssl): - protocol = factory() - protocol_holder["protocol"] = protocol - raise KeyboardInterrupt - - whisper_mock = MagicMock() - stubs = dict(_WHISPER_STUBS) - stubs["whisper"] = whisper_mock - - with ( - patch.dict(sys.modules, stubs), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - patch("voip.audio.whisper") as wm, - ): - wm.load_model.return_value = MagicMock() - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=p", - "--stun-server=none", - "sips:alice@example.com", - "transcribe", - ], - catch_exceptions=False, - ) - protocol = protocol_holder["protocol"] - from voip.sip.messages import Request - - request = Request( - method="INVITE", - uri="sip:u@example.com", - headers={"From": "sip:caller@example.com", "Call-ID": "test@pc"}, - ) - - async def _run_whisper(): - with patch.object(protocol, "answer") as mock_answer: - protocol.connection_made(make_mock_transport()) - protocol._pending_invites.add(request.headers["Call-ID"]) - protocol.call_received(request) - mock_answer.assert_called_once() - _, kwargs = mock_answer.call_args - assert "call_class" in kwargs - assert isinstance(kwargs["call_class"], type) - - asyncio.run(_run_whisper()) - - def test_transcribe__retries_on_os_error(self): - """_connect_sip retries when the outbound connection fails with OSError.""" - from unittest.mock import AsyncMock - - call_count = 0 - - async def fake_connection(factory, *, host, port, ssl): - nonlocal call_count - call_count += 1 - if call_count < 3: - raise OSError("Connection refused") - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - patch("voip.__main__.asyncio.sleep", new=AsyncMock()), - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=p", - "--stun-server=none", - "sips:alice@example.com", - "transcribe", - ], - catch_exceptions=False, - ) - assert call_count == 3 - - def test_transcribe__reconnects_after_disconnect(self): - """_connect_sip reconnects when the disconnected_event is set.""" - from unittest.mock import AsyncMock - - call_count = 0 - - async def fake_connection(factory, *, host, port, ssl): - nonlocal call_count - call_count += 1 - protocol = factory() - if call_count == 1: - protocol.disconnected_event.set() - return MagicMock(), protocol - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - patch("voip.__main__.asyncio.sleep", new=AsyncMock()), - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=p", - "--stun-server=none", - "sips:alice@example.com", - "transcribe", - ], - catch_exceptions=False, - ) - assert call_count == 2 - - -class TestAgentCLI: - def test_agent__sips_aor_uses_tls(self): - """sips: AOR without explicit port defaults to TLS on port 5061.""" - captured = {} - - async def fake_connection(factory, *, host, port, ssl): - captured["host"] = host - captured["port"] = port - captured["ssl"] = ssl - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=secret", - "sips:alice@sip.example.com", - "agent", - ], - catch_exceptions=False, - ) - assert captured.get("host") == "sip.example.com" - assert captured.get("port") == 5061 - assert captured.get("ssl") is not None - - def test_agent__port_5060_uses_tcp(self): - """Port 5060 in the AOR triggers plain TCP (no TLS).""" - captured = {} - - async def fake_connection(factory, *, host, port, ssl): - captured["ssl"] = ssl - captured["port"] = port - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=secret", - "sip:alice@example.com:5060", - "agent", - ], - catch_exceptions=False, - ) - assert captured.get("ssl") is None - assert captured.get("port") == 5060 - - def test_agent__call_received_uses_agent_call_class(self): - """call_received answers with an AgentCall subclass.""" - protocol_holder = {} - - async def fake_connection(factory, *, host, port, ssl): - protocol = factory() - protocol_holder["protocol"] = protocol - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=p", - "--stun-server=none", - "sips:alice@example.com", - "agent", - ], - catch_exceptions=False, - ) - protocol = protocol_holder["protocol"] - from voip.sip.messages import Request - - request = Request( - method="INVITE", - uri="sip:u@example.com", - headers={"From": "sip:caller@example.com", "Call-ID": "test@pc"}, - ) - - async def _run_agent(): - with patch.object(protocol, "answer") as mock_answer: - protocol.connection_made(make_mock_transport()) - protocol._pending_invites.add(request.headers["Call-ID"]) - protocol.call_received(request) - mock_answer.assert_called_once() - _, kwargs = mock_answer.call_args - assert "call_class" in kwargs - assert isinstance(kwargs["call_class"], type) - - asyncio.run(_run_agent()) - - def test_agent__ollama_model_option(self): - """--llm-model sets the llm_model kwarg on the answer call.""" - protocol_holder = {} - - async def fake_connection(factory, *, host, port, ssl): - protocol = factory() - protocol_holder["protocol"] = protocol - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=p", - "--stun-server=none", - "sips:alice@example.com", - "agent", - "--llm-model=mistral", - ], - catch_exceptions=False, - ) - protocol = protocol_holder.get("protocol") - if protocol is None: - return # Protocol not captured; skip assertion - from voip.sip.messages import Request - - request = Request( - method="INVITE", - uri="sip:u@example.com", - headers={"From": "sip:caller@example.com", "Call-ID": "test@pc"}, - ) - - async def _run_ollama(): - with patch.object(protocol, "answer") as mock_answer: - protocol.connection_made(make_mock_transport()) - protocol._pending_invites.add(request.headers["Call-ID"]) - protocol.call_received(request) - _, kwargs = mock_answer.call_args - assert kwargs.get("llm_model") == "mistral" - - asyncio.run(_run_ollama()) - - def test_agent__voice_option(self): - """--voice sets the voice kwarg on the answer call.""" - protocol_holder = {} - - async def fake_connection(factory, *, host, port, ssl): - protocol = factory() - protocol_holder["protocol"] = protocol - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=p", - "--stun-server=none", - "sips:alice@example.com", - "agent", - "--voice=ellie", - ], - catch_exceptions=False, - ) - protocol = protocol_holder.get("protocol") - if protocol is None: - return # Protocol not captured; skip assertion - from voip.sip.messages import Request - - request = Request( - method="INVITE", - uri="sip:u@example.com", - headers={"From": "sip:caller@example.com", "Call-ID": "test@pc"}, - ) - - async def _run_voice(): - with patch.object(protocol, "answer") as mock_answer: - protocol.connection_made(make_mock_transport()) - protocol._pending_invites.add(request.headers["Call-ID"]) - protocol.call_received(request) - _, kwargs = mock_answer.call_args - assert kwargs.get("voice") == "ellie" - - asyncio.run(_run_voice()) - - -class TestEchoCLI: - def test_echo__sips_aor_uses_tls(self): - """sips: AOR without explicit port defaults to TLS on port 5061.""" - captured = {} - - async def fake_connection(factory, *, host, port, ssl): - captured["host"] = host - captured["port"] = port - captured["ssl"] = ssl - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=secret", - "sips:alice@sip.example.com", - "echo", - ], - catch_exceptions=False, - ) - assert captured.get("host") == "sip.example.com" - assert captured.get("port") == 5061 - assert captured.get("ssl") is not None - - def test_echo__port_5060_uses_tcp(self): - """Port 5060 in the AOR triggers plain TCP (no TLS).""" - captured = {} - - async def fake_connection(factory, *, host, port, ssl): - captured["ssl"] = ssl - captured["port"] = port - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=secret", - "sip:alice@example.com:5060", - "echo", - ], - catch_exceptions=False, - ) - assert captured.get("ssl") is None - assert captured.get("port") == 5060 - - def test_echo__call_received_answers_with_echo_call(self): - """call_received answers with an EchoCall class.""" - protocol_holder = {} - - async def fake_connection(factory, *, host, port, ssl): - protocol = factory() - protocol_holder["protocol"] = protocol - raise KeyboardInterrupt - - with ( - patch.dict(sys.modules, _WHISPER_STUBS), - patch("asyncio.get_event_loop"), - patch("voip.__main__.asyncio.get_running_loop") as mock_loop, - ): - mock_loop.return_value.create_connection = fake_connection - make_runner().invoke( - voip, - [ - "sip", - "--password=p", - "--stun-server=none", - "sips:alice@example.com", - "echo", - ], - catch_exceptions=False, - ) - protocol = protocol_holder["protocol"] - from voip.sip.messages import Request - - request = Request( - method="INVITE", - uri="sip:u@example.com", - headers={"From": "sip:caller@example.com", "Call-ID": "test@pc"}, - ) - - async def run(): - with patch.object(protocol, "answer") as mock_answer: - protocol.connection_made(make_mock_transport()) - protocol._pending_invites.add(request.headers["Call-ID"]) - protocol.call_received(request) - mock_answer.assert_called_once() - _, kwargs = mock_answer.call_args - assert "call_class" in kwargs - assert isinstance(kwargs["call_class"], type) - - asyncio.run(run()) diff --git a/tests/test_rtp.py b/tests/test_rtp.py index 4b7b8b1..32d090a 100644 --- a/tests/test_rtp.py +++ b/tests/test_rtp.py @@ -9,7 +9,7 @@ from unittest.mock import MagicMock import pytest -from voip.rtp import RTP, RealtimeTransportProtocol, RTPCall, RTPPacket, RTPPayloadType +from voip.rtp import RTP, RealtimeTransportProtocol, RTPPacket, RTPPayloadType, Session from voip.sdp.types import MediaDescription, RTPPayloadFormat from voip.sip.types import CallerID @@ -21,7 +21,7 @@ def make_media() -> MediaDescription: ) -def make_call(**kwargs) -> RTPCall: +def make_call(**kwargs) -> Session: """Create an RTPCall with mock rtp/sip for unit testing.""" defaults: dict = { "rtp": MagicMock(spec=RealtimeTransportProtocol), @@ -30,7 +30,7 @@ def make_call(**kwargs) -> RTPCall: "caller": CallerID(""), } defaults.update(kwargs) - return RTPCall(**defaults) + return Session(**defaults) def make_rtp_packet( @@ -158,7 +158,7 @@ async def test_datagram_received__routes_to_handler(self): """Non-STUN datagrams from registered addr are forwarded to the handler.""" routed: list[RTPPacket] = [] - class RecordCall(RTPCall): + class RecordCall(Session): def packet_received(self, packet: RTPPacket, addr): routed.append(packet) @@ -185,7 +185,7 @@ async def test_datagram_received__malformed_packet__dropped(self): """Malformed RTP datagrams (too short) are discarded without crashing.""" routed: list[RTPPacket] = [] - class RecordCall(RTPCall): + class RecordCall(Session): def packet_received(self, packet: RTPPacket, addr): routed.append(packet) @@ -202,7 +202,7 @@ async def test_datagram_received__stun_packet__not_forwarded(self): """A STUN packet (first byte < 4) must not reach any Call handler.""" routed: list[RTPPacket] = [] - class RecordCall(RTPCall): + class RecordCall(Session): def packet_received(self, packet: RTPPacket, addr): routed.append(packet) @@ -256,12 +256,22 @@ def datagram_received(self, data, addr): ) server_addr = server_t.get_extra_info("sockname") - proto = RealtimeTransportProtocol(stun_server_address=server_addr) + done: asyncio.Future[ + tuple[ipaddress.IPv4Address | ipaddress.IPv6Address, int] + ] = loop.create_future() + + class TrackingRTP(RealtimeTransportProtocol): + def stun_connection_made(self, transport, addr): + super().stun_connection_made(transport, addr) + if not done.done(): + done.set_result(addr) + + proto = TrackingRTP(stun_server_address=server_addr) rtp_t, _ = await loop.create_datagram_endpoint( lambda: proto, local_addr=("127.0.0.1", 0) ) try: - result = await proto.public_address + result = await asyncio.wait_for(done, 2.0) assert result == (ipaddress.IPv4Address("203.0.113.5"), 54321) assert len(received_requests) == 1 finally: @@ -274,11 +284,11 @@ async def test_register_call__routes_by_addr(self): received_wildcard: list[RTPPacket] = [] received_call: list[RTPPacket] = [] - class WildcardCall(RTPCall): + class WildcardCall(Session): def packet_received(self, packet: RTPPacket, addr): received_wildcard.append(packet) - class SpecificCall(RTPCall): + class SpecificCall(Session): def packet_received(self, packet: RTPPacket, addr): received_call.append(packet) @@ -304,7 +314,7 @@ async def test_register_call__unmatched_addr_uses_wildcard_handler(self): """Packets from an unknown addr reach the None-key wildcard handler.""" received: list[RTPPacket] = [] - class WildcardCall(RTPCall): + class WildcardCall(Session): def packet_received(self, packet: RTPPacket, addr): received.append(packet) @@ -324,7 +334,7 @@ async def test_unregister_call__removes_handler(self): """After unregister_call, packets from that addr are no longer routed to handler.""" received: list[RTPPacket] = [] - class RecordCall(RTPCall): + class RecordCall(Session): def packet_received(self, packet: RTPPacket, addr): received.append(packet) @@ -345,7 +355,7 @@ async def test_register_call__logs_info(self, caplog): import logging # noqa: PLC0415 mux = RealtimeTransportProtocol() - handler = RTPCall( + handler = Session( rtp=mux, sip=MagicMock(), media=make_media(), caller=CallerID("") ) with caplog.at_level(logging.INFO, logger="voip.rtp"): @@ -358,7 +368,7 @@ async def test_unregister_call__logs_info(self, caplog): import logging # noqa: PLC0415 mux = RealtimeTransportProtocol() - handler = RTPCall( + handler = Session( rtp=mux, sip=MagicMock(), media=make_media(), caller=CallerID("") ) addr = ("1.2.3.4", 5004) @@ -372,7 +382,7 @@ async def test_packet_received__dispatches_to_handler(self): """packet_received forwards the datagram to the registered handler.""" received: list[tuple[RTPPacket, tuple]] = [] - class CapturingCall(RTPCall): + class CapturingCall(Session): def packet_received(self, packet: RTPPacket, addr): received.append((packet, addr)) @@ -414,7 +424,7 @@ async def test_srtp_packet__decrypted_before_delivery(self): received: list[RTPPacket] = [] @dataclasses.dataclass - class SRTPCapture(RTPCall): + class SRTPCapture(Session): def packet_received(self, packet: RTPPacket, addr) -> None: received.append(packet) @@ -446,7 +456,7 @@ async def test_srtp_invalid_auth_tag__discarded(self, caplog): received: list[RTPPacket] = [] @dataclasses.dataclass - class SRTPCapture(RTPCall): + class SRTPCapture(Session): def packet_received(self, packet: RTPPacket, addr) -> None: received.append(packet) @@ -471,7 +481,7 @@ def packet_received(self, packet: RTPPacket, addr) -> None: assert any("authentication failed" in r.message for r in caplog.records) -class TestRTPCall: +class TestSession: def test_caller__defaults_to_empty_string(self): """Caller defaults to an empty CallerID when not provided.""" call = make_call() @@ -491,7 +501,7 @@ def test_rtp_and_sip_stored_as_fields(self): """Rtp and sip back-references are stored on the instance.""" mock_rtp = MagicMock(spec=RealtimeTransportProtocol) mock_sip = MagicMock() - call = RTPCall( + call = Session( rtp=mock_rtp, sip=mock_sip, media=make_media(), caller=CallerID("") ) assert call.rtp is mock_rtp @@ -531,7 +541,7 @@ def test_send_packet__encrypts_with_srtp_when_set(self): def test_negotiate_codec__raises_not_implemented(self): """negotiate_codec raises NotImplementedError in the base class.""" with pytest.raises(NotImplementedError): - RTPCall.negotiate_codec(MagicMock()) + Session.negotiate_codec(MagicMock()) async def test_hang_up__raises_not_implemented(self): """hang_up raises NotImplementedError in the base class.""" diff --git a/voip/__main__.py b/voip/__main__.py index 3115f8e..bc875a7 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -3,13 +3,16 @@ import dataclasses import ipaddress import logging -import re +import socket import ssl import time +from voip.rtp import RealtimeTransportProtocol from voip.sip import messages from voip.sip.protocol import SessionInitiationProtocol +from voip.sip.transactions import InviteTransaction from voip.sip.types import SipUri +from voip.types import NetworkAddress try: import click @@ -27,86 +30,19 @@ logger = logging.getLogger("voip") -#: Standard SIP/TCP port — plain text, no TLS (RFC 3261 §18.2). -SIP_TCP_PORT = 5060 -#: Standard SIP/TLS port (RFC 3261 §26.2.2). -SIP_TLS_PORT = 5061 - - -HOSTPORT_PATTERN: re.Pattern[str] = re.compile( - r"^(?:\[(?P[0-9a-fA-F:]+)\]|(?P[^:\[\]]+))" - r"(?::(?P\d+))?$" -) - - -def _parse_hostport( - ctx, param, value: str, default_port: int = 5061 -) -> tuple[ipaddress.IPv4Address | ipaddress.IPv6Address | str, int]: - """Parse `HOST[:PORT]` or `[IPv6HOST][:PORT]` into a typed `(host, port)` tuple. - - IPv6 addresses must be enclosed in square brackets per RFC 2732, e.g. - ``[::1]:5061``. The returned host is an - [`IPv4Address`][ipaddress.IPv4Address] or [`IPv6Address`][ipaddress.IPv6Address] - when the value is a numeric IP address, otherwise a plain hostname string. - - Args: - ctx: Click context. - param: Click parameter. - value: Hostport string. - default_port: Port to use when not specified. - - Returns: - Tuple of (host, port) where host is an IP address object or hostname string. - - Raises: - click.BadParameter: When value is malformed (unbracketed IPv6 or invalid port). - """ - if not (match := HOSTPORT_PATTERN.fullmatch(value)): - if value.count(":") > 1: - raise click.BadParameter( - f"IPv6 address must be enclosed in brackets, e.g. [{value}].", - param=param, - ) - raise click.BadParameter(f"Invalid host:port value: {value!r}.", param=param) - raw_host = match.group("ipv6") or match.group("host") - port = int(match.group("port")) if match.group("port") else default_port - try: - return ipaddress.ip_address(raw_host), port - except ValueError: - return raw_host, port - - -def _parse_stun_server(ctx, param, value: str | None) -> tuple[str, int] | None: - """Parse the --stun-server option; return None when the value is 'none'. - - Args: - ctx: Click context. - param: Click parameter. - value: Stun server string or None. - - Returns: - Tuple of (host, port) or None. - """ - if value is None or value.lower() == "none": - return None - host, port = _parse_hostport(ctx, param, value, default_port=3478) - return str(host), port - - +@dataclasses.dataclass(kw_only=True, slots=True) class ConsoleMessageProtocol(SessionInitiationProtocol): """Pretty print SIP messages to stdout using pygments.""" - __slots__ = ("verbose",) + verbose: int = 0 - def request_received(self, request: messages.Request, addr: tuple[str, int]): + def request_received(self, request: messages.Request): self.pprint(request) - super().request_received(request, addr) + super().request_received(request) - def response_received( - self, response: messages.Response, addr: tuple[str, int] | None - ): + def response_received(self, response: messages.Response): self.pprint(response) - super().response_received(response, addr) + super().response_received(response) def send(self, message) -> None: """Send a message and print it to stdout.""" @@ -140,58 +76,34 @@ def voip(ctx, verbose: int = 0): """VoIP CLI.""" ctx.ensure_object(dict) ctx.obj["verbose"] = verbose + + console = logging.StreamHandler() + console.setFormatter( + logging.Formatter( + "%(addr)s - %(levelname)s: [%(asctime)s] (%(name)s) %(message)s", + defaults={"addr": NetworkAddress(socket.gethostname())}, + ) + ) + logging.basicConfig( level=max(10, 10 * (4 - verbose)), - format="%(levelname)s: [%(asctime)s] (%(name)s) %(message)s", - handlers=[logging.StreamHandler()], + handlers=[console], ) logging.getLogger("voip").setLevel(max(10, 10 * (3 - verbose))) @voip.group() @click.argument("aor", metavar="AOR", envvar="SIP_AOR") -@click.option( - "--password", - envvar="SIP_PASSWORD", - required=True, - help="SIP password (not parsed from AOR for security).", -) -@click.option( - "--username", - envvar="SIP_USERNAME", - default=None, - help="Override SIP username (defaults to user part of AOR).", -) -@click.option( - "--proxy", - envvar="SIP_PROXY", - default=None, - metavar="HOST[:PORT]", - help=( - "Outbound proxy address (RFC 3261 §8.1.2). " - "Defaults to the host and port from AOR. " - "Use this when the proxy differs from the registrar domain." - ), -) @click.option( "--stun-server", envvar="STUN_SERVER", default="stun.cloudflare.com:3478", show_default=True, metavar="HOST[:PORT]", - callback=_parse_stun_server, + callback=lambda ctx, param, value: NetworkAddress.parse(value), is_eager=False, help="STUN server for RTP NAT traversal (use 'none' to disable).", ) -@click.option( - "--no-tls", - is_flag=True, - default=False, - help=( - "Force plain TCP — skips TLS. " - "Auto-selected when port 5060 is used; explicit flag overrides any port." - ), -) @click.option( "--no-verify-tls", is_flag=True, @@ -199,7 +111,7 @@ def voip(ctx, verbose: int = 0): help="Disable TLS certificate verification (insecure; for testing only).", ) @click.pass_context -def sip(ctx, aor, password, username, proxy, stun_server, no_tls, no_verify_tls): +def sip(ctx, aor, stun_server, no_verify_tls): """Session Initiation Protocol (SIP).""" ctx.ensure_object(dict) try: @@ -207,52 +119,34 @@ def sip(ctx, aor, password, username, proxy, stun_server, no_tls, no_verify_tls) except ValueError as exc: raise click.BadParameter(str(exc), param_hint="AOR") from exc - effective_username = username or parsed_aor.user - if not effective_username: - raise click.BadParameter( - "AOR must contain a user part (e.g. sip:alice@example.com).", - param_hint="AOR", - ) - - if proxy is not None: - proxy_addr = _parse_hostport(ctx, None, proxy) - else: - default_port = SIP_TCP_PORT if parsed_aor.scheme == "sip" else SIP_TLS_PORT - port = parsed_aor.port if parsed_aor.port is not None else default_port - proxy_addr = (parsed_aor.host, port) - - use_tls = not no_tls and proxy_addr[1] != SIP_TCP_PORT - # Build the canonical AOR; IPv6 hosts must be enclosed in brackets per RFC 2732. - host_in_aor = ( - f"[{parsed_aor.host}]" - if isinstance(parsed_aor.host, ipaddress.IPv6Address) - else str(parsed_aor.host) - ) - normalized_aor = f"{parsed_aor.scheme}:{effective_username}@{host_in_aor}" - ctx.obj.update( - aor=normalized_aor, - username=effective_username, - password=password, - proxy_addr=proxy_addr, + aor=parsed_aor, + proxy_addr=parsed_aor.maddr, stun_server=stun_server, - use_tls=use_tls, no_verify_tls=no_verify_tls, ) +async def _connect_rtp( + proxy_addr: NetworkAddress, + rtp_stun_server_address: NetworkAddress | None, +) -> tuple[asyncio.DatagramTransport, RealtimeTransportProtocol]: + loop = asyncio.get_running_loop() + rtp_bind = ( + "::" if isinstance(proxy_addr[0], ipaddress.IPv6Address) else "0.0.0.0" # noqa: S104 + ) + return await loop.create_datagram_endpoint( + lambda: RealtimeTransportProtocol(stun_server_address=rtp_stun_server_address), + local_addr=(rtp_bind, 0), + ) + + async def _connect_sip( session_factory, - proxy_addr: tuple[ipaddress.IPv4Address | ipaddress.IPv6Address | str, int], + proxy_addr: NetworkAddress, use_tls: bool, no_verify_tls: bool, ) -> None: - """Connect to a SIP proxy with automatic reconnection on failure. - - Retries with exponential back-off (1 s → 2 s → … → 60 s) after each - failed connection or dropped session so the process stays running without - manual intervention. - """ loop = asyncio.get_running_loop() ssl_context: ssl.SSLContext | None = None if use_tls: @@ -287,26 +181,28 @@ def echo(ctx): from .audio import EchoCall # noqa: PLC0415 obj = ctx.obj - proxy_addr = obj["proxy_addr"] - - class EchoSession(ConsoleMessageProtocol): - verbose = obj.get("verbose", 0) + obj = ctx.obj + aor = obj["aor"] - def call_received(self, request) -> None: - self.ringing(request=request) - asyncio.create_task(self.answer(request=request, call_class=EchoCall)) + class EchoInviteTransaction(InviteTransaction): + def invite_received(self, request: messages.Request) -> None: + self.ringing() + self.answer(call_class=EchoCall) async def run(): + _, rtp_protocol = await _connect_rtp( + aor.maddr, + obj["stun_server"], + ) await _connect_sip( - lambda: EchoSession( - outbound_proxy=proxy_addr, - aor=obj["aor"], - username=obj["username"], - password=obj["password"], - rtp_stun_server_address=obj["stun_server"], + lambda: ConsoleMessageProtocol( + verbose=obj.get("verbose", 0), + transaction_class=EchoInviteTransaction, + aor=aor, + rtp=rtp_protocol, ), - proxy_addr, - obj["use_tls"], + aor.maddr, + aor.transport == "tls", obj["no_verify_tls"], ) @@ -332,7 +228,7 @@ def transcribe(ctx, stt_model): from .ai import TranscribeCall # noqa: PLC0415 obj = ctx.obj - proxy_addr = obj["proxy_addr"] + aor = obj["aor"] @dataclasses.dataclass(kw_only=True, slots=True) class TranscribingCall(TranscribeCall): @@ -341,30 +237,28 @@ class TranscribingCall(TranscribeCall): def transcription_received(self, text: str) -> None: click.echo(click.style(text, fg="green", bold=True)) - class TranscribeSession(ConsoleMessageProtocol): - verbose = obj.get("verbose", 0) - - def call_received(self, request) -> None: - self.ringing(request=request) - asyncio.create_task( - self.answer( - request=request, - call_class=TranscribingCall, - stt_model=WhisperModel(stt_model), - ) + class TranscribeInviteTransaction(InviteTransaction): + def invite_received(self, request: messages.Request) -> None: + self.ringing() + self.answer( + call_class=TranscribingCall, + stt_model=WhisperModel(stt_model), ) async def run(): + _, rtp_protocol = await _connect_rtp( + aor.maddr, + obj["stun_server"], + ) await _connect_sip( - lambda: TranscribeSession( - outbound_proxy=proxy_addr, - aor=obj["aor"], - username=obj["username"], - password=obj["password"], - rtp_stun_server_address=obj["stun_server"], + lambda: ConsoleMessageProtocol( + verbose=obj.get("verbose", 0), + transaction_class=TranscribeInviteTransaction, + aor=aor, + rtp=rtp_protocol, ), - proxy_addr, - obj["use_tls"], + aor.maddr, + aor.transport == "tls", obj["no_verify_tls"], ) @@ -414,7 +308,7 @@ def agent(ctx, stt_model, llm_model, voice, system_prompt): from .ai import AgentCall # noqa: PLC0415 obj = ctx.obj - proxy_addr = obj["proxy_addr"] + aor = obj["aor"] @dataclasses.dataclass(kw_only=True, slots=True) class AgentCallWithOutput(AgentCall): @@ -441,33 +335,31 @@ async def respond(self) -> None: self.msg_count = len(self._messages) await super().respond() - class AgentSession(ConsoleMessageProtocol): - verbose = obj.get("verbose", 0) - - def call_received(self, request) -> None: - self.ringing(request=request) - asyncio.create_task( - self.answer( - request=request, - call_class=AgentCallWithOutput, - stt_model=WhisperModel(stt_model), - llm_model=llm_model, - voice=voice, - system_prompt=system_prompt, - ) + class AgentInviteTransaction(InviteTransaction): + def invite_received(self, request: messages.Request) -> None: + self.ringing() + self.answer( + call_class=AgentCallWithOutput, + stt_model=WhisperModel(stt_model), + llm_model=llm_model, + voice=voice, + system_prompt=system_prompt, ) async def run(): + _, rtp_protocol = await _connect_rtp( + aor.maddr, + obj["stun_server"], + ) await _connect_sip( - lambda: AgentSession( - outbound_proxy=proxy_addr, - aor=obj["aor"], - username=obj["username"], - password=obj["password"], - rtp_stun_server_address=obj["stun_server"], + lambda: ConsoleMessageProtocol( + verbose=obj.get("verbose", 0), + transaction_class=AgentInviteTransaction, + aor=aor, + rtp=rtp_protocol, ), - proxy_addr, - obj["use_tls"], + aor.maddr, + aor.transport == "tls", obj["no_verify_tls"], ) diff --git a/voip/audio.py b/voip/audio.py index 5f20228..b2fbf51 100644 --- a/voip/audio.py +++ b/voip/audio.py @@ -14,7 +14,6 @@ import asyncio import dataclasses import datetime -import json import logging import secrets from collections.abc import Iterator @@ -26,7 +25,7 @@ import voip.codecs as codecs from voip.codecs import RTPCodec from voip.codecs.base import PayloadDecoder -from voip.rtp import RTPCall, RTPPacket +from voip.rtp import RTPPacket, Session from voip.sdp.types import MediaDescription __all__ = ["AudioCall", "EchoCall", "VoiceActivityCall"] @@ -45,7 +44,7 @@ def generate_ssrc() -> int: @dataclasses.dataclass(slots=True, kw_only=True) -class AudioCall(RTPCall): +class AudioCall(Session): """ RTP call handler for audio calls supporting Opus, G.722, PCMA, and PCMU. @@ -93,23 +92,6 @@ def __post_init__(self) -> None: self.payload_decoder = self.codec.create_decoder( self.sampling_rate_hz, input_rate_hz=self.sample_rate ) - logger.info( - json.dumps( - { - "event": "call_started", - "caller": repr(self.caller), - "codec": fmt.encoding_name, - "sample_rate": fmt.sample_rate or 0, - "channels": fmt.channels, - "payload_type": fmt.payload_type, - } - ), - extra={ - "caller": repr(self.caller), - "codec": fmt.encoding_name, - "payload_type": fmt.payload_type, - }, - ) @property def payload_type(self) -> int: diff --git a/voip/rtp.py b/voip/rtp.py index de0be4a..4d1ae22 100644 --- a/voip/rtp.py +++ b/voip/rtp.py @@ -9,7 +9,6 @@ import asyncio import dataclasses import enum -import ipaddress import json import logging import struct @@ -19,13 +18,13 @@ from voip.sdp.types import MediaDescription from voip.srtp import SRTPSession from voip.stun import STUNProtocol -from voip.types import ByteSerializableObject +from voip.types import ByteSerializableObject, NetworkAddress if TYPE_CHECKING: from voip.sip.protocol import SessionInitiationProtocol from voip.sip.types import CallerID -__all__ = ["RTP", "RTPCall", "RTPPacket", "RTPPayloadType", "RealtimeTransportProtocol"] +__all__ = ["RTP", "Session", "RTPPacket", "RTPPayloadType", "RealtimeTransportProtocol"] logger = logging.getLogger(__name__) @@ -44,7 +43,7 @@ class RTPPayloadType(enum.IntEnum): OPUS = 111 # RFC 7587 (dynamic) -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True, slots=True) class RTPPacket(ByteSerializableObject): """ RTP data packet [RFC 3550 §5.1]. @@ -90,7 +89,7 @@ def __bytes__(self) -> bytes: @dataclasses.dataclass -class RTPCall: +class Session: """One call leg managed by the RTP multiplexer. Associates a SIP dialog with the `RealtimeTransportProtocol` media @@ -117,7 +116,7 @@ class RTPCall: caller: CallerID srtp: SRTPSession | None = None - def packet_received(self, packet: RTPPacket, addr: tuple[str, int]) -> None: + def packet_received(self, packet: RTPPacket, addr: NetworkAddress) -> None: """Handle a parsed RTP packet. Override in subclasses to process media. Args: @@ -125,7 +124,7 @@ def packet_received(self, packet: RTPPacket, addr: tuple[str, int]) -> None: addr: Remote ``(host, port)`` the packet arrived from. """ - def send_packet(self, packet: RTPPacket, addr: tuple[str, int]) -> None: + def send_packet(self, packet: RTPPacket, addr: NetworkAddress) -> None: """Serialize *packet* and send it via the shared RTP socket. Encrypts the packet with the call's SRTP session when one is set. @@ -187,24 +186,22 @@ class RealtimeTransportProtocol(STUNProtocol): """ rtp_header_size: typing.ClassVar[int] = 12 - calls: dict[tuple[str, int] | None, RTPCall] = dataclasses.field( + calls: dict[tuple[str, int] | None, Session] = dataclasses.field( init=False, default_factory=dict ) - public_address: asyncio.Future[ - tuple[ipaddress.IPv4Address | ipaddress.IPv6Address, int] - ] = dataclasses.field(init=False, default_factory=asyncio.Future) + public_address: NetworkAddress | None = dataclasses.field(init=False, default=None) def stun_connection_made( self, transport: asyncio.DatagramTransport, - addr: tuple[ipaddress.IPv4Address | ipaddress.IPv6Address, int], + addr: NetworkAddress, ) -> None: - self.public_address.set_result(addr) + self.public_address = addr def register_call( self, - addr: tuple[str, int] | None, - handler: RTPCall, + addr: NetworkAddress | None, + handler: Session, ) -> None: """Register *handler* for RTP traffic arriving from *addr*. @@ -231,7 +228,7 @@ def register_call( ) self.calls[addr] = handler - def unregister_call(self, addr: tuple[str, int] | None) -> None: + def unregister_call(self, addr: NetworkAddress | None) -> None: """Remove the handler registered for *addr*. Args: @@ -250,7 +247,7 @@ def unregister_call(self, addr: tuple[str, int] | None) -> None: ) self.calls.pop(addr) - def packet_received(self, data: bytes, addr: tuple[str, int]) -> None: + def packet_received(self, data: bytes, addr: NetworkAddress) -> None: """Route an incoming SRTP datagram to the matching per-call handler. Looks up *addr* in the call registry. Falls back to the wildcard diff --git a/voip/sdp/messages.py b/voip/sdp/messages.py index 597e8d1..d86c5aa 100644 --- a/voip/sdp/messages.py +++ b/voip/sdp/messages.py @@ -44,7 +44,7 @@ FIELD_BY_LETTER: dict[str, Field] = {field.letter: field for field in FIELD_MAP} -@dataclasses.dataclass +@dataclasses.dataclass(slots=True) class SessionDescription(ByteSerializableObject): """Session Description Protocol message [RFC 4566]. diff --git a/voip/sdp/types.py b/voip/sdp/types.py index 8df437a..78d77e7 100644 --- a/voip/sdp/types.py +++ b/voip/sdp/types.py @@ -38,7 +38,7 @@ def parse(value: str) -> object: """Parse a raw SDP line value.""" -@dataclasses.dataclass(slots=True) +@dataclasses.dataclass(slots=True, frozen=True) class StrField: """Descriptor for SDP fields that parse and serialize as plain strings.""" @@ -52,7 +52,7 @@ def parse(value: str) -> str: return value -@dataclasses.dataclass(slots=True) +@dataclasses.dataclass(slots=True, frozen=True) class IntField: """Descriptor for SDP fields that parse and serialize as integers.""" @@ -66,7 +66,7 @@ def parse(value: str) -> int: return int(value) -@dataclasses.dataclass(slots=True) +@dataclasses.dataclass(slots=True, frozen=True) class Origin(ByteSerializableObject): """Origin field (o=) as defined by RFC 4566 §5.2.""" @@ -104,7 +104,7 @@ def parse(cls, data: bytes | str) -> Origin: ) -@dataclasses.dataclass(slots=True) +@dataclasses.dataclass(slots=True, frozen=True) class ConnectionData(ByteSerializableObject): """Connection data field (c=) as defined by RFC 4566 §5.7.""" @@ -131,7 +131,7 @@ def parse(cls, data: bytes | str) -> ConnectionData: ) -@dataclasses.dataclass(slots=True) +@dataclasses.dataclass(slots=True, frozen=True) class Bandwidth(ByteSerializableObject): """Bandwidth field (b=) as defined by RFC 4566 §5.8.""" @@ -153,7 +153,7 @@ def parse(cls, data: bytes | str) -> Bandwidth: return cls(bwtype=bwtype, bandwidth=int(bandwidth)) -@dataclasses.dataclass(slots=True) +@dataclasses.dataclass(slots=True, frozen=True) class Timing(ByteSerializableObject): """Timing field (t=) as defined by RFC 4566 §5.9.""" @@ -175,7 +175,7 @@ def parse(cls, data: bytes | str) -> Timing: return cls(start_time=int(start_time), stop_time=int(stop_time)) -@dataclasses.dataclass(slots=True) +@dataclasses.dataclass(slots=True, frozen=True) class Attribute(ByteSerializableObject): """Attribute field (a=) as defined by RFC 4566 §5.13.""" diff --git a/voip/sip/__init__.py b/voip/sip/__init__.py index 4fd9cac..10d5f49 100644 --- a/voip/sip/__init__.py +++ b/voip/sip/__init__.py @@ -6,6 +6,7 @@ from .messages import Message, Request, Response from .protocol import SessionInitiationProtocol +from .transactions import InviteTransaction, RegistrationTransaction from .types import CallerID, SIPMethod, SIPStatus, SipUri __all__ = [ @@ -13,6 +14,8 @@ "Request", "Response", "SessionInitiationProtocol", + "InviteTransaction", + "RegistrationTransaction", "CallerID", "SipUri", "SIPStatus", diff --git a/voip/sip/exceptions.py b/voip/sip/exceptions.py new file mode 100644 index 0000000..fb477fc --- /dev/null +++ b/voip/sip/exceptions.py @@ -0,0 +1,9 @@ +from __future__ import annotations + + +class RegistrationError(Exception): + """Raised when a SIP REGISTER request fails with an unexpected response. + + The exception message includes the response status code and reason phrase + from the server, e.g. ``"403 Forbidden"`` or ``"500 Server Error"``. + """ diff --git a/voip/sip/messages.py b/voip/sip/messages.py index 73cee3e..fe5cd7f 100644 --- a/voip/sip/messages.py +++ b/voip/sip/messages.py @@ -4,23 +4,22 @@ import abc import dataclasses -import typing +import datetime +import socket +import uuid from voip.sdp.messages import SessionDescription from ..types import ByteSerializableObject -from .types import CallerID, SIPMethod, SIPStatus +from .types import CallerID, SIPMethod, SIPStatus, SipUri -if typing.TYPE_CHECKING: - from . import SipUri - -__all__ = ["Request", "Response", "Message"] +__all__ = ["Request", "Response", "Message", "Dialog"] #: Headers whose values are parsed as `CallerID` objects. _CALLER_HEADERS = frozenset({"From", "To"}) -@dataclasses.dataclass(kw_only=True) +@dataclasses.dataclass(slots=True, kw_only=True) class Message(ByteSerializableObject, abc.ABC): """ A SIP message [RFC 3261 §7]. @@ -28,7 +27,9 @@ class Message(ByteSerializableObject, abc.ABC): [RFC 3261 §7]: https://datatracker.ietf.org/doc/html/rfc3261#section-7 """ - headers: dict[str, str] = dataclasses.field(default_factory=dict) + headers: dict[str, str | CallerID] = dataclasses.field( + default_factory=dict, repr=False + ) body: SessionDescription | None = dataclasses.field(default=None, repr=False) version: str = "SIP/2.0" @@ -84,6 +85,31 @@ def __bytes__(self) -> bytes: ) return f"{self._first_line()}\r\n{header_lines}\r\n".encode() + raw_body + @property + def branch(self) -> str | None: + """Branch parameter from the top Via header (RFC 3261 §20.42). + + Falls back to the Call-ID when the Via header contains no branch + (RFC 2543 compatibility). + """ + _, uri = self.headers["Via"].split() + return SipUri.parse(f"sip:{uri}").parameters["branch"] + + @property + def remote_tag(self) -> str | None: + """To-tag used with From-tag to identify the SIP dialog (RFC 3261 §12.2.2).""" + return self.headers["To"].tag + + @property + def local_tag(self) -> str: + """From-tag used with To-tag to identify the SIP dialog (RFC 3261 §12.2.2).""" + return self.headers["From"].tag + + @property + def sequence(self) -> int: + """Sequence number a transaction within a dialog.""" + return int(self.headers["CSeq"].split()[0]) + @abc.abstractmethod def _first_line(self) -> str: ... @@ -102,6 +128,14 @@ class Request(Message): def _first_line(self) -> str: return f"{self.method} {self.uri} {self.version}" + @classmethod + def from_dialog(cls, *, dialog: Dialog, headers, **kwargs) -> Request: + """Create a request from a dialog, copying relevant headers.""" + return cls( + headers=headers | dialog.headers, + **kwargs, + ) + @dataclasses.dataclass(kw_only=True) class Response(Message): @@ -116,3 +150,86 @@ class Response(Message): def _first_line(self) -> str: return f"{self.version} {self.status_code} {self.phrase}" + + @classmethod + def from_request( + cls, request: Request, *, headers=None, dialog: Dialog = None, **kwargs + ) -> Response: + """Create a response from a request, copying relevant headers.""" + headers = { + "Via": request.headers["Via"], + "From": request.headers["From"], + "To": f"{request.headers['To']};tag={dialog.remote_tag}" + if dialog and dialog.remote_tag + else request.headers["To"], + "Call-ID": request.headers["Call-ID"], + "CSeq": request.headers["CSeq"], + } | (headers or {}) + return cls(headers=headers, **kwargs) + + +@dataclasses.dataclass(kw_only=True, slots=True) +class Dialog: + """ + Peer-to-peer SIP relationship between two user agents. + + A dialog is identified by the tuple of (Call-ID, From tag, To tag) and + established by a non-final response to the INVITE, see also: [RFC 3261 §12] + + [RFC 3261 $12]: https://datatracker.ietf.org/doc/html/rfc3261#section-12 + + Args: + uac: The user agent that initiated the dialog. + call_id: The Call-ID header value for this dialog. + local_tag: The From-header tag parameter value for this dialog. + remote_tag: The To-header tag parameter value for this dialog. + + """ + + uac: SipUri | None = None + call_id: str = dataclasses.field( + default_factory=lambda: f"{uuid.uuid4()}@{socket.gethostname()}", + compare=False, + ) + local_tag: str = dataclasses.field( + default_factory=lambda: str(uuid.uuid4()), compare=True + ) + remote_tag: str | None = dataclasses.field(default=None, compare=True) + remote_contact: SipUri | None = dataclasses.field(default=None, compare=True) + route_set: list[SipUri] = dataclasses.field(default_factory=list) + + created: datetime.datetime = dataclasses.field( + init=False, default_factory=datetime.datetime.now + ) + + @property + def from_header(self) -> str: + """The logical sender of a request.""" + return f"{self.uac.scheme}:{self.uac.user}@{socket.gethostname()};tag={self.local_tag}" + + @property + def to_header(self) -> str: + """The logical recipient of a request.""" + part = f"{self.uac.scheme}:{self.uac.user}@{self.uac.host}:{self.uac.port};transport={self.uac.parameters.get('transport', 'TLS')}" + if self.remote_tag: + part += f";tag={self.remote_tag}" + return part + + @property + def headers(self) -> dict[str, str]: + """Return a dict of headers for this dialog.""" + return { + "From": self.from_header, + "To": self.to_header, + "Call-ID": self.call_id, + } + + @classmethod + def from_request(cls, request: Request) -> Dialog: + """Create a dialog from a request, extracting relevant headers.""" + return cls( + call_id=request.headers["Call-ID"], + local_tag=request.local_tag, + remote_tag=request.remote_tag or str(uuid.uuid4()), + remote_contact=request.headers.get("Contact"), + ) diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 3428309..7d7de5c 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -7,111 +7,49 @@ from __future__ import annotations import asyncio -import collections import dataclasses import datetime -import hashlib import ipaddress -import json import logging -import re -import secrets -import socket import typing -import uuid - -from voip.rtp import RealtimeTransportProtocol, RTPCall -from voip.sdp.messages import SessionDescription -from voip.sdp.types import ( - Attribute, - ConnectionData, - MediaDescription, - Origin, - RTPPayloadFormat, - Timing, -) -from voip.srtp import SRTPSession - -from .messages import Message, Request, Response -from .types import CallerID, DigestAlgorithm, DigestQoP, SIPStatus - -logger = logging.getLogger("voip.sip") - -__all__ = ["RegistrationError", "SIP", "SessionInitiationProtocol"] - - -def _format_host(host: str | ipaddress.IPv4Address | ipaddress.IPv6Address) -> str: - """Return *host* wrapped in brackets when it is an IPv6 address. - - RFC 3261 §19.1.1 and RFC 2732 require IPv6 addresses in SIP URIs and - Via/Contact headers to be enclosed in square brackets. - - Args: - host: Host as a typed IP address object or bare host string. - - Returns: - ``[host]`` for IPv6 addresses, *host* unchanged otherwise. - """ - if isinstance(host, ipaddress.IPv6Address): - return f"[{host}]" - if isinstance(host, ipaddress.IPv4Address): - return str(host) - try: - addr = ipaddress.ip_address(host) - return f"[{addr}]" if isinstance(addr, ipaddress.IPv6Address) else host - except ValueError: - return host - - -class RegistrationError(Exception): - """Raised when a SIP REGISTER request fails with an unexpected response. - - The exception message includes the response status code and reason phrase - from the server, e.g. ``"403 Forbidden"`` or ``"500 Server Error"``. - """ +from voip.rtp import RealtimeTransportProtocol -def _mask_caller(header: str) -> str: - """Return a privacy-safe label from a SIP From/To header value. +from ..types import NetworkAddress +from . import types +from .messages import Dialog, Message, Request, Response +from .transactions import InviteTransaction, RegistrationTransaction, Transaction +from .types import ( + SIPMethod, + SIPStatus, +) - Strips the `tag=` parameter, extracts the display name or SIP user part, - and replaces all but the last four characters with `*`. +logger = logging.getLogger("voip.sip") - Examples: - ``` - >>> _mask_caller('"08001234567" ;tag=abc') - '*******4567' - >>> _mask_caller('sip:alice@example.com') - '*lice' - ``` - """ - # Drop the tag and any subsequent parameters - value = header.split(";")[0].strip() - # Extract display name: "Name" or Name - m = re.match(r'^"?([^"<]+?)"?\s*<', value) - name = m.group(1).strip() if m else None - if not name: - # Bare or angle-bracket URI: sip:user@host or - m = re.search(r"sips?:([^@>;\s]+)", value) - name = m.group(1) if m else value - if len(name) > 4: - return "*" * (len(name) - 4) + name[-4:] - return name +__all__ = [ + "SIP", + "SessionInitiationProtocol", + "InviteTransaction", + "RegistrationTransaction", +] @dataclasses.dataclass(kw_only=True, slots=True) class SessionInitiationProtocol(asyncio.Protocol): """ - SIP User Agent Client (UAC) over TLS/TCP [RFC 3261][RFC 3261]. + SIP User Agent Client (UAC) over TLS/TCP [RFC 3261]. Handles incoming calls and, optionally, carrier registration with digest authentication [RFC 3261 §22]. All signaling is sent over a single persistent TLS/TCP connection. ```python - class MySession(SessionInitiationProtocol): + class MyTransaction(Transaction): def call_received(self, request: Request) -> None: - self.answer(request=request, call_class=MyCall) + asyncio.create_task(self.answer(call_class=MyCall)) + + class MySession(SessionInitiationProtocol): + transaction_class = MyTransaction ``` To register with a carrier on startup, pass the registration parameters: @@ -121,8 +59,6 @@ def call_received(self, request: Request) -> None: aor="sips:alice@example.com", username="alice", password="secret", - # Optional: connect via a separate outbound proxy - # outbound_proxy=("proxy.carrier.com", 5061), ) ``` @@ -132,143 +68,111 @@ def call_received(self, request: Request) -> None: Attributes: VIA_BRANCH_PREFIX: RFC 3261 §8.1.1.7 Via branch magic cookie (indicates RFC 3261 compliance). - ALLOW: - RFC 3261 §11 – methods supported by this UA (used in Allow header). Args: + aor: SIP Address of Record (AOR) to register with the carrier, e.g. + rtp: Shared RTP mux for call media. When provided, call handlers can register + their RTP addresses with the mux to receive media packets. + transaction_class: Transaction subclass to handle SIP transactions. + registration_class: Transaction subclass to handle registration transactions. keepalive_interval: Keep-alive ping interval. Should be between 30 and 90 seconds. """ - #: RFC 3261 §8.1.1.7 Via branch magic cookie (indicates RFC 3261 compliance). VIA_BRANCH_PREFIX: typing.ClassVar[str] = "z9hG4bK" - #: RFC 3261 §11 – methods supported by this UA (used in Allow header). - ALLOW: typing.ClassVar[str] = "INVITE, ACK, BYE, CANCEL, OPTIONS" + aor: types.SipUri + rtp: RealtimeTransportProtocol + transaction_class: type[InviteTransaction] + registration_class: type[RegistrationTransaction] = RegistrationTransaction + keepalive_interval: datetime.timedelta = datetime.timedelta(seconds=30) - _pending_invites: set[str] = dataclasses.field(init=False, default_factory=set) - _answered_calls: collections.OrderedDict[str, None] = dataclasses.field( - init=False, default_factory=collections.OrderedDict - ) - answered_call_backlog: int = 1000 - _to_tags: dict[str, str] = dataclasses.field(init=False, default_factory=dict) - _rtp_protocol: RealtimeTransportProtocol | None = dataclasses.field( - init=False, default=None - ) - _rtp_transport: asyncio.DatagramTransport | None = dataclasses.field( - init=False, default=None + keepalive_task: asyncio.Task | None = dataclasses.field(init=False, default=None) + local_address: NetworkAddress = dataclasses.field(init=False) + dialogs: dict[tuple[str, str], Dialog] = dataclasses.field( + init=False, default_factory=dict ) - _initialize_task: asyncio.Task | None = dataclasses.field(init=False, default=None) - _keepalive_task: asyncio.Task | None = dataclasses.field(init=False, default=None) - _call_rtp_addrs: dict[str, tuple[str, int] | None] = dataclasses.field( + transactions: dict[str, Transaction] = dataclasses.field( init=False, default_factory=dict ) - _buffer: bytearray = dataclasses.field(init=False, default_factory=bytearray) disconnected_event: asyncio.Event = dataclasses.field( init=False, default_factory=asyncio.Event ) - #: RFC 3261 §8.1.2 — outbound SIP proxy address ``(host, port)``. - #: When ``None`` the caller connects directly to the registrar server. - #: The address may differ from the registrar domain derived from - #: `aor` (e.g. ``proxy.carrier.com`` vs ``carrier.com``). - outbound_proxy: ( - tuple[ipaddress.IPv4Address | ipaddress.IPv6Address | str, int] | None - ) = None - aor: str - username: str | None = None - password: str | None = None - #: STUN server used for RTP NAT traversal (SIP uses TLS/TCP; no STUN needed). - rtp_stun_server_address: tuple[str, int] | None = ("stun.cloudflare.com", 3478) - keepalive_interval: datetime.timedelta = datetime.timedelta(seconds=30) - call_id: str = dataclasses.field(init=False) - cseq: int = dataclasses.field(init=False, default=0) - #: Local TCP socket address (host, port) — set when connection is established. - local_address: tuple[ipaddress.IPv4Address | ipaddress.IPv6Address, int] = ( - dataclasses.field(init=False) - ) transport: asyncio.Transport | None = dataclasses.field(init=False, default=None) - #: True when the underlying transport is TLS-wrapped; False for plain TCP. - _is_tls: bool = dataclasses.field(init=False, default=False) - - def __post_init__(self): - self.call_id = f"{uuid.uuid4()}@{socket.gethostname()}" + is_secure: bool = dataclasses.field(init=False, default=False) def connection_made(self, transport: asyncio.Transport) -> None: # type: ignore[override] """Store the TLS/TCP transport and start RTP mux + carrier registration.""" self.transport = transport # IPv6 sockets return a 4-tuple (host, port, flowinfo, scope_id); # we only need the first two elements. - sockname = transport.get_extra_info("sockname") - host, port = sockname[0], sockname[1] - self.local_address = (ipaddress.ip_address(host), port) - self._is_tls = transport.get_extra_info("ssl_object") is not None + host, port = transport.get_extra_info("sockname") + self.local_address = NetworkAddress(ipaddress.ip_address(host), port) + self.is_secure = transport.get_extra_info("ssl_object") is not None try: loop = asyncio.get_running_loop() - self._initialize_task = loop.create_task(self._initialize()) - self._keepalive_task = loop.create_task(self._run_keepalive()) + tx = RegistrationTransaction(sip=self, method=SIPMethod.REGISTER) + self.transactions[tx.branch] = tx + self.keepalive_task = loop.create_task(self.send_keepalive()) except RuntimeError: pass # no running loop in synchronous test setups - async def _initialize(self) -> None: - loop = asyncio.get_running_loop() - rtp_bind = ( - "::" - if isinstance(self.local_address[0], ipaddress.IPv6Address) - else "0.0.0.0" # noqa: S104 - ) - self._rtp_transport, self._rtp_protocol = await loop.create_datagram_endpoint( - lambda: RealtimeTransportProtocol( - stun_server_address=self.rtp_stun_server_address - ), - local_addr=(rtp_bind, 0), - ) - await self.register() - - async def _run_keepalive(self) -> None: + async def send_keepalive(self) -> None: while True: await asyncio.sleep(self.keepalive_interval.total_seconds()) if self.transport is None: return - logger.debug("Sending RFC 5626 §4.4.1 keep-alive ping") + logger.info("PING", extra={"addr": self.local_address}) self.transport.write(b"\r\n\r\n") def data_received(self, data: bytes) -> None: - self._buffer.extend(data) - while True: - end_of_headers = self._buffer.find(b"\r\n\r\n") - if end_of_headers == -1: - break - header_bytes = bytes(self._buffer[:end_of_headers]) - # Determine body length from Content-Length header. - content_length = 0 - for line in header_bytes.decode(errors="replace").split("\r\n")[1:]: - low = line.lower() - if low.startswith("content-length:"): - try: - content_length = int(line.split(":", 1)[1].strip()) - except ValueError: - pass - break - message_end = end_of_headers + 4 + content_length - if len(self._buffer) < message_end: - break - message_data = bytes(self._buffer[:message_end]) - del self._buffer[:message_end] - addr = self.transport.get_extra_info("peername") if self.transport else None - self.packet_received(message_data, addr) - - def packet_received(self, data: bytes, addr: tuple[str, int] | None) -> None: - """Handle RFC 5626 keepalive pings, then dispatch SIP messages.""" - if data == b"\r\n\r\n": # RFC 5626 §4.4.1 double-CRLF keepalive ping - logger.debug("RFC 5626 keepalive from %s, sending pong", addr) - if self.transport: - self.transport.write(b"\r\n") - return + match data: + case b"\r\n": + logger.info( + "PONG", + extra={ + "addr": NetworkAddress( + *self.transport.get_extra_info("peername") + ) + }, + ) + return + case b"\r\n\r\n": + logger.info( + "PING", + extra={ + "addr": NetworkAddress( + *self.transport.get_extra_info("peername") + ) + }, + ) + if self.transport: + logger.info("PONG", extra={"addr": self.local_address}) + self.transport.write(b"\r\n") + return match Message.parse(data): case Request() as request: - self.request_received(request, addr) + logger.info( + "Request received: %r", + request, + extra={ + "addr": NetworkAddress( + *self.transport.get_extra_info("peername") + ) + }, + ) + self.request_received(request) case Response() as response: - self.response_received(response, addr) + logger.info( + "Response received %r", + response, + extra={ + "addr": NetworkAddress( + *self.transport.get_extra_info("peername") + ) + }, + ) + self.response_received(response) def send(self, message: Response | Request) -> None: """Serialize and send a SIP message over the TLS/TCP connection.""" @@ -280,438 +184,101 @@ def close(self) -> None: """Close the TLS/TCP transport and the RTP mux.""" if self.transport is not None: self.transport.close() - if self._rtp_transport is not None: - self._rtp_transport.close() - - def _cleanup_rtp_call(self, call_id: str) -> None: - """Remove the call handler registered with the shared RTP mux, if any.""" - if call_id in self._call_rtp_addrs and self._rtp_protocol is not None: - self._rtp_protocol.unregister_call(self._call_rtp_addrs.pop(call_id)) - - def _mark_call_answered(self, call_id: str) -> None: - """Record *call_id* as answered, evicting the oldest entry if the LRU is full.""" - if call_id in self._answered_calls: - self._answered_calls.move_to_end(call_id) - else: - if len(self._answered_calls) >= self.answered_call_backlog: - self._answered_calls.popitem(last=False) - self._answered_calls[call_id] = None - - def request_received(self, request: Request, addr: tuple[str, int]) -> None: - """Dispatch a received SIP request to the appropriate handler.""" - call_id = request.headers.get("Call-ID", "") - peer_ip = addr[0] if addr else None - match request.method: - case "INVITE": - caller = CallerID(request.headers.get("From", "")) - logger.info( - json.dumps( - { - "event": "incoming_call", - "caller": repr(caller), - "ip": peer_ip, - "call_id": call_id, - } - ), - extra={"caller": repr(caller), "ip": peer_ip, "call_id": call_id}, - ) - if call_id in self._answered_calls: - logger.debug( - "Ignoring INVITE retransmission for Call-ID %r", call_id - ) - return - # Mark immediately (before async answering) so retransmissions - # that arrive while RTP setup is in progress are suppressed. - self._mark_call_answered(call_id) - self._pending_invites.add(call_id) - self._to_tags[call_id] = secrets.token_hex(8) - self.call_received(request) - case "ACK": - self.ack_received(request) - case "BYE": - self._answered_calls.pop(call_id, None) - caller = CallerID(request.headers.get("From", "")) - logger.info( - json.dumps( - { - "event": "call_ended", - "caller": repr(caller), - "ip": peer_ip, - "call_id": call_id, - } - ), - extra={"caller": repr(caller), "ip": peer_ip, "call_id": call_id}, - ) - self.send( - Response( - status_code=SIPStatus.OK, - phrase=SIPStatus.OK.phrase, - headers=self._with_to_tag( - { - key: value - for key, value in request.headers.items() - if key in ("Via", "To", "From", "Call-ID", "CSeq") - }, - call_id, - ), - ), - ) - self._to_tags.pop(call_id, None) - self._cleanup_rtp_call(call_id) - self.bye_received(request) - case "CANCEL": - caller = CallerID(request.headers.get("From", "")) - logger.info( - json.dumps( - { - "event": "call_cancelled", - "caller": repr(caller), - "ip": peer_ip, - "call_id": call_id, - } - ), - extra={"caller": repr(caller), "ip": peer_ip, "call_id": call_id}, - ) - self.send( - Response( - status_code=SIPStatus.OK, - phrase=SIPStatus.OK.phrase, - headers={ - key: value - for key, value in request.headers.items() - if key in ("Via", "To", "From", "Call-ID", "CSeq") - }, - ), - ) - if call_id in self._pending_invites: - self._pending_invites.discard(call_id) - self.send( - Response( - status_code=SIPStatus.REQUEST_TERMINATED, - phrase=SIPStatus.REQUEST_TERMINATED.phrase, - headers=self._with_to_tag( - { - key: value - for key, value in request.headers.items() - if key in ("Via", "To", "From", "Call-ID", "CSeq") - }, - call_id, - ), - ), - ) - self._answered_calls.pop(call_id, None) - self._to_tags.pop(call_id, None) - self._cleanup_rtp_call(call_id) - self.cancel_received(request) - case _: - raise NotImplementedError( - f"Unsupported SIP request method: {request.method}" - ) - def response_received( - self, response: Response, addr: tuple[str, int] | None - ) -> None: - """Handle REGISTER responses including digest auth challenges (RFC 3261 §22). - - Only processes responses when registration parameters are configured. - """ - if response.status_code == SIPStatus.OK and response.headers.get( - "CSeq", "" - ).split()[-1:] == ["REGISTER"]: - logger.info("Registration successful") - self.registered() - return - if response.status_code in ( - SIPStatus.UNAUTHORIZED, - SIPStatus.PROXY_AUTHENTICATION_REQUIRED, - ): - if not self.username or not self.password: - logger.error( - "Auth challenge received but username/password are not configured" - ) - return - logger.debug( - "Auth challenge received (%s), retrying with credentials", - response.status_code, - ) - is_proxy = response.status_code == SIPStatus.PROXY_AUTHENTICATION_REQUIRED - challenge_key = "Proxy-Authenticate" if is_proxy else "WWW-Authenticate" - params = self.parse_auth_challenge(response.headers.get(challenge_key, "")) - realm = params.get("realm", "") - nonce = params.get("nonce", "") - opaque = params.get("opaque") - algorithm = params.get("algorithm", DigestAlgorithm.SHA_256) - qop_options = params.get("qop", "") - qop = ( - DigestQoP.AUTH.value - if DigestQoP.AUTH.value in qop_options.split(",") - else None - ) - nc = "00000001" - cnonce = secrets.token_hex(8) if qop else None - digest = self.digest_response( - username=self.username, - password=self.password, - realm=realm, - nonce=nonce, - method="REGISTER", - uri=self.registrar_uri, - algorithm=algorithm, - qop=qop, - nc=nc, - cnonce=cnonce, - ) - auth_value = ( - f'Digest username="{self.username}", realm="{realm}", ' - f'nonce="{nonce}", uri="{self.registrar_uri}", ' - f'response="{digest}", algorithm="{algorithm}"' - ) - if qop: - auth_value += f', qop={qop}, nc={nc}, cnonce="{cnonce}"' - if opaque: - auth_value += f', opaque="{opaque}"' - if is_proxy: - asyncio.create_task(self.register(proxy_authorization=auth_value)) - else: - asyncio.create_task(self.register(authorization=auth_value)) - return - raise RegistrationError(f"{response.status_code} {response.phrase}") - - def call_received(self, request: Request) -> None: - """Handle an incoming call. - - Override in subclasses to accept or reject the call: - - ```python - def call_received(self, request: Request) -> None: - self.answer(request=request, call_class=MyCall) - ``` - - Args: - request: The SIP INVITE request. - """ - - def ack_received(self, request: Request) -> None: - """Handle an ACK confirming dialog establishment. + @property + def allowed_methods(self) -> frozenset[SIPMethod]: + """SIP methods supported by this UA. - Override in subclasses to react to the ACK. + A method is included when the class defines a ``_received`` + handler (e.g. ``register_received`` enables REGISTER). - Args: - request: The SIP ACK request. + Returns: + Frozenset of [`SIPMethod`][voip.sip.types.SIPMethod] values. """ + return frozenset( + ( + *( + m + for m in SIPMethod + if hasattr(self.transaction_class, f"{m.lower()}_received") + ), + "OPTIONS", + ) + ) - def bye_received(self, request: Request) -> None: - """Handle a BYE terminating a dialog. - - Override in subclasses to tear down the call. - - Args: - request: The SIP BYE request. - """ + @property + def allow_header(self) -> str: + """Comma-separated Allow header value in SIPMethod enum order.""" + return ",".join(m for m in SIPMethod if m in self.allowed_methods) - def cancel_received(self, request: Request) -> None: - """Handle a CANCEL request for a pending INVITE. + def method_not_allowed(self, request: Request) -> None: + """Respond with 405 Method Not Allowed. - Override in subclasses to react to caller cancellation before the call - is answered. + Override to customise the error response or add logging. Args: - request: The SIP CANCEL request. + request: The unhandled SIP request. """ + logger.warning("SIP method %r is not supported", request.method) + dialog_headers = { + key: value + for key, value in request.headers.items() + if key in ("Via", "To", "From", "Call-ID", "CSeq") + } + self.send( + Response( + status_code=SIPStatus.METHOD_NOT_ALLOWED, + phrase=SIPStatus.METHOD_NOT_ALLOWED.phrase, + headers={**dialog_headers, "Allow": self.allow_header}, + ), + ) - async def answer( - self, request: Request, *, call_class: type[RTPCall], **call_kwargs: typing.Any - ) -> None: - """Answer an incoming call by setting up RTP and sending 200 OK with SDP. - - Example: - This coroutine can be awaited directly or wrapped in a task: - - ```python - # inside a sync call_received: - asyncio.create_task(self.answer(request=request, call_class=MyCall)) + def request_received(self, request: Request) -> None: + """Dispatch request to transaction methods.""" + match request.method: + case SIPMethod.CANCEL: + try: + tx = self.transactions[request.branch] + except KeyError: + self.send( + Response.from_request( + request, + status_code=SIPStatus.GONE, + phrase=SIPStatus.GONE.phrase, + ) + ) + return + case SIPMethod.OPTIONS: + self.send( + Response.from_request( + request, + status_code=SIPStatus.OK, + phrase=SIPStatus.OK.phrase, + headers={"Allow": self.allow_header}, + ) + ) + return + case _: + tx = self.transaction_class.from_request(request=request, sip=self) + self.transactions[request.branch] = tx + try: + handler: typing.Callable[[Request], Response | None] = getattr( + tx, f"{request.method.lower()}_received" + ) + except AttributeError: + handler = self.method_not_allowed + handler(request) - # inside an async call_received: - await self.answer(request=request, call_class=MyCall) - ``` + def response_received(self, response: Response) -> None: + """Delegate REGISTER responses to the registration transaction. Args: - request: The SIP INVITE request (from `call_received`). - call_class: A `Call` subclass whose `negotiate_codec` selects the codec. - The class is constructed with ``rtp``, ``sip``, ``caller``, - and ``media`` keyword arguments. - call_kwargs: Optional additional keyword arguments to pass to the call class constructor. - - Raises: - NotImplementedError: When `negotiate_codec` raises (no supported codec in the remote SDP offer). + response: The parsed SIP response. """ - call_id = request.headers.get("Call-ID", "") - if call_id not in self._pending_invites: - logger.error("No pending INVITE found for Call-ID %r", call_id) - return - self._pending_invites.discard(call_id) - # Ensure the RTP mux has been created before answering. Under normal - # operation _initialize() completes before any INVITE arrives, but an - # early INVITE must wait for the mux. Skip if already available. - if self._rtp_protocol is None: - if self._initialize_task is not None: - await self._initialize_task - if self._rtp_protocol is None: - logger.error("RTP mux not ready; cannot answer call") - return - peer = self.transport.get_extra_info("peername") if self.transport else None - caller = CallerID(request.headers.get("From", "")) - logger.info( - json.dumps( - { - "event": "call_answered", - "caller": repr(caller), - "ip": peer[0] if peer else None, - "call_id": call_id, - } - ), - extra={ - "caller": repr(caller), - "ip": peer[0] if peer else None, - "call_id": call_id, - }, - ) - remote_audio = next( - ( - m - for m in (request.body.media if request.body else []) - if m.media == "audio" - ), - None, - ) - # Codec negotiation is delegated to the call class. If the remote SDP - # offers no supported codec, negotiate_codec raises NotImplementedError - # and the exception propagates — the call is not answered. - if remote_audio is not None: - negotiated_media = call_class.negotiate_codec(remote_audio) - else: - negotiated_media = MediaDescription( - media="audio", - port=0, - proto="RTP/SAVP", - fmt=[RTPPayloadFormat.from_pt(0)], - ) - - # Generate a fresh SRTP session only when the negotiated transport is SRTP. - use_srtp = negotiated_media.proto == "RTP/SAVP" - srtp_session = SRTPSession.generate() if use_srtp else None - - # Instantiate the per-call handler and register it with the shared mux. - call_handler = call_class( - rtp=self._rtp_protocol, - sip=self, - caller=caller, - media=negotiated_media, - srtp=srtp_session, - **call_kwargs, - ) - # Determine the remote RTP address for routing. - # Per RFC 4566 §5.7 the effective connection address is taken from the - # media-level c= line first, then the session-level c= line, then the - # SIP peer IP as last resort. - # - # When the media port is 0, the stream is inactive (RFC 4566 §5.14); - # registering an address and hole-punching are skipped, and we fall - # through to ``remote_rtp_addr = None`` so the mux wildcard is used - # (if any traffic arrives at all). - # - # When no SDP was present in the INVITE we also use the wildcard so - # the mux delivers all unmatched traffic to this handler. - if remote_audio is not None and remote_audio.port != 0: - media_conn = remote_audio.connection - session_conn = request.body.connection if request.body else None - conn = media_conn or session_conn - if conn is not None: - remote_ip = conn.connection_address - else: - remote_ip = peer[0] if peer else "0.0.0.0" # noqa: S104 - remote_rtp_addr: tuple[str, int] | None = (remote_ip, remote_audio.port) - else: - remote_rtp_addr = None - self._rtp_protocol.register_call(remote_rtp_addr, call_handler) - self._call_rtp_addrs[call_id] = remote_rtp_addr - - # NAT hole-punch: send a dummy datagram to the carrier's RTP address so - # that our router creates a return-path mapping allowing the carrier's - # media packets to reach our UDP socket (RFC 4787 / address-restricted NAT). - if remote_rtp_addr is not None: - self._rtp_protocol.send(b"\x00", remote_rtp_addr) - - record_route = request.headers.get("Record-Route") - sess_id = str(secrets.randbelow(2**32) + 1) - rtp_public = await self._rtp_protocol.public_address - sdp_media_attributes = [Attribute(name="sendrecv")] - if srtp_session is not None: - sdp_media_attributes.append( - Attribute(name="crypto", value=srtp_session.sdes_attribute) - ) - self.send( - Response( - status_code=SIPStatus.OK, - phrase=SIPStatus.OK.phrase, - headers={ - **self._with_to_tag( - { - key: value - for key, value in request.headers.items() - if key in ("Via", "To", "From", "Call-ID", "CSeq") - }, - call_id, - ), - **({"Record-Route": record_route} if record_route else {}), - "Contact": self._build_contact(), - "Allow": self.ALLOW, - "Supported": "replaces", - "Content-Type": "application/sdp", - }, - body=SessionDescription( - origin=Origin( - username="-", - sess_id=sess_id, - sess_version=sess_id, - nettype="IN", - addrtype="IP6" - if isinstance(rtp_public[0], ipaddress.IPv6Address) - else "IP4", - unicast_address=str(rtp_public[0]), - ), - timings=[Timing(start_time=0, stop_time=0)], - connection=ConnectionData( - nettype="IN", - addrtype="IP6" - if isinstance(rtp_public[0], ipaddress.IPv6Address) - else "IP4", - connection_address=str(rtp_public[0]), - ), - media=[ - MediaDescription( - media="audio", - port=rtp_public[1], - proto=negotiated_media.proto, - fmt=negotiated_media.fmt, - attributes=sdp_media_attributes, - ) - ], - ), - ), - ) - self._mark_call_answered(call_id) - self._to_tags.pop(call_id, None) - - def _with_to_tag(self, headers: dict[str, str], call_id: str) -> dict[str, str]: - """Return headers with the To tag appended (RFC 3261 §8.2.6.2).""" - tag = self._to_tags.get(call_id, "") - return { - **headers, - "To": headers.get("To", "") + (f";tag={tag}" if tag else ""), - } + self.transactions[response.branch].response_received(response) - def _build_contact(self, user: str | None = None, *, ob: bool = False) -> str: + @property + def contact(self) -> str: """Return a ``Contact:`` header value for this UA. The URI scheme mirrors `aor`: a ``sips:`` AOR produces a @@ -724,257 +291,25 @@ def _build_contact(self, user: str | None = None, *, ob: bool = False) -> str: support to the registrar. [RFC 5626 §5]: https://datatracker.ietf.org/doc/html/rfc5626#section-5 - - Args: - user: SIP user part (e.g. ``"alice"``). When provided the Contact - is of the form ````; otherwise just - ````. - ob: Include the ``ob`` URI parameter (RFC 5626 §5) to indicate - outbound keep-alive support. - """ - aor_scheme = self.aor.partition(":")[0] # "sip" or "sips" - host_port = f"{_format_host(self.local_address[0])}:{self.local_address[1]}" - addr = f"{user}@{host_port}" if user else host_port - ob_uri_param = ";ob" if ob else "" - if aor_scheme == "sips": - return f"" - tls_param = ";transport=tls" if self._is_tls else "" - return f"" - - def ringing(self, request: Request) -> None: - """Send a 180 Ringing provisional response to the caller. - - Call this from `call_received` before answering to indicate - that the call is being processed (e.g. while a user is alerted). - - Args: - request: The SIP INVITE request (from `call_received`). """ - call_id = request.headers.get("Call-ID", "") - if call_id not in self._pending_invites: - logger.error("No pending INVITE found for Call-ID %r", call_id) - return - caller = CallerID(request.headers.get("From", "")) - logger.info( - json.dumps( - {"event": "call_ringing", "caller": repr(caller), "call_id": call_id} - ), - extra={"caller": repr(caller), "call_id": call_id}, + address = ( + f"{self.aor.user}@{self.local_address}" + if self.aor.user + else str(self.local_address) ) - self.send( - Response( - status_code=SIPStatus.RINGING, - phrase=SIPStatus.RINGING.phrase, - headers=self._with_to_tag( - { - key: value - for key, value in request.headers.items() - if key in ("Via", "To", "From", "Call-ID", "CSeq") - }, - call_id, - ), - ), - ) - - def reject( - self, - request: Request, - status_code: SIPStatus = SIPStatus.BUSY_HERE, - ) -> None: - """Reject an incoming call. - - Args: - request: The SIP INVITE request (from `call_received`). - status_code: SIP response status code (default: 486 Busy Here). - """ - call_id = request.headers.get("Call-ID", "") - if call_id not in self._pending_invites: - logger.error("No pending INVITE found for Call-ID %r", call_id) - return - self._pending_invites.discard(call_id) - peer = self.transport.get_extra_info("peername") if self.transport else None - caller = CallerID(request.headers.get("From", "")) - logger.info( - json.dumps( - { - "event": "call_rejected", - "caller": repr(caller), - "ip": peer[0] if peer else None, - "call_id": call_id, - "status": status_code, - "reason": status_code.phrase, - } - ), - extra={ - "caller": repr(caller), - "ip": peer[0] if peer else None, - "call_id": call_id, - "status": status_code, - }, - ) - self.send( - Response( - status_code=status_code, - phrase=status_code.phrase, - headers=self._with_to_tag( - { - key: value - for key, value in request.headers.items() - if key in ("Via", "To", "From", "Call-ID", "CSeq") - }, - call_id, - ), - ), - ) - self._to_tags.pop(call_id, None) - - @property - def registrar_uri(self) -> str: - """Registrar Request-URI derived from the AOR, preserving its scheme. - - The scheme (``sip:`` or ``sips:``) is taken directly from `aor` - so the client honours whatever security contract the administrator has - configured. The user part is stripped; only the host (and optional - port) is kept, per RFC 3261 §10.2. - - Examples: - ``` - sip:alice@example.com → sip:example.com - sips:alice@example.com → sips:example.com - ``` - """ - if not self.aor: - raise ValueError("AOR is not configured; cannot derive registrar URI") - scheme, _, rest = self.aor.partition(":") - _, _, hostport = rest.partition("@") - return f"{scheme}:{hostport}" - - async def register( - self, - authorization: str | None = None, - proxy_authorization: str | None = None, - ) -> None: - """Send a REGISTER request to the registrar, optionally with credentials. - - The REGISTER Request-URI is the registrar URI derived from `aor` - (RFC 3261 §10.2). When an `outbound_proxy` is configured, the - request is sent over the existing TLS/TCP connection to that proxy, - which routes it to the registrar on our behalf. - """ - self.cseq += 1 - if self.outbound_proxy: - logger.debug( - "Sending REGISTER via outbound proxy %s:%s to registrar %s (CSeq %s)", - self.outbound_proxy[0], - self.outbound_proxy[1], - self.registrar_uri, - self.cseq, - ) - else: - logger.debug( - "Sending REGISTER to registrar %s (CSeq %s)", - self.registrar_uri, - self.cseq, - ) - branch = f"{self.VIA_BRANCH_PREFIX}{secrets.token_hex(16)}" - logger.debug("REGISTER Via branch: %s", branch) - # Extract SIP user part from AOR (e.g. "sips:alice@example.com" -> "alice") - aor_rest = self.aor.partition(":")[2] if self.aor else "" - user = aor_rest.partition("@")[0] if "@" in aor_rest else aor_rest - headers = { - "Via": f"SIP/2.0/{'TLS' if self._is_tls else 'TCP'} {_format_host(self.local_address[0])}:{self.local_address[1]};rport;branch={branch}", - "From": self.aor, - "To": self.aor, - "Call-ID": self.call_id, - "CSeq": f"{self.cseq} REGISTER", - "Contact": self._build_contact(user, ob=True), - "Expires": "3600", # 1 hour - "Max-Forwards": "70", - "Supported": "outbound", # RFC 5626 §5 — outbound keep-alive support - } - if authorization is not None: - headers["Authorization"] = authorization - if proxy_authorization is not None: - headers["Proxy-Authorization"] = proxy_authorization - self.send( - Request(method="REGISTER", uri=self.registrar_uri, headers=headers), - ) - - def registered(self) -> None: - """Handle a confirmed carrier registration. Override to react.""" - - @staticmethod - def parse_auth_challenge(header: str) -> dict[str, str]: - """Parse Digest challenge parameters from a WWW-Authenticate/Proxy-Authenticate header.""" - _, _, params_str = header.partition(" ") - params = {} - for part in re.split(r",\s*(?=[a-zA-Z])", params_str): - key, _, value = part.partition("=") - if key.strip(): - params[key.strip()] = value.strip().strip('"') - return params - - #: Map from `DigestAlgorithm` to the hashlib name. - _DIGEST_HASH_NAME: typing.ClassVar[dict[str, str]] = { - DigestAlgorithm.MD5: "md5", - DigestAlgorithm.MD5_SESS: "md5", - DigestAlgorithm.SHA_256: "sha256", - DigestAlgorithm.SHA_256_SESS: "sha256", - DigestAlgorithm.SHA_512_256: "sha512_256", - DigestAlgorithm.SHA_512_256_SESS: "sha512_256", - } - - @classmethod - def digest_response( - cls, - *, - username: str, - password: str, - realm: str, - nonce: str, - method: str, - uri: str, - algorithm: str = DigestAlgorithm.SHA_256, - qop: str | None = None, - nc: str = "00000001", - cnonce: str | None = None, - ) -> str: - """Compute a SIP digest response per RFC 3261 §22 and RFC 8760. - - RFC 8760 deprecates MD5 and mandates support for SHA-256 and - SHA-512-256. The ``algorithm`` parameter selects the hash function; - it defaults to ``SHA-256``. - - Raises: - ValueError: If ``algorithm`` is not a recognised `DigestAlgorithm`, - or if a ``*-sess`` algorithm is requested without a ``cnonce``. - """ - try: - hash_name = cls._DIGEST_HASH_NAME[algorithm] - except KeyError: - raise ValueError(f"Unsupported digest algorithm: {algorithm!r}") from None - is_sess = algorithm.endswith("-sess") - if is_sess and cnonce is None: - raise ValueError(f"algorithm={algorithm!r} requires a cnonce value") - - def h(data: str) -> str: - return hashlib.new(hash_name, data.encode()).hexdigest() - - ha1 = h(f"{username}:{realm}:{password}") - if is_sess: - ha1 = h(f"{ha1}:{nonce}:{cnonce}") - ha2 = h(f"{method}:{uri}") - if qop in (DigestQoP.AUTH, DigestQoP.AUTH_INT): - return h(f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}") - return h(f"{ha1}:{nonce}:{ha2}") + ob_uri_param = ";ob" + if self.aor.scheme == "sips": + return f"" + tls_param = ";transport=tls" if self.is_secure else "" + return f"" def connection_lost(self, exc: Exception | None) -> None: """Handle a lost TLS/TCP connection.""" if exc is not None: logger.exception("Connection lost", exc_info=exc) - if self._keepalive_task is not None: - self._keepalive_task.cancel() - self._keepalive_task = None + if self.keepalive_task is not None: + self.keepalive_task.cancel() + self.keepalive_task = None self.transport = None self.disconnected_event.set() diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py new file mode 100644 index 0000000..c575210 --- /dev/null +++ b/voip/sip/transactions.py @@ -0,0 +1,590 @@ +"""SIP transaction layer (RFC 3261 §17).""" + +from __future__ import annotations + +import dataclasses +import datetime +import hashlib +import ipaddress +import logging +import re +import secrets +import typing +import uuid + +from voip.rtp import Session +from voip.sdp.messages import SessionDescription +from voip.sdp.types import ( + Attribute, + ConnectionData, + MediaDescription, + Origin, + RTPPayloadFormat, + Timing, +) +from voip.srtp import SRTPSession + +from ..types import NetworkAddress +from . import messages, types +from .messages import Dialog, Request, Response +from .types import ( + CallerID, + DigestAlgorithm, + DigestQoP, + SIPMethod, + SIPStatus, +) + +if typing.TYPE_CHECKING: + from .protocol import SessionInitiationProtocol + +logger = logging.getLogger("voip.sip") + +__all__ = [ + "InviteTransaction", + "RegistrationTransaction", +] + + +@dataclasses.dataclass(kw_only=True, slots=True) +class Transaction: + """ + Initiated by a request, completed by any number of responses. + + Args: + dialog: The SIP dialog this transaction belongs to. + branch: Unique identifier for the transaction, must start with "z9hG4bK". + cseq: The CSeq number for this transaction, starting at 1 and incremented + for each new request sent within the transaction. + """ + + branch_prefix: typing.ClassVar[str] = "z9hG4bK" + + method: SIPMethod + branch: str = dataclasses.field( + default_factory=lambda: f"{Transaction.branch_prefix}-{uuid.uuid4()}" + ) + cseq: int + sip: SessionInitiationProtocol + request: messages.Request | None = None + responses: list[messages.Response] = dataclasses.field( + init=False, default_factory=list + ) + dialog: Dialog = None + + created: datetime.datetime = dataclasses.field( + init=False, default_factory=datetime.datetime.now + ) + + def __post_init__(self): + if not self.branch.startswith(self.branch_prefix): + raise ValueError( + f"Branch parameter must not start with {self.branch_prefix!r}" + ) + + @property + def headers(self) -> dict[str, str]: + """Return a dict of headers for this transaction.""" + return { + "Via": f"SIP/2.0/{self.sip.aor.parameters.get('transport', 'TLS').upper()} {self.sip.local_address};rport;branch={self.branch}", + "CSeq": f"{self.cseq} {self.method}", + } + + def response_received(self, response: messages.Response): + """Send a response to this transaction.""" + + def send_response(self, response: messages.Response): + """Send a response to this transaction.""" + self.sip.send(response) + + @classmethod + def from_request( + cls, + *, + request: messages.Request, + sip: SessionInitiationProtocol, + ): + try: + dialog = sip.dialogs[request.remote_tag, request.local_tag] + except KeyError: + dialog = Dialog.from_request(request) + return cls( + sip=sip, + dialog=dialog, + method=request.method, + branch=request.branch, + request=request, + cseq=request.sequence, + ) + + +@dataclasses.dataclass(kw_only=True, slots=True) +class RegistrationTransaction(Transaction): + """SIP REGISTER client transaction [RFC 3261 §10].""" + + #: Map from `DigestAlgorithm` to the hashlib name. + DIGEST_HASH_NAME: typing.ClassVar[dict[str, str]] = { + DigestAlgorithm.MD5: "md5", + DigestAlgorithm.MD5_SESS: "md5", + DigestAlgorithm.SHA_256: "sha256", + DigestAlgorithm.SHA_256_SESS: "sha256", + DigestAlgorithm.SHA_512_256: "sha512_256", + DigestAlgorithm.SHA_512_256_SESS: "sha512_256", + } + + authorization: str | None = None + proxy_authorization: str | None = None + cseq: int = 1 + + def __post_init__(self): + self.dialog = self.dialog or Dialog(uac=self.sip.aor) + headers = ( + self.headers + | self.dialog.headers + | { + "Contact": self.sip.contact, + "Expires": "3600", + "Max-Forwards": "70", + "Supported": "outbound", + } + ) + if self.authorization is not None: + headers["Authorization"] = self.authorization + if self.proxy_authorization is not None: + headers["Proxy-Authorization"] = self.proxy_authorization + self.request = Request.from_dialog( + dialog=self.dialog, + method=SIPMethod.REGISTER, + uri=types.SipUri(host=self.sip.aor.host, scheme=self.sip.aor.scheme), + headers=headers, + ) + + self.sip.send(self.request) + + def response_received(self, response: Response) -> None: + """Handle a REGISTER response including digest auth challenges (RFC 3261 §22). + + Args: + response: The parsed SIP response. + """ + self.sip.transactions.pop(self.branch) + match response.status_code: + case SIPStatus.OK: + logger.info("Registration successfull") + return + case SIPStatus.UNAUTHORIZED | SIPStatus.PROXY_AUTHENTICATION_REQUIRED: + logger.debug( + "Auth challenge received (%s), retrying with credentials", + response.status_code, + ) + is_proxy = ( + response.status_code == SIPStatus.PROXY_AUTHENTICATION_REQUIRED + ) + challenge_key = "Proxy-Authenticate" if is_proxy else "WWW-Authenticate" + params = self.parse_auth_challenge( + response.headers.get(challenge_key, "") + ) + realm = params.get("realm", "") + nonce = params.get("nonce", "") + opaque = params.get("opaque") + algorithm = params.get("algorithm", DigestAlgorithm.SHA_256) + qop_options = params.get("qop", "") + qop = ( + DigestQoP.AUTH.value + if DigestQoP.AUTH.value in qop_options.split(",") + else None + ) + nc = "00000001" + cnonce = secrets.token_hex(8) if qop else None + digest = self.digest_response( + username=self.sip.aor.user, + password=self.sip.aor.password, + realm=realm, + nonce=nonce, + method=SIPMethod.REGISTER, + uri=self.sip.aor.host, + algorithm=algorithm, + qop=qop, + nc=nc, + cnonce=cnonce, + ) + auth_value = ( + f'Digest username="{self.sip.aor.user}", realm="{realm}", ' + f'nonce="{nonce}", uri="{self.sip.aor.host}", ' + f'response="{digest}", algorithm="{algorithm}"' + ) + if qop: + auth_value += f', qop={qop}, nc={nc}, cnonce="{cnonce}"' + if opaque: + auth_value += f', opaque="{opaque}"' + if is_proxy: + tx = RegistrationTransaction( + sip=self.sip, + dialog=self.dialog, + cseq=2, + method=self.method, + proxy_authorization=auth_value, + ) + else: + tx = RegistrationTransaction( + sip=self.sip, + dialog=self.dialog, + cseq=2, + method=self.method, + authorization=auth_value, + ) + self.sip.transactions[tx.branch] = tx + case _: + raise NotImplementedError( + f"Unknown SIP status code: {response.status_code}" + ) + + @staticmethod + def parse_auth_challenge(header: str) -> dict[str, str]: + """Parse Digest challenge parameters from a WWW-Authenticate/Proxy-Authenticate header. + + Args: + header: The raw ``WWW-Authenticate`` or ``Proxy-Authenticate`` header value. + + Returns: + A dict mapping parameter names to their unquoted values. + """ + _, _, params_str = header.partition(" ") + params = {} + for part in re.split(r",\s*(?=[a-zA-Z])", params_str): + key, _, value = part.partition("=") + if key.strip(): + params[key.strip()] = value.strip().strip('"') + return params + + @classmethod + def digest_response( + cls, + *, + username: str, + password: str, + realm: str, + nonce: str, + method: str, + uri: str, + algorithm: str = DigestAlgorithm.SHA_256, + qop: str | None = None, + nc: str = "00000001", + cnonce: str | None = None, + ) -> str: + """Compute a SIP digest response per RFC 3261 §22 and RFC 8760. + + RFC 8760 deprecates MD5 and mandates support for SHA-256 and + SHA-512-256. The ``algorithm`` parameter selects the hash function; + it defaults to ``SHA-256``. + + Args: + username: SIP username (AOR user part). + password: SIP password. + realm: Digest realm from the challenge. + nonce: Digest nonce from the challenge. + method: SIP method string (e.g. ``"REGISTER"``). + uri: Request-URI string used in the digest. + algorithm: Digest algorithm identifier (default: ``"SHA-256"``). + qop: Quality-of-protection value, or ``None``. + nc: Nonce count hex string (default: ``"00000001"``). + cnonce: Client nonce, required for ``*-sess`` algorithms and ``qop``. + + Returns: + Hex-encoded digest response string. + + Raises: + ValueError: If ``algorithm`` is not a recognised `DigestAlgorithm`, + or if a ``*-sess`` algorithm is requested without a ``cnonce``. + """ + try: + hash_name = cls.DIGEST_HASH_NAME[algorithm] + except KeyError: + raise ValueError(f"Unsupported digest algorithm: {algorithm!r}") from None + is_sess = algorithm.endswith("-sess") + if is_sess and cnonce is None: + raise ValueError(f"algorithm={algorithm!r} requires a cnonce value") + + def h(data: str) -> str: + return hashlib.new(hash_name, data.encode()).hexdigest() + + ha1 = h(f"{username}:{realm}:{password}") + if is_sess: + ha1 = h(f"{ha1}:{nonce}:{cnonce}") + ha2 = h(f"{method}:{uri}") + if qop in (DigestQoP.AUTH, DigestQoP.AUTH_INT): + return h(f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}") + return h(f"{ha1}:{nonce}:{ha2}") + + +@dataclasses.dataclass(kw_only=True, slots=True) +class InviteTransaction(Transaction): + """SIP INVITE server transaction [RFC 3261 §17.2]. + + Encapsulates the state and behavior of a single INVITE dialog. + The SIP layer creates one instance per incoming INVITE, keyed by + Via branch (RFC 3261 §17.1.3). + + Override `call_received` in a subclass to react to the call without + subclassing + [`SessionInitiationProtocol`][voip.sip.protocol.SessionInitiationProtocol]: + + ```python + class MyTransaction(Transaction): + def call_received(self) -> None: + asyncio.create_task(self.answer(call_class=MyCall)) + ``` + + Register the subclass on the session: + + ```python + class MySession(SessionInitiationProtocol): + transaction_class = MyTransaction + ``` + + [RFC 3261 §17.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-17.2 + """ + + def invite_received(self, request: Request) -> None: + """Handle the incoming call. + + Override in subclasses to decide whether to answer, ring, or reject. + Implementations are expected to produce a response by calling + `ringing`, `answer`, or `reject`. The base implementation is a no-op. + + Args: + request: The SIP INVITE request. + """ + + def ack_received(self, request: Request) -> None: + """Handle an ACK confirming dialog establishment (RFC 3261 §17.2.1). + + Removes the INVITE server transaction from the registry. Override + in subclasses to react to the ACK. + + Args: + request: The SIP ACK request. + """ + self.sip.transactions.pop(self.branch) + + def bye_received(self, request: Request) -> None: + """Handle a BYE terminating a dialog. + + Override in subclasses to tear down the call. + + Args: + request: The SIP BYE request. + """ + self.sip.dialogs.pop((self.dialog.remote_tag, self.dialog.local_tag)) + self.send_response( + Response.from_request( + request, + dialog=self.dialog, + status_code=SIPStatus.OK, + phrase=SIPStatus.OK.phrase, + ) + ) + + def cancel_received(self, request: Request) -> None: + """Handle a CANCEL request for a pending INVITE. + + Override in subclasses to react to caller cancellation before the call + is answered. + + Args: + request: The SIP CANCEL request. + """ + self.sip.transactions.pop(self.branch) + self.sip.dialogs.pop((self.dialog.remote_tag, self.dialog.local_tag)) + self.send_response( + Response.from_request( + request, + dialog=self.dialog, + status_code=SIPStatus.OK, + phrase=SIPStatus.OK.phrase, + ) + ) + + def ringing(self) -> None: + """Send a 180 Ringing provisional response [RFC 3261 §21.1.2]. + + Call before `answer` to notify the caller that the UA is alerting the + user. + + [RFC 3261 §21.1.2]: https://datatracker.ietf.org/doc/html/rfc3261#section-21.1.2 + """ + self.send_response( + Response.from_request( + self.request, + dialog=self.dialog, + status_code=SIPStatus.RINGING, + phrase=SIPStatus.RINGING.phrase, + headers=self.headers, + ) + ) + + def reject(self, status_code: SIPStatus = SIPStatus.BUSY_HERE) -> None: + """Reject the incoming call. + + Args: + status_code: SIP response status code (default: 486 Busy Here). + """ + self.send_response( + Response.from_request( + self.request, + dialog=self.dialog, + status_code=status_code, + phrase=status_code.phrase, + headers=self.headers, + ) + ) + + def answer(self, *, call_class: type[Session], **call_kwargs: typing.Any) -> None: + """Answer the call by setting up RTP and sending 200 OK with SDP. + + Example: + Call from within `call_received`: + + ```python + def call_received(self) -> None: + asyncio.create_task(self.answer(call_class=MyCall)) + ``` + + Args: + call_class: Session implementation that will be initialized. + **call_kwargs: Additional keyword arguments forwarded to the + call class constructor. + + Raises: + NotImplementedError: When `negotiate_codec` raises (no supported + codec in the remote SDP offer). + """ + peer = ( + self.sip.transport.get_extra_info("peername") + if self.sip.transport + else None + ) + caller = CallerID(self.request.headers.get("From", "")) + remote_audio = next( + ( + m + for m in (self.request.body.media if self.request.body else []) + if m.media == "audio" + ), + None, + ) + if remote_audio is not None: + negotiated_media = call_class.negotiate_codec(remote_audio) + else: + negotiated_media = MediaDescription( + media="audio", + port=0, + proto="RTP/SAVP", + fmt=[RTPPayloadFormat.from_pt(0)], + ) + + use_srtp = negotiated_media.proto == "RTP/SAVP" + srtp_session = SRTPSession.generate() if use_srtp else None + + call_handler = call_class( + rtp=self.sip.rtp, + sip=self.sip, + caller=caller, + media=negotiated_media, + srtp=srtp_session, + **call_kwargs, + ) + if remote_audio is not None and remote_audio.port != 0: + media_connection = remote_audio.connection + session_connection = ( + self.request.body.connection if self.request.body else None + ) + connection = media_connection or session_connection + if connection is not None: + remote_ip = connection.connection_address + else: + remote_ip = peer[0] if peer else "0.0.0.0" # noqa: S104 + remote_rtp_address: NetworkAddress | None = NetworkAddress( + remote_ip, remote_audio.port + ) + else: + remote_rtp_address = None + self.sip.rtp.register_call(remote_rtp_address, call_handler) + + if remote_rtp_address is not None: + self.sip.rtp.send(b"\x00", remote_rtp_address) + + record_route = self.request.headers.get("Record-Route") + session_id = str(secrets.randbelow(2**32) + 1) + rtp_public = self.sip.rtp.public_address + sdp_media_attributes = [Attribute(name="sendrecv")] + if srtp_session is not None: + sdp_media_attributes.append( + Attribute(name="crypto", value=srtp_session.sdes_attribute) + ) + dialog = Dialog.from_request(self.request) + self.sip.dialogs[dialog.remote_tag, dialog.local_tag] = dialog + self.send_response( + Response.from_request( + request=self.request, + dialog=dialog, + status_code=SIPStatus.OK, + phrase=SIPStatus.OK.phrase, + headers={ + **({"Record-Route": record_route} if record_route else {}), + "Contact": self.sip.contact, + "Allow": self.sip.allow_header, + "Supported": "replaces", + "Content-Type": "application/sdp", + }, + body=SessionDescription( + origin=Origin( + username="-", + sess_id=session_id, + sess_version=session_id, + nettype="IN", + addrtype="IP6" + if isinstance(rtp_public[0], ipaddress.IPv6Address) + else "IP4", + unicast_address=str(rtp_public[0]), + ), + timings=[Timing(start_time=0, stop_time=0)], + connection=ConnectionData( + nettype="IN", + addrtype="IP6" + if isinstance(rtp_public[0], ipaddress.IPv6Address) + else "IP4", + connection_address=str(rtp_public[0]), + ), + media=[ + MediaDescription( + media="audio", + port=rtp_public[1], + proto=negotiated_media.proto, + fmt=negotiated_media.fmt, + attributes=sdp_media_attributes, + ) + ], + ), + ) + ) + + async def make_call( + self, + target: str, + *, + call_class: type[Session], + **call_kwargs: typing.Any, + ) -> Request: + """Initiate an outgoing call to `target`. + + Args: + target: SIP URI of the callee (e.g. ``"sip:bob@example.com"``). + call_class: Session implementation that will be initialized for the call. + **call_kwargs: Additional keyword arguments forwarded to the + call class constructor. + + Raises: + NotImplementedError: Not yet implemented. + """ + raise NotImplementedError("make_call is not yet implemented") diff --git a/voip/sip/types.py b/voip/sip/types.py index ddeb5cb..4933bb6 100644 --- a/voip/sip/types.py +++ b/voip/sip/types.py @@ -18,6 +18,11 @@ import urllib.parse from collections.abc import Iterator +from voip.types import NetworkAddress + +if typing.TYPE_CHECKING: + pass + @dataclasses.dataclass(slots=True, eq=True) class SipUri: @@ -54,7 +59,7 @@ class SipUri: scheme: str host: str | ipaddress.IPv6Address | ipaddress.IPv4Address user: str | None = None - password: str | None = None + password: str | None = dataclasses.field(default=None, repr=False) port: int | None = None parameters: dict[str, str | None] = dataclasses.field(default_factory=dict) headers: dict[str, str] = dataclasses.field(default_factory=dict) @@ -99,10 +104,6 @@ def parse(cls, value: str) -> SipUri: if host.startswith("[") and host.endswith("]"): host = host[1:-1] host = urllib.parse.unquote(host) - try: - ipaddress.ip_address(host) - except ValueError: - pass # Not an IP address, treat as a regular hostname return cls( scheme=match.group("scheme").lower(), @@ -169,6 +170,23 @@ def __str__(self) -> str: ) return "".join(parts) + @property + def maddr(self) -> NetworkAddress: + try: + return NetworkAddress.parse(self.parameters["maddr"]) + except KeyError: + return NetworkAddress(self.host, self.port) + + @property + def ttl(self): + return self.parameters["ttl"] + + @property + def transport(self): + return self.parameters.get( + "transport", "TLS" if self.scheme == "sips" else None + ) + class CallerID(str): """SIP From/To header value with structured access and privacy-safe repr. @@ -190,8 +208,7 @@ class CallerID(str): @property def display_name(self) -> str | None: """Display name from the From/To header, if present.""" - m = re.match(r'^"([^"]+)"\s*<|^([^<"]+?)\s*<', self) - if m: + if m := re.match(r'^"([^"]+)"\s*<|^([^<"]+?)\s*<', self): return (m.group(1) or m.group(2) or "").strip() or None return None @@ -533,3 +550,31 @@ class DigestQoP(enum.StrEnum): AUTH = "auth" AUTH_INT = "auth-int" + + +def _mask_caller(header: str) -> str: + """Return a privacy-safe label from a SIP From/To header value. + + Strips the `tag=` parameter, extracts the display name or SIP user part, + and replaces all but the last four characters with `*`. + + Examples: + ``` + >>> _mask_caller('"08001234567" ;tag=abc') + '*******4567' + >>> _mask_caller('sip:alice@example.com') + '*lice' + ``` + """ + # Drop the tag and any subsequent parameters + value = header.split(";")[0].strip() + # Extract display name: "Name" or Name + m = re.match(r'^"?([^"<]+?)"?\s*<', value) + name = m.group(1).strip() if m else None + if not name: + # Bare or angle-bracket URI: sip:user@host or + m = re.search(r"sips?:([^@>;\s]+)", value) + name = m.group(1) if m else value + if len(name) > 4: + return "*" * (len(name) - 4) + name[-4:] + return name diff --git a/voip/stun.py b/voip/stun.py index e931506..9d54680 100644 --- a/voip/stun.py +++ b/voip/stun.py @@ -12,6 +12,8 @@ __all__ = ["STUNAttributeType", "STUNMessageType", "STUNProtocol"] +from voip.types import NetworkAddress + logger = logging.getLogger(__name__) MAGIC_COOKIE = 0x2112A442 @@ -151,7 +153,7 @@ def stun_connection_made( local otherwise. """ # noqa: D401 - def send(self, data: bytes, addr: tuple[str, int]) -> None: + def send(self, data: bytes, addr: NetworkAddress) -> None: """Send a raw datagram through the shared UDP socket. Args: @@ -176,7 +178,7 @@ def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: ): self._parse_stun_response(data) return - self.packet_received(data, addr) + self.packet_received(data, NetworkAddress(*addr)) def connection_lost(self, exc: Exception | None) -> None: """Clear the internal transport reference on disconnect.""" @@ -185,7 +187,7 @@ def connection_lost(self, exc: Exception | None) -> None: def error_received(self, exc: Exception) -> None: logger.warning("UDP transport error (ignored): %s", exc) - def packet_received(self, data: bytes, addr: tuple[str, int]) -> None: + def packet_received(self, data: bytes, addr: NetworkAddress) -> None: """Override in subclasses to handle non-STUN datagrams. Args: diff --git a/voip/types.py b/voip/types.py index be30d15..fbd54c0 100644 --- a/voip/types.py +++ b/voip/types.py @@ -2,6 +2,7 @@ import abc import typing +from ipaddress import IPv4Address, IPv6Address class ByteSerializableObject(abc.ABC): @@ -20,3 +21,26 @@ def __bytes__(self) -> bytes: def __str__(self) -> str: return self.__bytes__().decode() + + +class NetworkAddress(typing.NamedTuple): + """Parse and serialize an address.""" + + host: str | IPv4Address | IPv6Address + port: int | None = None + + def __str__(self): + if self.port and isinstance(self.host, IPv6Address): + return f"[{self.host}]:{self.port}" + elif self.port: + return f"{self.host}:{self.port}" + return str(self.host) + + @classmethod + def parse(cls, data: str) -> NetworkAddress: + if data.startswith("["): + host, port = data[1:].split("]:") + return cls(host=host, port=int(port)) + else: + host, port = data.split(":") + return cls(host=host, port=int(port)) From 9328516153d6697f1bab244ce0b4b0aa6ae28061 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:00:21 +0000 Subject: [PATCH 2/9] Add comprehensive SIP protocol, transaction, and type tests for 100% coverage --- tests/sip/conftest.py | 121 ++++++ tests/sip/test_messages.py | 163 ++++++++ tests/sip/test_protocol.py | 355 ++++++++++++++++++ tests/sip/test_transactions.py | 668 +++++++++++++++++++++++++++++++++ tests/sip/test_types.py | 126 +++++++ 5 files changed, 1433 insertions(+) create mode 100644 tests/sip/conftest.py create mode 100644 tests/sip/test_protocol.py create mode 100644 tests/sip/test_transactions.py diff --git a/tests/sip/conftest.py b/tests/sip/conftest.py new file mode 100644 index 0000000..e1389fc --- /dev/null +++ b/tests/sip/conftest.py @@ -0,0 +1,121 @@ +"""Shared fixtures for SIP tests.""" + +from __future__ import annotations + +import dataclasses +import ipaddress + +import pytest +from voip.rtp import RealtimeTransportProtocol, Session +from voip.sdp.types import MediaDescription, RTPPayloadFormat +from voip.sip.protocol import SessionInitiationProtocol +from voip.sip.transactions import InviteTransaction +from voip.sip.types import SipUri +from voip.types import NetworkAddress + + +@dataclasses.dataclass +class FakeTransport: + """Minimal asyncio.Transport stub that records written data.""" + + _local_address: tuple = ("127.0.0.1", 5061) + _peer_address: tuple = ("192.0.2.1", 5061) + _ssl: bool = True + sent: list[bytes] = dataclasses.field(default_factory=list) + closed: bool = False + + def write(self, data: bytes) -> None: + """Record outgoing data.""" + self.sent.append(data) + + def close(self) -> None: + """Mark transport as closed.""" + self.closed = True + + def get_extra_info(self, key: str, default=None): + """Return socket metadata.""" + match key: + case "sockname": + return self._local_address + case "peername": + return self._peer_address + case "ssl_object": + return object() if self._ssl else None + case _: + return default + + +class CallFixture(Session): + """Minimal Session subclass for testing codec negotiation.""" + + @classmethod + def negotiate_codec(cls, remote_media: MediaDescription) -> MediaDescription: + """Return the first format from the offered media.""" + return MediaDescription( + media="audio", + port=5004, + proto="RTP/AVP", + fmt=remote_media.fmt[:1] or [RTPPayloadFormat.from_pt(0)], + ) + + +@pytest.fixture +def fake_transport() -> FakeTransport: + """Return a fresh FakeTransport with TLS.""" + return FakeTransport() + + +@pytest.fixture +def rtp() -> RealtimeTransportProtocol: + """Return a RealtimeTransportProtocol with a pre-set public address.""" + mux = RealtimeTransportProtocol() + mux.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + return mux + + +@pytest.fixture +async def sip( + fake_transport: FakeTransport, rtp: RealtimeTransportProtocol +) -> SessionInitiationProtocol: + """Return a connected SIP session with keepalive cancelled.""" + session = SessionInitiationProtocol( + aor=SipUri.parse("sips:alice:secret@example.com:5061"), + rtp=rtp, + transaction_class=InviteTransaction, + ) + session.connection_made(fake_transport) + if session.keepalive_task is not None: + session.keepalive_task.cancel() + session.keepalive_task = None + return session + + +#: A minimal incoming INVITE request as raw bytes. +INVITE_BYTES = ( + b"INVITE sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKabc123\r\n" + b"From: sip:bob@biloxi.com;tag=from-tag-1\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: test-call-id@biloxi.com\r\n" + b"CSeq: 1 INVITE\r\n" + b"\r\n" +) + +#: INVITE bytes that include an SDP body with audio media. +INVITE_WITH_SDP_BYTES = ( + b"INVITE sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKsdp456\r\n" + b"From: sip:bob@biloxi.com;tag=from-tag-2\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: test-call-id-sdp@biloxi.com\r\n" + b"CSeq: 1 INVITE\r\n" + b"Content-Type: application/sdp\r\n" + b"\r\n" + b"v=0\r\n" + b"o=- 1 1 IN IP4 192.0.2.1\r\n" + b"s=-\r\n" + b"c=IN IP4 192.0.2.1\r\n" + b"t=0 0\r\n" + b"m=audio 5004 RTP/AVP 0\r\n" + b"a=rtpmap:0 PCMU/8000\r\n" +) diff --git a/tests/sip/test_messages.py b/tests/sip/test_messages.py index 9dc41ed..e1cfafc 100644 --- a/tests/sip/test_messages.py +++ b/tests/sip/test_messages.py @@ -199,3 +199,166 @@ def test_response__bytes__with_sdp_body__auto_content_length(self): assert b"Content-Length:" in serialized parsed = Message.parse(serialized) assert parsed.body is None + + +class TestMessageProperties: + def test_remote_tag__with_tag(self): + """Return the To-header tag parameter.""" + data = ( + b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" + b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bKabc\r\n" + b"To: sip:bob@biloxi.com;tag=to-tag-1\r\n" + b"\r\n" + ) + request = Message.parse(data) + assert request.remote_tag == "to-tag-1" + + def test_local_tag__with_tag(self): + """Return the From-header tag parameter.""" + data = ( + b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" + b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bKabc\r\n" + b"From: sip:alice@atlanta.com;tag=from-tag-1\r\n" + b"\r\n" + ) + request = Message.parse(data) + assert request.local_tag == "from-tag-1" + + def test_sequence__returns_cseq_number(self): + """Return the integer sequence number from the CSeq header.""" + data = ( + b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" + b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bKabc\r\n" + b"CSeq: 42 INVITE\r\n" + b"\r\n" + ) + request = Message.parse(data) + assert request.sequence == 42 + + +class TestRequestFromDialog: + def test_from_dialog__merges_dialog_headers(self): + """Merge the provided headers with the dialog's headers.""" + from voip.sip.messages import Dialog + from voip.sip.types import SipUri + + dialog = Dialog( + uac=SipUri.parse("sips:alice@example.com"), + local_tag="local-tag", + remote_tag="remote-tag", + ) + request = Request.from_dialog( + dialog=dialog, + headers={"Via": "SIP/2.0/TLS example.com;branch=z9hG4bK123"}, + method="REGISTER", + uri="sips:example.com", + ) + assert "From" in request.headers + assert "Call-ID" in request.headers + assert "Via" in request.headers + + +class TestResponseFromRequest: + def test_from_request__with_dialog_remote_tag(self): + """Include dialog remote_tag in To header when dialog has a remote_tag.""" + from voip.sip.messages import Dialog + from voip.sip.types import SipUri + + data = ( + b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" + b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bKabc\r\n" + b"From: sip:alice@atlanta.com;tag=from-tag-1\r\n" + b"To: sip:bob@biloxi.com\r\n" + b"Call-ID: test-call@atlanta.com\r\n" + b"CSeq: 1 INVITE\r\n" + b"\r\n" + ) + request = Message.parse(data) + dialog = Dialog( + uac=SipUri.parse("sip:alice@atlanta.com"), + remote_tag="server-tag", + ) + response = Response.from_request( + request, dialog=dialog, status_code=200, phrase="OK" + ) + assert "server-tag" in str(response.headers["To"]) + + def test_from_request__without_dialog(self): + """Copy To header verbatim from the request when no dialog is provided.""" + data = ( + b"OPTIONS sip:bob@biloxi.com SIP/2.0\r\n" + b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bKxyz\r\n" + b"From: sip:alice@atlanta.com;tag=ft1\r\n" + b"To: sip:bob@biloxi.com\r\n" + b"Call-ID: opts-call@atlanta.com\r\n" + b"CSeq: 1 OPTIONS\r\n" + b"\r\n" + ) + request = Message.parse(data) + response = Response.from_request(request, status_code=200, phrase="OK") + assert response.headers["To"] == request.headers["To"] + + +class TestDialog: + def test_from_header__contains_local_tag(self): + """from_header includes the local_tag parameter.""" + from voip.sip.messages import Dialog + from voip.sip.types import SipUri + + dialog = Dialog( + uac=SipUri.parse("sips:alice@example.com"), + local_tag="my-local-tag", + ) + assert "my-local-tag" in dialog.from_header + + def test_to_header__without_remote_tag(self): + """to_header omits the tag parameter when remote_tag is None.""" + from voip.sip.messages import Dialog + from voip.sip.types import SipUri + + dialog = Dialog( + uac=SipUri.parse("sip:bob@biloxi.com:5060"), + remote_tag=None, + ) + assert ";tag=" not in dialog.to_header + + def test_to_header__with_remote_tag(self): + """to_header includes the remote_tag parameter.""" + from voip.sip.messages import Dialog + from voip.sip.types import SipUri + + dialog = Dialog( + uac=SipUri.parse("sip:bob@biloxi.com:5060"), + remote_tag="their-tag", + ) + assert "their-tag" in dialog.to_header + + def test_headers__returns_required_keys(self): + """Headers property returns From, To, and Call-ID keys.""" + from voip.sip.messages import Dialog + from voip.sip.types import SipUri + + dialog = Dialog(uac=SipUri.parse("sips:alice@example.com")) + headers = dialog.headers + assert "From" in headers + assert "To" in headers + assert "Call-ID" in headers + + def test_from_request__extracts_call_id_and_tags(self): + """from_request creates a Dialog with the correct call_id and tags.""" + from voip.sip.messages import Dialog + + data = ( + b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" + b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bKabc\r\n" + b"From: sip:alice@atlanta.com;tag=from-tag-99\r\n" + b"To: sip:bob@biloxi.com\r\n" + b"Call-ID: call-99@atlanta.com\r\n" + b"CSeq: 1 INVITE\r\n" + b"\r\n" + ) + request = Message.parse(data) + dialog = Dialog.from_request(request) + assert dialog.call_id == "call-99@atlanta.com" + assert dialog.local_tag == "from-tag-99" + assert dialog.remote_tag is not None diff --git a/tests/sip/test_protocol.py b/tests/sip/test_protocol.py new file mode 100644 index 0000000..e127cde --- /dev/null +++ b/tests/sip/test_protocol.py @@ -0,0 +1,355 @@ +"""Tests for the SIP asyncio protocol handler.""" + +from __future__ import annotations + +import asyncio +import datetime + +from voip.sip.messages import Message, Response +from voip.sip.protocol import SessionInitiationProtocol +from voip.sip.transactions import InviteTransaction +from voip.sip.types import SIPMethod, SipUri + +from .conftest import INVITE_BYTES, FakeTransport + + +class TestSessionInitiationProtocolConnectionMade: + def test_connection_made__stores_transport(self, fake_transport, rtp): + """Store transport reference after connection_made.""" + session = SessionInitiationProtocol( + aor=SipUri.parse("sips:alice:secret@example.com"), + rtp=rtp, + transaction_class=InviteTransaction, + ) + session.connection_made(fake_transport) + assert session.transport is fake_transport + + def test_connection_made__sets_local_address(self, fake_transport, rtp): + """Set local_address from the socket's sockname after connection_made.""" + session = SessionInitiationProtocol( + aor=SipUri.parse("sips:alice:secret@example.com"), + rtp=rtp, + transaction_class=InviteTransaction, + ) + session.connection_made(fake_transport) + assert str(session.local_address.host) == "127.0.0.1" + assert session.local_address.port == 5061 + + def test_connection_made__sets_is_secure_for_tls(self, rtp): + """Mark connection as secure when ssl_object is present.""" + transport = FakeTransport(_ssl=True) + session = SessionInitiationProtocol( + aor=SipUri.parse("sips:alice:secret@example.com"), + rtp=rtp, + transaction_class=InviteTransaction, + ) + session.connection_made(transport) + assert session.is_secure is True + + def test_connection_made__is_not_secure_without_ssl(self, rtp): + """Mark connection as not secure when ssl_object is absent.""" + transport = FakeTransport(_ssl=False) + session = SessionInitiationProtocol( + aor=SipUri.parse("sip:alice:secret@example.com"), + rtp=rtp, + transaction_class=InviteTransaction, + ) + session.connection_made(transport) + assert session.is_secure is False + + async def test_connection_made__sends_register(self, fake_transport, rtp): + """Send a REGISTER request immediately after connection_made in async context.""" + session = SessionInitiationProtocol( + aor=SipUri.parse("sips:alice:secret@example.com"), + rtp=rtp, + transaction_class=InviteTransaction, + ) + session.connection_made(fake_transport) + if session.keepalive_task: + session.keepalive_task.cancel() + assert any(b"REGISTER" in data for data in fake_transport.sent) + + async def test_connection_made__creates_keepalive_task(self, fake_transport, rtp): + """Create a keepalive task in async context.""" + session = SessionInitiationProtocol( + aor=SipUri.parse("sips:alice:secret@example.com"), + rtp=rtp, + transaction_class=InviteTransaction, + ) + session.connection_made(fake_transport) + assert session.keepalive_task is not None + session.keepalive_task.cancel() + + +class TestSessionInitiationProtocolSendKeepalive: + async def test_send_keepalive__sends_ping(self, sip, fake_transport): + """Send a CRLF CRLF ping after the keepalive interval elapses.""" + sip.keepalive_interval = datetime.timedelta(milliseconds=10) + task = asyncio.create_task(sip.send_keepalive()) + await asyncio.sleep(0.05) + task.cancel() + assert b"\r\n\r\n" in fake_transport.sent + + async def test_send_keepalive__stops_when_transport_is_none(self, sip): + """Stop the keepalive loop immediately when transport is cleared.""" + sip.transport = None + sip.keepalive_interval = datetime.timedelta(milliseconds=1) + await sip.send_keepalive() + + +class TestSessionInitiationProtocolDataReceived: + def test_data_received__pong(self, sip): + r"""Handle \r\n as a PONG without sending a reply.""" + initial_sent = len(sip.transport.sent) + sip.data_received(b"\r\n") + assert len(sip.transport.sent) == initial_sent + + def test_data_received__ping__sends_pong(self, sip, fake_transport): + r"""Reply with \r\n when a PING (\r\n\r\n) is received.""" + sip.data_received(b"\r\n\r\n") + assert b"\r\n" in fake_transport.sent + + def test_data_received__sip_request(self, sip): + """Dispatch a valid SIP request to request_received without error.""" + before = len(sip.transactions) + sip.data_received(INVITE_BYTES) + # An InviteTransaction is added to transactions + assert len(sip.transactions) > before + + def test_data_received__sip_response(self, sip): + """Dispatch a valid SIP response to response_received.""" + branch = list(sip.transactions.keys())[0] + response_bytes = ( + f"SIP/2.0 200 OK\r\n" + f"Via: SIP/2.0/TLS example.com;branch={branch}\r\n" + f"From: sip:alice@example.com;tag=local-tag\r\n" + f"To: sip:example.com;tag=remote-tag\r\n" + f"Call-ID: call-id@example.com\r\n" + f"CSeq: 1 REGISTER\r\n" + f"\r\n" + ).encode() + sip.data_received(response_bytes) + + +class TestSessionInitiationProtocolSend: + def test_send__writes_message_bytes(self, sip, fake_transport): + """Serialize and write a SIP message to the transport.""" + response = Response(status_code=200, phrase="OK") + sip.send(response) + assert bytes(response) in fake_transport.sent + + def test_send__with_no_transport(self, sip): + """Skip writing when transport is None.""" + sip.transport = None + response = Response(status_code=200, phrase="OK") + sip.send(response) + + +class TestSessionInitiationProtocolClose: + def test_close__closes_transport(self, sip, fake_transport): + """Close the underlying transport.""" + sip.close() + assert fake_transport.closed is True + + def test_close__with_no_transport(self, sip): + """Do nothing when transport is already None.""" + sip.transport = None + sip.close() + + +class TestSessionInitiationProtocolAllowedMethods: + def test_allowed_methods__includes_options(self, sip): + """Always include OPTIONS in allowed methods.""" + assert "OPTIONS" in sip.allowed_methods + + def test_allowed_methods__includes_invite_when_transaction_class_has_handler( + self, sip + ): + """Include INVITE when transaction_class defines invite_received.""" + assert SIPMethod.INVITE in sip.allowed_methods + + def test_allow_header__is_comma_separated_string(self, sip): + """allow_header returns a comma-separated string of supported methods.""" + header = sip.allow_header + assert "OPTIONS" in header + assert "," in header + + +class TestSessionInitiationProtocolMethodNotAllowed: + def test_method_not_allowed__sends_405(self, sip, fake_transport): + """Send a 405 Method Not Allowed response.""" + request = Message.parse( + b"PUBLISH sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKpub\r\n" + b"From: sip:bob@biloxi.com;tag=t1\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: pub-call@biloxi.com\r\n" + b"CSeq: 1 PUBLISH\r\n" + b"\r\n" + ) + sip.method_not_allowed(request) + assert any(b"405" in data for data in fake_transport.sent) + + +class TestSessionInitiationProtocolRequestReceived: + def test_request_received__options__sends_200(self, sip, fake_transport): + """Reply with 200 OK for an OPTIONS request.""" + request = Message.parse( + b"OPTIONS sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKopt\r\n" + b"From: sip:bob@biloxi.com;tag=t1\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: opt-call@biloxi.com\r\n" + b"CSeq: 2 OPTIONS\r\n" + b"\r\n" + ) + sip.request_received(request) + assert any(b"200" in data for data in fake_transport.sent) + + def test_request_received__invite__creates_transaction(self, sip): + """Create an InviteTransaction for an incoming INVITE.""" + request = Message.parse(INVITE_BYTES) + sip.request_received(request) + assert request.branch in sip.transactions + + def test_request_received__unsupported_method__sends_405(self, sip, fake_transport): + """Send 405 for a method not handled by the transaction class.""" + request = Message.parse( + b"PUBLISH sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKpub2\r\n" + b"From: sip:bob@biloxi.com;tag=t2\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: pub2-call@biloxi.com\r\n" + b"CSeq: 1 PUBLISH\r\n" + b"\r\n" + ) + sip.request_received(request) + assert any(b"405" in data for data in fake_transport.sent) + + def test_request_received__cancel__dispatches_to_existing_transaction( + self, sip, fake_transport + ): + """Dispatch a CANCEL to the matching INVITE transaction.""" + invite = Message.parse(INVITE_BYTES) + sip.request_received(invite) + tx = sip.transactions[invite.branch] + sip.dialogs[(tx.dialog.remote_tag, tx.dialog.local_tag)] = tx.dialog + + cancel = Message.parse( + b"CANCEL sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKabc123\r\n" + b"From: sip:bob@biloxi.com;tag=from-tag-1\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: test-call-id@biloxi.com\r\n" + b"CSeq: 1 CANCEL\r\n" + b"\r\n" + ) + sip.request_received(cancel) + assert any(b"200" in data for data in fake_transport.sent) + + def test_request_received__cancel__gone_when_no_transaction( + self, sip, fake_transport + ): + """Send 410 Gone for a CANCEL with no matching transaction.""" + cancel = Message.parse( + b"CANCEL sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKnone\r\n" + b"From: sip:bob@biloxi.com;tag=t3\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: no-tx@biloxi.com\r\n" + b"CSeq: 1 CANCEL\r\n" + b"\r\n" + ) + sip.request_received(cancel) + assert any(b"410" in data for data in fake_transport.sent) + + +class TestSessionInitiationProtocolResponseReceived: + async def test_response_received__delegates_to_transaction(self, sip): + """Delegate a response to the matching transaction by branch.""" + branch = list(sip.transactions.keys())[0] + response = Message.parse( + f"SIP/2.0 200 OK\r\n" + f"Via: SIP/2.0/TLS example.com;branch={branch}\r\n" + f"From: sip:alice@example.com;tag=local-tag\r\n" + f"To: sip:example.com;tag=remote-tag\r\n" + f"Call-ID: call@example.com\r\n" + f"CSeq: 1 REGISTER\r\n" + f"\r\n".encode() + ) + sip.response_received(response) + + +class TestSessionInitiationProtocolContact: + def test_contact__sips_aor_produces_sips_contact(self, sip): + """Build a sips: Contact for a sips: AOR.""" + assert sip.contact.startswith(" initial_sent_count + second_register = b"".join(transport.sent[initial_sent_count:]) + assert b"Authorization:" in second_register + + def test_response_received__401_with_qop(self): + """Retry with digest credentials and qop=auth after 401 with qop option.""" + transport = FakeTransport() + sip = create_sip_session(fake_transport=transport) + tx = RegistrationTransaction(sip=sip, method=SIPMethod.REGISTER) + sip.transactions[tx.branch] = tx + + response = Message.parse( + f"SIP/2.0 401 Unauthorized\r\n" + f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" + f"From: sip:alice@example.com;tag=lt\r\n" + f"To: sip:example.com;tag=rt\r\n" + f"Call-ID: qop-call@example.com\r\n" + f"CSeq: 1 REGISTER\r\n" + f'WWW-Authenticate: Digest realm="example.com", nonce="nonce1", qop="auth", algorithm=SHA-256\r\n' + f"\r\n".encode() + ) + tx.response_received(response) + second_register = b"".join(transport.sent[1:]) + assert b"qop=auth" in second_register + + def test_response_received__401_with_opaque(self): + """Include opaque parameter in Authorization when challenge includes opaque.""" + transport = FakeTransport() + sip = create_sip_session(fake_transport=transport) + tx = RegistrationTransaction(sip=sip, method=SIPMethod.REGISTER) + sip.transactions[tx.branch] = tx + + response = Message.parse( + f"SIP/2.0 401 Unauthorized\r\n" + f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" + f"From: sip:alice@example.com;tag=lt\r\n" + f"To: sip:example.com;tag=rt\r\n" + f"Call-ID: opaque-call@example.com\r\n" + f"CSeq: 1 REGISTER\r\n" + f'WWW-Authenticate: Digest realm="example.com", nonce="nonce2", opaque="myopaque", algorithm=SHA-256\r\n' + f"\r\n".encode() + ) + tx.response_received(response) + second_register = b"".join(transport.sent[1:]) + assert b"opaque=" in second_register + + def test_response_received__407_sends_proxy_credentials(self): + """Retry with Proxy-Authorization after receiving 407.""" + transport = FakeTransport() + sip = create_sip_session(fake_transport=transport) + tx = RegistrationTransaction(sip=sip, method=SIPMethod.REGISTER) + sip.transactions[tx.branch] = tx + initial_sent_count = len(transport.sent) + + response = Message.parse( + f"SIP/2.0 407 Proxy Authentication Required\r\n" + f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" + f"From: sip:alice@example.com;tag=lt\r\n" + f"To: sip:example.com;tag=rt\r\n" + f"Call-ID: proxy-reg@example.com\r\n" + f"CSeq: 1 REGISTER\r\n" + f'Proxy-Authenticate: Digest realm="example.com", nonce="proxy-nonce", algorithm=SHA-256\r\n' + f"\r\n".encode() + ) + tx.response_received(response) + second_register = b"".join(transport.sent[initial_sent_count:]) + assert b"Proxy-Authorization:" in second_register + + def test_response_received__unknown_status__raises(self): + """Raise NotImplementedError for unrecognised status codes.""" + transport = FakeTransport() + sip = create_sip_session(fake_transport=transport) + tx = RegistrationTransaction(sip=sip, method=SIPMethod.REGISTER) + sip.transactions[tx.branch] = tx + + response = Message.parse( + f"SIP/2.0 500 Server Internal Error\r\n" + f"Via: SIP/2.0/TLS example.com;branch={tx.branch}\r\n" + f"From: sip:alice@example.com;tag=lt\r\n" + f"To: sip:example.com;tag=rt\r\n" + f"Call-ID: err-call@example.com\r\n" + f"CSeq: 1 REGISTER\r\n" + f"\r\n".encode() + ) + with pytest.raises(NotImplementedError): + tx.response_received(response) + + def test_parse_auth_challenge__parses_realm_and_nonce(self): + """Extract realm and nonce from a Digest challenge header.""" + header = 'Digest realm="example.com", nonce="abc123"' + params = RegistrationTransaction.parse_auth_challenge(header) + assert params["realm"] == "example.com" + assert params["nonce"] == "abc123" + + def test_parse_auth_challenge__empty_header(self): + """Return empty dict for an empty challenge header.""" + assert RegistrationTransaction.parse_auth_challenge("") == {} + + def test_parse_auth_challenge__multiple_params(self): + """Parse multiple parameters including algorithm and qop.""" + header = 'Digest realm="test.com", nonce="xyz", algorithm=SHA-256, qop="auth"' + params = RegistrationTransaction.parse_auth_challenge(header) + assert params["realm"] == "test.com" + assert params["algorithm"] == "SHA-256" + assert params["qop"] == "auth" + + def test_digest_response__sha256(self): + """Compute a deterministic SHA-256 digest response.""" + result = RegistrationTransaction.digest_response( + username="alice", + password="secret", # noqa: S106 + realm="example.com", + nonce="nonce123", + method="REGISTER", + uri="example.com", + algorithm=DigestAlgorithm.SHA_256, + ) + assert isinstance(result, str) + assert len(result) == 64 + + def test_digest_response__md5(self): + """Compute a deterministic MD5 digest response.""" + result = RegistrationTransaction.digest_response( + username="alice", + password="secret", # noqa: S106 + realm="example.com", + nonce="nonce123", + method="REGISTER", + uri="example.com", + algorithm=DigestAlgorithm.MD5, + ) + assert len(result) == 32 + + def test_digest_response__with_qop_auth(self): + """Include nc and cnonce in the digest when qop=auth.""" + result = RegistrationTransaction.digest_response( + username="alice", + password="secret", # noqa: S106 + realm="example.com", + nonce="nonce123", + method="REGISTER", + uri="example.com", + algorithm=DigestAlgorithm.SHA_256, + qop=DigestQoP.AUTH, + cnonce="clientnonce", + ) + assert isinstance(result, str) + + def test_digest_response__sess_algorithm_requires_cnonce(self): + """Raise ValueError when a -sess algorithm is used without cnonce.""" + with pytest.raises(ValueError, match="cnonce"): + RegistrationTransaction.digest_response( + username="alice", + password="secret", # noqa: S106 + realm="example.com", + nonce="nonce123", + method="REGISTER", + uri="example.com", + algorithm=DigestAlgorithm.SHA_256_SESS, + cnonce=None, + ) + + def test_digest_response__sess_algorithm_with_cnonce(self): + """Compute a digest with a -sess algorithm when cnonce is provided.""" + result = RegistrationTransaction.digest_response( + username="alice", + password="secret", # noqa: S106 + realm="example.com", + nonce="nonce123", + method="REGISTER", + uri="example.com", + algorithm=DigestAlgorithm.SHA_256_SESS, + cnonce="client-cnonce", + ) + assert isinstance(result, str) + + def test_digest_response__unsupported_algorithm_raises(self): + """Raise ValueError for unrecognised digest algorithm.""" + with pytest.raises(ValueError, match="Unsupported"): + RegistrationTransaction.digest_response( + username="alice", + password="secret", # noqa: S106 + realm="example.com", + nonce="nonce123", + method="REGISTER", + uri="example.com", + algorithm="UNKNOWN-ALG", + ) + + +# --------------------------------------------------------------------------- +# InviteTransaction +# --------------------------------------------------------------------------- + + +class TestInviteTransaction: + def test_invite_received__is_noop(self): + """invite_received base implementation does nothing.""" + sip = create_sip_session() + request = Message.parse(INVITE_BYTES) + tx = InviteTransaction.from_request(request=request, sip=sip) + assert tx.invite_received(request) is None + + def test_ack_received__removes_transaction(self): + """ack_received removes the transaction from sip.transactions.""" + sip = create_sip_session() + request = Message.parse(INVITE_BYTES) + tx = InviteTransaction.from_request(request=request, sip=sip) + sip.transactions[tx.branch] = tx + + ack = Message.parse( + b"ACK sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKabc123\r\n" + b"From: sip:bob@biloxi.com;tag=from-tag-1\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: test-call-id@biloxi.com\r\n" + b"CSeq: 1 ACK\r\n" + b"\r\n" + ) + tx.ack_received(ack) + assert tx.branch not in sip.transactions + + def test_bye_received__removes_dialog_and_sends_200(self): + """bye_received removes the dialog and sends 200 OK.""" + transport = FakeTransport() + sip = create_sip_session(fake_transport=transport) + request = Message.parse(INVITE_BYTES) + tx = InviteTransaction.from_request(request=request, sip=sip) + sip.dialogs[(tx.dialog.remote_tag, tx.dialog.local_tag)] = tx.dialog + + bye = Message.parse( + b"BYE sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKbye001\r\n" + b"From: sip:bob@biloxi.com;tag=from-tag-1\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: test-call-id@biloxi.com\r\n" + b"CSeq: 2 BYE\r\n" + b"\r\n" + ) + tx.bye_received(bye) + assert any(b"200" in data for data in transport.sent) + assert (tx.dialog.remote_tag, tx.dialog.local_tag) not in sip.dialogs + + def test_cancel_received__removes_transaction_and_sends_200(self): + """cancel_received removes the transaction, the dialog, and sends 200 OK.""" + transport = FakeTransport() + sip = create_sip_session(fake_transport=transport) + request = Message.parse(INVITE_BYTES) + tx = InviteTransaction.from_request(request=request, sip=sip) + sip.transactions[tx.branch] = tx + sip.dialogs[(tx.dialog.remote_tag, tx.dialog.local_tag)] = tx.dialog + + cancel = Message.parse( + b"CANCEL sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKabc123\r\n" + b"From: sip:bob@biloxi.com;tag=from-tag-1\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: test-call-id@biloxi.com\r\n" + b"CSeq: 1 CANCEL\r\n" + b"\r\n" + ) + tx.cancel_received(cancel) + assert tx.branch not in sip.transactions + assert any(b"200" in data for data in transport.sent) + + def test_ringing__sends_180(self): + """Ringing sends a 180 Ringing provisional response.""" + transport = FakeTransport() + sip = create_sip_session(fake_transport=transport) + request = Message.parse(INVITE_BYTES) + tx = InviteTransaction.from_request(request=request, sip=sip) + tx.ringing() + assert any(b"180" in data for data in transport.sent) + + def test_reject__sends_busy_here_by_default(self): + """Reject sends 486 Busy Here when no status code is specified.""" + transport = FakeTransport() + sip = create_sip_session(fake_transport=transport) + request = Message.parse(INVITE_BYTES) + tx = InviteTransaction.from_request(request=request, sip=sip) + tx.reject() + assert any(b"486" in data for data in transport.sent) + + def test_reject__sends_custom_status_code(self): + """Reject sends the specified status code.""" + transport = FakeTransport() + sip = create_sip_session(fake_transport=transport) + request = Message.parse(INVITE_BYTES) + tx = InviteTransaction.from_request(request=request, sip=sip) + tx.reject(SIPStatus.NOT_FOUND) + assert any(b"404" in data for data in transport.sent) + + def test_answer__without_sdp__sends_200_ok(self): + """Answer sends 200 OK with SDP even when the INVITE has no body.""" + import ipaddress + + from voip.types import NetworkAddress + + transport = FakeTransport() + rtp = RealtimeTransportProtocol() + rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + sip = create_sip_session(fake_transport=transport, rtp=rtp) + request = Message.parse(INVITE_BYTES) + tx = InviteTransaction.from_request(request=request, sip=sip) + tx.answer(call_class=CallFixture) + assert any(b"200" in data for data in transport.sent) + assert any(b"application/sdp" in data for data in transport.sent) + + def test_answer__with_sdp__negotiates_codec(self): + """Answer negotiates a codec from the SDP offer in the INVITE.""" + import ipaddress + + from voip.types import NetworkAddress + + transport = FakeTransport() + rtp = RealtimeTransportProtocol() + rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + sip = create_sip_session(fake_transport=transport, rtp=rtp) + request = Message.parse(INVITE_WITH_SDP_BYTES) + tx = InviteTransaction.from_request(request=request, sip=sip) + tx.answer(call_class=CallFixture) + assert any(b"200" in data for data in transport.sent) + + def test_answer__stores_dialog(self): + """Answer stores the dialog in sip.dialogs.""" + import ipaddress + + from voip.types import NetworkAddress + + transport = FakeTransport() + rtp = RealtimeTransportProtocol() + rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + sip = create_sip_session(fake_transport=transport, rtp=rtp) + request = Message.parse(INVITE_BYTES) + tx = InviteTransaction.from_request(request=request, sip=sip) + tx.answer(call_class=CallFixture) + assert len(sip.dialogs) > 0 + + def test_answer__with_record_route(self): + """Include Record-Route in 200 OK when present in the INVITE.""" + import ipaddress + + from voip.types import NetworkAddress + + transport = FakeTransport() + rtp = RealtimeTransportProtocol() + rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + sip = create_sip_session(fake_transport=transport, rtp=rtp) + request = Message.parse( + b"INVITE sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKrr99\r\n" + b"From: sip:bob@biloxi.com;tag=rr-tag\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: rr-call@biloxi.com\r\n" + b"CSeq: 1 INVITE\r\n" + b"Record-Route: \r\n" + b"\r\n" + ) + tx = InviteTransaction.from_request(request=request, sip=sip) + tx.answer(call_class=CallFixture) + ok_data = b"".join(transport.sent) + assert b"Record-Route:" in ok_data + + async def test_make_call__raises_not_implemented(self): + """make_call raises NotImplementedError since it is not yet implemented.""" + sip = create_sip_session() + request = Message.parse(INVITE_BYTES) + tx = InviteTransaction.from_request(request=request, sip=sip) + with pytest.raises(NotImplementedError): + await tx.make_call("sip:bob@biloxi.com", call_class=CallFixture) + + def test_answer__sdp_without_connection_uses_peer_address(self): + """Use the transport peer address when SDP has no c= connection line.""" + import ipaddress + + from voip.types import NetworkAddress + + transport = FakeTransport() + rtp = RealtimeTransportProtocol() + rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + sip = create_sip_session(fake_transport=transport, rtp=rtp) + # INVITE SDP with audio port > 0 but no c= connection line + request = Message.parse( + b"INVITE sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKnoconn\r\n" + b"From: sip:bob@biloxi.com;tag=noconn-tag\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: noconn-call@biloxi.com\r\n" + b"CSeq: 1 INVITE\r\n" + b"Content-Type: application/sdp\r\n" + b"\r\n" + b"v=0\r\n" + b"o=- 1 1 IN IP4 192.0.2.1\r\n" + b"s=-\r\n" + b"t=0 0\r\n" + b"m=audio 5004 RTP/AVP 0\r\n" + b"a=rtpmap:0 PCMU/8000\r\n" + ) + tx = InviteTransaction.from_request(request=request, sip=sip) + tx.answer(call_class=CallFixture) + assert any(b"200" in data for data in transport.sent) + + def test_answer__sdp_with_zero_port_uses_none_rtp_address(self): + """Use None for RTP address when audio port is 0 in SDP offer.""" + import ipaddress + + from voip.types import NetworkAddress + + transport = FakeTransport() + rtp = RealtimeTransportProtocol() + rtp.public_address = NetworkAddress(ipaddress.ip_address("192.0.2.1"), 5004) + sip = create_sip_session(fake_transport=transport, rtp=rtp) + # INVITE SDP with port=0 (rejected audio) + request = Message.parse( + b"INVITE sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKzeroport\r\n" + b"From: sip:bob@biloxi.com;tag=zero-tag\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: zero-call@biloxi.com\r\n" + b"CSeq: 1 INVITE\r\n" + b"Content-Type: application/sdp\r\n" + b"\r\n" + b"v=0\r\n" + b"o=- 1 1 IN IP4 192.0.2.1\r\n" + b"s=-\r\n" + b"c=IN IP4 192.0.2.1\r\n" + b"t=0 0\r\n" + b"m=audio 0 RTP/AVP 0\r\n" + ) + tx = InviteTransaction.from_request(request=request, sip=sip) + tx.answer(call_class=CallFixture) + assert any(b"200" in data for data in transport.sent) + + +# --------------------------------------------------------------------------- +# RegistrationError +# --------------------------------------------------------------------------- + + +class TestRegistrationError: + def test_registration_error__is_exception(self): + """RegistrationError is a subclass of Exception.""" + assert issubclass(RegistrationError, Exception) + + def test_registration_error__can_be_raised(self): + """RegistrationError can be raised and caught.""" + with pytest.raises(RegistrationError, match="403 Forbidden"): + raise RegistrationError("403 Forbidden") + + def test_registration_error__message(self): + """RegistrationError stores the message string.""" + err = RegistrationError("500 Server Error") + assert str(err) == "500 Server Error" diff --git a/tests/sip/test_types.py b/tests/sip/test_types.py index c57db9a..8a174d7 100644 --- a/tests/sip/test_types.py +++ b/tests/sip/test_types.py @@ -5,6 +5,7 @@ import pytest from voip.sip import SipUri from voip.sip.messages import Response +from voip.sip.types import CallerID class TestSipUri: @@ -278,3 +279,128 @@ def _ok() -> Response: def _trying() -> Response: return Response(status_code=100, phrase="Trying") + + +class TestSipUriMaddr: + def test_maddr__with_parameter(self): + """Parse NetworkAddress from maddr URI parameter.""" + uri = SipUri.parse("sip:alice@example.com;maddr=192.0.2.1:5060") + assert uri.maddr == ("192.0.2.1", 5060) + + def test_maddr__without_parameter(self): + """Fall back to host:port when maddr parameter is absent.""" + uri = SipUri.parse("sip:alice@192.0.2.2:5060") + result = uri.maddr + assert result.port == 5060 + + def test_ttl__returns_value(self): + """Return the ttl URI parameter value as a string.""" + uri = SipUri.parse("sip:alice@example.com;ttl=30") + assert uri.ttl == "30" + + def test_transport__sips_returns_tls(self): + """Return 'TLS' for sips: URIs that have no transport parameter.""" + uri = SipUri.parse("sips:alice@example.com") + assert uri.transport == "TLS" + + def test_transport__sip_without_parameter_returns_none(self): + """Return None for a plain sip: URI without transport parameter.""" + uri = SipUri.parse("sip:alice@example.com") + assert uri.transport is None + + def test_transport__explicit_parameter(self): + """Return explicit transport parameter value.""" + uri = SipUri.parse("sip:alice@example.com;transport=udp") + assert uri.transport == "udp" + + +class TestCallerID: + def test_display_name__quoted(self): + """Parse a quoted display name before the angle bracket.""" + assert ( + CallerID('"Alice Smith" ').display_name + == "Alice Smith" + ) + + def test_display_name__unquoted(self): + """Parse an unquoted display name before the angle bracket.""" + assert CallerID("Alice ").display_name == "Alice" + + def test_display_name__absent(self): + """Return None when there is no display name.""" + assert CallerID("sip:alice@example.com").display_name is None + + def test_user__present(self): + """Extract the SIP user part.""" + assert CallerID("sip:08001234567@example.com").user == "08001234567" + + def test_user__absent(self): + """Return None when no SIP user is present.""" + assert CallerID("example.com").user is None + + def test_host__present(self): + """Extract the carrier domain from the SIP URI.""" + assert CallerID("sip:alice@example.com").host == "example.com" + + def test_host__absent(self): + """Return None when no host is found.""" + assert CallerID("plain string").host is None + + def test_tag__present(self): + """Extract the dialog tag parameter.""" + assert CallerID("sip:alice@example.com;tag=abc123").tag == "abc123" + + def test_tag__absent(self): + """Return None when no tag parameter is present.""" + assert CallerID("sip:alice@example.com").tag is None + + def test_repr__long_user(self): + """Mask all but the last four chars of a long caller string.""" + assert ( + repr(CallerID('"08001234567" ;tag=abc')) + == "*******4567@telefonica.de" + ) + + def test_repr__short_user(self): + """Show all characters when the name is four characters or fewer.""" + assert repr(CallerID("sip:alice@example.com")) == "*lice@example.com" + + def test_repr__no_user_no_host(self): + """Fall back to asterisks when neither user nor host can be parsed.""" + assert "****" in repr(CallerID("")) + + def test_repr__no_host(self): + """Show only masked user when there is no carrier domain.""" + masked = repr(CallerID("notasipuri")) + assert "@" not in masked + + +class TestMaskCaller: + def test_mask_caller__with_display_name(self): + """Mask all but last four chars of a quoted display name.""" + from voip.sip.types import _mask_caller + + assert ( + _mask_caller('"08001234567" ;tag=abc') + == "*******4567" + ) + + def test_mask_caller__bare_uri(self): + """Mask user part from a bare SIP URI.""" + from voip.sip.types import _mask_caller + + assert _mask_caller("sip:alice@example.com") == "*lice" + + def test_mask_caller__short_name(self): + """Return the name unchanged when it is four characters or fewer.""" + from voip.sip.types import _mask_caller + + assert _mask_caller("sip:bob@example.com") == "bob" + + def test_mask_caller__long_name(self): + """Mask all but last four characters of a long username.""" + from voip.sip.types import _mask_caller + + result = _mask_caller("sip:verylonguser@example.com") + assert result.endswith("user") + assert result.startswith("*") From d6657e556a2c130c7bc948647c334e031802f352 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:05:28 +0000 Subject: [PATCH 3/9] Add 100% test coverage for SIP protocol/transaction layer Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> Agent-Logs-Url: https://github.com/codingjoe/VoIP/sessions/dd6b4390-f976-4e9d-ae5f-e207b4c92ae9 --- tests/sip/test_protocol.py | 2 +- tests/sip/test_transactions.py | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/sip/test_protocol.py b/tests/sip/test_protocol.py index e127cde..e7bc10d 100644 --- a/tests/sip/test_protocol.py +++ b/tests/sip/test_protocol.py @@ -99,7 +99,7 @@ async def test_send_keepalive__stops_when_transport_is_none(self, sip): class TestSessionInitiationProtocolDataReceived: def test_data_received__pong(self, sip): - r"""Handle \r\n as a PONG without sending a reply.""" + r"""Receive a PONG (\r\n keepalive reply) without sending any reply.""" initial_sent = len(sip.transport.sent) sip.data_received(b"\r\n") assert len(sip.transport.sent) == initial_sent diff --git a/tests/sip/test_transactions.py b/tests/sip/test_transactions.py index 7aacf71..f3b4b1f 100644 --- a/tests/sip/test_transactions.py +++ b/tests/sip/test_transactions.py @@ -14,6 +14,12 @@ from .conftest import INVITE_BYTES, INVITE_WITH_SDP_BYTES, CallFixture, FakeTransport +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +TEST_PASSWORD = "secret" # noqa: S105 + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -323,7 +329,7 @@ def test_digest_response__sha256(self): """Compute a deterministic SHA-256 digest response.""" result = RegistrationTransaction.digest_response( username="alice", - password="secret", # noqa: S106 + password=TEST_PASSWORD, realm="example.com", nonce="nonce123", method="REGISTER", @@ -337,7 +343,7 @@ def test_digest_response__md5(self): """Compute a deterministic MD5 digest response.""" result = RegistrationTransaction.digest_response( username="alice", - password="secret", # noqa: S106 + password=TEST_PASSWORD, realm="example.com", nonce="nonce123", method="REGISTER", @@ -350,7 +356,7 @@ def test_digest_response__with_qop_auth(self): """Include nc and cnonce in the digest when qop=auth.""" result = RegistrationTransaction.digest_response( username="alice", - password="secret", # noqa: S106 + password=TEST_PASSWORD, realm="example.com", nonce="nonce123", method="REGISTER", @@ -366,7 +372,7 @@ def test_digest_response__sess_algorithm_requires_cnonce(self): with pytest.raises(ValueError, match="cnonce"): RegistrationTransaction.digest_response( username="alice", - password="secret", # noqa: S106 + password=TEST_PASSWORD, realm="example.com", nonce="nonce123", method="REGISTER", @@ -379,7 +385,7 @@ def test_digest_response__sess_algorithm_with_cnonce(self): """Compute a digest with a -sess algorithm when cnonce is provided.""" result = RegistrationTransaction.digest_response( username="alice", - password="secret", # noqa: S106 + password=TEST_PASSWORD, realm="example.com", nonce="nonce123", method="REGISTER", @@ -394,7 +400,7 @@ def test_digest_response__unsupported_algorithm_raises(self): with pytest.raises(ValueError, match="Unsupported"): RegistrationTransaction.digest_response( username="alice", - password="secret", # noqa: S106 + password=TEST_PASSWORD, realm="example.com", nonce="nonce123", method="REGISTER", From 4762b05eede5f5fd88b0e86558d694a55779a8ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:00:36 +0000 Subject: [PATCH 4/9] Reorganize test classes: one TestMyClass per API class, test_method__scenario naming Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> Agent-Logs-Url: https://github.com/codingjoe/VoIP/sessions/50ee7641-f523-4ea5-b7b9-ac597fe93272 --- tests/sip/test_messages.py | 158 +++++++++++++++------------------ tests/sip/test_protocol.py | 22 +---- tests/sip/test_transactions.py | 6 +- tests/sip/test_types.py | 18 ++-- 4 files changed, 83 insertions(+), 121 deletions(-) diff --git a/tests/sip/test_messages.py b/tests/sip/test_messages.py index e1cfafc..a23006e 100644 --- a/tests/sip/test_messages.py +++ b/tests/sip/test_messages.py @@ -2,11 +2,11 @@ import pytest from voip.sdp.messages import SessionDescription -from voip.sip.messages import Message, Request, Response -from voip.sip.types import CallerID +from voip.sip.messages import Dialog, Message, Request, Response +from voip.sip.types import CallerID, SipUri -class TestSIPMessage: +class TestMessage: def test_parse__request(self): """Parse a SIP request from bytes.""" data = ( @@ -123,7 +123,7 @@ def test_parse__raises_value_error_on_invalid_first_line(self): with pytest.raises(ValueError, match="Invalid SIP message"): Message.parse(b"TOOSHORT\r\n\r\n") - def test_str__returns_decoded_bytes(self): + def test___str____returns_decoded_bytes(self): """Return the string representation of a request as decoded bytes.""" request = Request( method="REGISTER", @@ -132,9 +132,52 @@ def test_str__returns_decoded_bytes(self): ) assert str(request) == bytes(request).decode() + def test_branch__extracts_via_branch_parameter(self): + """Return the branch parameter from the top Via header.""" + data = ( + b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" + b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bKabc\r\n" + b"\r\n" + ) + request = Message.parse(data) + assert request.branch == "z9hG4bKabc" + + def test_remote_tag__with_tag(self): + """Return the To-header tag parameter.""" + data = ( + b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" + b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bKabc\r\n" + b"To: sip:bob@biloxi.com;tag=to-tag-1\r\n" + b"\r\n" + ) + request = Message.parse(data) + assert request.remote_tag == "to-tag-1" + + def test_local_tag__with_tag(self): + """Return the From-header tag parameter.""" + data = ( + b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" + b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bKabc\r\n" + b"From: sip:alice@atlanta.com;tag=from-tag-1\r\n" + b"\r\n" + ) + request = Message.parse(data) + assert request.local_tag == "from-tag-1" + + def test_sequence__returns_cseq_number(self): + """Return the integer sequence number from the CSeq header.""" + data = ( + b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" + b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bKabc\r\n" + b"CSeq: 42 INVITE\r\n" + b"\r\n" + ) + request = Message.parse(data) + assert request.sequence == 42 + class TestRequest: - def test_request__bytes(self): + def test___bytes__(self): """Serialize a SIP request to bytes.""" request = Request( method="INVITE", @@ -147,7 +190,7 @@ def test_request__bytes(self): b"\r\n" ) - def test_request__bytes__with_sdp_body(self): + def test___bytes____with_sdp_body(self): """Serialize a SIP request with an SDP body to bytes.""" sdp = SessionDescription() request = Request( @@ -159,8 +202,8 @@ def test_request__bytes__with_sdp_body(self): assert b"Content-Length:" in serialized assert b"v=0" in serialized - def test_via_branch__with_branch(self): - """via_branch returns the branch parameter from the Via header.""" + def test_branch__with_branch(self): + """branch returns the branch parameter from the Via header.""" request = Request( method="INVITE", uri="sip:bob@biloxi.com", @@ -168,9 +211,26 @@ def test_via_branch__with_branch(self): ) assert request.branch == "z9hG4bKabc123" + def test_from_dialog__merges_dialog_headers(self): + """Merge the provided headers with the dialog's headers.""" + dialog = Dialog( + uac=SipUri.parse("sips:alice@example.com"), + local_tag="local-tag", + remote_tag="remote-tag", + ) + request = Request.from_dialog( + dialog=dialog, + headers={"Via": "SIP/2.0/TLS example.com;branch=z9hG4bK123"}, + method="REGISTER", + uri="sips:example.com", + ) + assert "From" in request.headers + assert "Call-ID" in request.headers + assert "Via" in request.headers + class TestResponse: - def test_response__bytes(self): + def test___bytes__(self): """Serialize a SIP response to bytes.""" response = Response( status_code=200, @@ -183,7 +243,7 @@ def test_response__bytes(self): b"\r\n" ) - def test_response__bytes__with_sdp_body(self): + def test___bytes____with_sdp_body(self): """Serialize a SIP response with an SDP body to bytes.""" sdp = SessionDescription() response = Response(status_code=200, phrase="OK", body=sdp) @@ -191,7 +251,7 @@ def test_response__bytes__with_sdp_body(self): assert b"Content-Length:" in serialized assert b"v=0" in serialized - def test_response__bytes__with_sdp_body__auto_content_length(self): + def test___bytes____with_sdp_body__auto_content_length(self): """Auto-calculate Content-Length when SDP body is present and header is not set.""" sdp = SessionDescription() response = Response(status_code=200, phrase="OK", body=sdp) @@ -200,70 +260,8 @@ def test_response__bytes__with_sdp_body__auto_content_length(self): parsed = Message.parse(serialized) assert parsed.body is None - -class TestMessageProperties: - def test_remote_tag__with_tag(self): - """Return the To-header tag parameter.""" - data = ( - b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" - b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bKabc\r\n" - b"To: sip:bob@biloxi.com;tag=to-tag-1\r\n" - b"\r\n" - ) - request = Message.parse(data) - assert request.remote_tag == "to-tag-1" - - def test_local_tag__with_tag(self): - """Return the From-header tag parameter.""" - data = ( - b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" - b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bKabc\r\n" - b"From: sip:alice@atlanta.com;tag=from-tag-1\r\n" - b"\r\n" - ) - request = Message.parse(data) - assert request.local_tag == "from-tag-1" - - def test_sequence__returns_cseq_number(self): - """Return the integer sequence number from the CSeq header.""" - data = ( - b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" - b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bKabc\r\n" - b"CSeq: 42 INVITE\r\n" - b"\r\n" - ) - request = Message.parse(data) - assert request.sequence == 42 - - -class TestRequestFromDialog: - def test_from_dialog__merges_dialog_headers(self): - """Merge the provided headers with the dialog's headers.""" - from voip.sip.messages import Dialog - from voip.sip.types import SipUri - - dialog = Dialog( - uac=SipUri.parse("sips:alice@example.com"), - local_tag="local-tag", - remote_tag="remote-tag", - ) - request = Request.from_dialog( - dialog=dialog, - headers={"Via": "SIP/2.0/TLS example.com;branch=z9hG4bK123"}, - method="REGISTER", - uri="sips:example.com", - ) - assert "From" in request.headers - assert "Call-ID" in request.headers - assert "Via" in request.headers - - -class TestResponseFromRequest: def test_from_request__with_dialog_remote_tag(self): """Include dialog remote_tag in To header when dialog has a remote_tag.""" - from voip.sip.messages import Dialog - from voip.sip.types import SipUri - data = ( b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bKabc\r\n" @@ -302,9 +300,6 @@ def test_from_request__without_dialog(self): class TestDialog: def test_from_header__contains_local_tag(self): """from_header includes the local_tag parameter.""" - from voip.sip.messages import Dialog - from voip.sip.types import SipUri - dialog = Dialog( uac=SipUri.parse("sips:alice@example.com"), local_tag="my-local-tag", @@ -313,9 +308,6 @@ def test_from_header__contains_local_tag(self): def test_to_header__without_remote_tag(self): """to_header omits the tag parameter when remote_tag is None.""" - from voip.sip.messages import Dialog - from voip.sip.types import SipUri - dialog = Dialog( uac=SipUri.parse("sip:bob@biloxi.com:5060"), remote_tag=None, @@ -324,9 +316,6 @@ def test_to_header__without_remote_tag(self): def test_to_header__with_remote_tag(self): """to_header includes the remote_tag parameter.""" - from voip.sip.messages import Dialog - from voip.sip.types import SipUri - dialog = Dialog( uac=SipUri.parse("sip:bob@biloxi.com:5060"), remote_tag="their-tag", @@ -335,9 +324,6 @@ def test_to_header__with_remote_tag(self): def test_headers__returns_required_keys(self): """Headers property returns From, To, and Call-ID keys.""" - from voip.sip.messages import Dialog - from voip.sip.types import SipUri - dialog = Dialog(uac=SipUri.parse("sips:alice@example.com")) headers = dialog.headers assert "From" in headers @@ -346,8 +332,6 @@ def test_headers__returns_required_keys(self): def test_from_request__extracts_call_id_and_tags(self): """from_request creates a Dialog with the correct call_id and tags.""" - from voip.sip.messages import Dialog - data = ( b"INVITE sip:bob@biloxi.com SIP/2.0\r\n" b"Via: SIP/2.0/UDP pc33.atlanta.com;branch=z9hG4bKabc\r\n" diff --git a/tests/sip/test_protocol.py b/tests/sip/test_protocol.py index e7bc10d..60b8bbf 100644 --- a/tests/sip/test_protocol.py +++ b/tests/sip/test_protocol.py @@ -13,7 +13,7 @@ from .conftest import INVITE_BYTES, FakeTransport -class TestSessionInitiationProtocolConnectionMade: +class TestSessionInitiationProtocol: def test_connection_made__stores_transport(self, fake_transport, rtp): """Store transport reference after connection_made.""" session = SessionInitiationProtocol( @@ -80,8 +80,6 @@ async def test_connection_made__creates_keepalive_task(self, fake_transport, rtp assert session.keepalive_task is not None session.keepalive_task.cancel() - -class TestSessionInitiationProtocolSendKeepalive: async def test_send_keepalive__sends_ping(self, sip, fake_transport): """Send a CRLF CRLF ping after the keepalive interval elapses.""" sip.keepalive_interval = datetime.timedelta(milliseconds=10) @@ -96,8 +94,6 @@ async def test_send_keepalive__stops_when_transport_is_none(self, sip): sip.keepalive_interval = datetime.timedelta(milliseconds=1) await sip.send_keepalive() - -class TestSessionInitiationProtocolDataReceived: def test_data_received__pong(self, sip): r"""Receive a PONG (\r\n keepalive reply) without sending any reply.""" initial_sent = len(sip.transport.sent) @@ -130,8 +126,6 @@ def test_data_received__sip_response(self, sip): ).encode() sip.data_received(response_bytes) - -class TestSessionInitiationProtocolSend: def test_send__writes_message_bytes(self, sip, fake_transport): """Serialize and write a SIP message to the transport.""" response = Response(status_code=200, phrase="OK") @@ -144,8 +138,6 @@ def test_send__with_no_transport(self, sip): response = Response(status_code=200, phrase="OK") sip.send(response) - -class TestSessionInitiationProtocolClose: def test_close__closes_transport(self, sip, fake_transport): """Close the underlying transport.""" sip.close() @@ -156,8 +148,6 @@ def test_close__with_no_transport(self, sip): sip.transport = None sip.close() - -class TestSessionInitiationProtocolAllowedMethods: def test_allowed_methods__includes_options(self, sip): """Always include OPTIONS in allowed methods.""" assert "OPTIONS" in sip.allowed_methods @@ -174,8 +164,6 @@ def test_allow_header__is_comma_separated_string(self, sip): assert "OPTIONS" in header assert "," in header - -class TestSessionInitiationProtocolMethodNotAllowed: def test_method_not_allowed__sends_405(self, sip, fake_transport): """Send a 405 Method Not Allowed response.""" request = Message.parse( @@ -190,8 +178,6 @@ def test_method_not_allowed__sends_405(self, sip, fake_transport): sip.method_not_allowed(request) assert any(b"405" in data for data in fake_transport.sent) - -class TestSessionInitiationProtocolRequestReceived: def test_request_received__options__sends_200(self, sip, fake_transport): """Reply with 200 OK for an OPTIONS request.""" request = Message.parse( @@ -263,8 +249,6 @@ def test_request_received__cancel__gone_when_no_transaction( sip.request_received(cancel) assert any(b"410" in data for data in fake_transport.sent) - -class TestSessionInitiationProtocolResponseReceived: async def test_response_received__delegates_to_transaction(self, sip): """Delegate a response to the matching transaction by branch.""" branch = list(sip.transactions.keys())[0] @@ -279,8 +263,6 @@ async def test_response_received__delegates_to_transaction(self, sip): ) sip.response_received(response) - -class TestSessionInitiationProtocolContact: def test_contact__sips_aor_produces_sips_contact(self, sip): """Build a sips: Contact for a sips: AOR.""" assert sip.contact.startswith(" Response: - return Response(status_code=200, phrase="OK") - - -def _trying() -> Response: - return Response(status_code=100, phrase="Trying") - - -class TestSipUriMaddr: def test_maddr__with_parameter(self): """Parse NetworkAddress from maddr URI parameter.""" uri = SipUri.parse("sip:alice@example.com;maddr=192.0.2.1:5060") @@ -314,6 +304,14 @@ def test_transport__explicit_parameter(self): assert uri.transport == "udp" +def _ok() -> Response: + return Response(status_code=200, phrase="OK") + + +def _trying() -> Response: + return Response(status_code=100, phrase="Trying") + + class TestCallerID: def test_display_name__quoted(self): """Parse a quoted display name before the angle bracket.""" From 403c4d61c273fab4a66066f127ca66c2bb2fd91d Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Thu, 26 Mar 2026 12:00:52 +0100 Subject: [PATCH 5/9] Address review comments --- tests/test_types.py | 53 ++++++++++++++++++++++++++++++++++++++++ voip/__main__.py | 9 +++---- voip/sip/messages.py | 8 ++---- voip/sip/protocol.py | 15 +++++++++--- voip/sip/transactions.py | 9 +++---- voip/sip/types.py | 13 +++++++--- voip/types.py | 43 +++++++++++++++++++++++++------- 7 files changed, 118 insertions(+), 32 deletions(-) create mode 100644 tests/test_types.py diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..ba978f9 --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,53 @@ +import ipaddress + +import pytest +from voip.types import NetworkAddress + + +class TestNetworkAddress: + @pytest.mark.parametrize( + "address,expected", + [ + # domain + (NetworkAddress("example.com"), "example.com"), + (NetworkAddress("example.com", 80), "example.com:80"), + # IPv4 + (NetworkAddress(ipaddress.IPv4Address("127.0.0.1")), "127.0.0.1"), + (NetworkAddress(ipaddress.IPv4Address("127.0.0.1"), 80), "127.0.0.1:80"), + # IPv6 + (NetworkAddress(ipaddress.IPv6Address("2001:db8::1")), "2001:db8::1"), + ( + NetworkAddress(ipaddress.IPv6Address("2001:db8::1"), 80), + "[2001:db8::1]:80", + ), + ], + ) + def test_str(self, address, expected): + assert str(address) == expected + + @pytest.mark.parametrize( + "data,expected", + [ + # domain + ("example.com", NetworkAddress("example.com")), + ("example.com:80", NetworkAddress("example.com", 80)), + # IPv4 + ("127.0.0.1", NetworkAddress(ipaddress.IPv4Address("127.0.0.1"))), + ("127.0.0.1:80", NetworkAddress(ipaddress.IPv4Address("127.0.0.1"), 80)), + # IPv6 + ("2001:db8::1", NetworkAddress(ipaddress.IPv6Address("2001:db8::1"))), + ( + "[2001:db8::1]:80", + NetworkAddress(ipaddress.IPv6Address("2001:db8::1"), 80), + ), + ], + ) + def test_parse(self, data, expected): + assert NetworkAddress.parse(data) == expected + + def test_parse__value_error__host(self): + with pytest.raises(ValueError) as exc_info: + NetworkAddress.parse("example.com:invalid_port") + assert ( + str(exc_info.value) == "Invalid network address: 'example.com:invalid_port'" + ) diff --git a/voip/__main__.py b/voip/__main__.py index bc875a7..6a8f703 100644 --- a/voip/__main__.py +++ b/voip/__main__.py @@ -102,7 +102,7 @@ def voip(ctx, verbose: int = 0): metavar="HOST[:PORT]", callback=lambda ctx, param, value: NetworkAddress.parse(value), is_eager=False, - help="STUN server for RTP NAT traversal (use 'none' to disable).", + help="STUN server for RTP NAT traversal.", ) @click.option( "--no-verify-tls", @@ -180,7 +180,6 @@ def echo(ctx): """Echo the caller's speech back after they finish speaking.""" from .audio import EchoCall # noqa: PLC0415 - obj = ctx.obj obj = ctx.obj aor = obj["aor"] @@ -202,7 +201,7 @@ async def run(): rtp=rtp_protocol, ), aor.maddr, - aor.transport == "tls", + aor.transport == "TLS", obj["no_verify_tls"], ) @@ -258,7 +257,7 @@ async def run(): rtp=rtp_protocol, ), aor.maddr, - aor.transport == "tls", + aor.transport == "TLS", obj["no_verify_tls"], ) @@ -359,7 +358,7 @@ async def run(): rtp=rtp_protocol, ), aor.maddr, - aor.transport == "tls", + aor.transport == "TLS", obj["no_verify_tls"], ) diff --git a/voip/sip/messages.py b/voip/sip/messages.py index fe5cd7f..972ea0c 100644 --- a/voip/sip/messages.py +++ b/voip/sip/messages.py @@ -87,11 +87,7 @@ def __bytes__(self) -> bytes: @property def branch(self) -> str | None: - """Branch parameter from the top Via header (RFC 3261 §20.42). - - Falls back to the Call-ID when the Via header contains no branch - (RFC 2543 compatibility). - """ + """Branch parameter from the top Via header (RFC 3261 §20.42).""" _, uri = self.headers["Via"].split() return SipUri.parse(f"sip:{uri}").parameters["branch"] @@ -176,7 +172,7 @@ class Dialog: A dialog is identified by the tuple of (Call-ID, From tag, To tag) and established by a non-final response to the INVITE, see also: [RFC 3261 §12] - [RFC 3261 $12]: https://datatracker.ietf.org/doc/html/rfc3261#section-12 + [RFC 3261 §12]: https://datatracker.ietf.org/doc/html/rfc3261#section-12 Args: uac: The user agent that initiated the dialog. diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 7d7de5c..783cb8b 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -106,7 +106,7 @@ def connection_made(self, transport: asyncio.Transport) -> None: # type: ignore self.transport = transport # IPv6 sockets return a 4-tuple (host, port, flowinfo, scope_id); # we only need the first two elements. - host, port = transport.get_extra_info("sockname") + host, port = transport.get_extra_info("sockname")[:2] self.local_address = NetworkAddress(ipaddress.ip_address(host), port) self.is_secure = transport.get_extra_info("ssl_object") is not None try: @@ -202,7 +202,7 @@ def allowed_methods(self) -> frozenset[SIPMethod]: for m in SIPMethod if hasattr(self.transaction_class, f"{m.lower()}_received") ), - "OPTIONS", + SIPMethod.OPTIONS, ) ) @@ -275,7 +275,16 @@ def response_received(self, response: Response) -> None: Args: response: The parsed SIP response. """ - self.transactions[response.branch].response_received(response) + try: + tx = self.transactions[response.branch] + except KeyError: + logger.warning( + "Received response with unknown branch %r: %r", + response.branch, + response, + ) + else: + tx.response_received(response) @property def contact(self) -> str: diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index c575210..0907d7b 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -78,15 +78,13 @@ class Transaction: def __post_init__(self): if not self.branch.startswith(self.branch_prefix): - raise ValueError( - f"Branch parameter must not start with {self.branch_prefix!r}" - ) + raise ValueError(f"Branch parameter must start with {self.branch_prefix!r}") @property def headers(self) -> dict[str, str]: """Return a dict of headers for this transaction.""" return { - "Via": f"SIP/2.0/{self.sip.aor.parameters.get('transport', 'TLS').upper()} {self.sip.local_address};rport;branch={self.branch}", + "Via": f"SIP/2.0/{self.sip.aor.transport} {self.sip.local_address};rport;branch={self.branch}", "CSeq": f"{self.cseq} {self.method}", } @@ -137,6 +135,7 @@ class RegistrationTransaction(Transaction): cseq: int = 1 def __post_init__(self): + super().__post_init__() self.dialog = self.dialog or Dialog(uac=self.sip.aor) headers = ( self.headers @@ -170,7 +169,7 @@ def response_received(self, response: Response) -> None: self.sip.transactions.pop(self.branch) match response.status_code: case SIPStatus.OK: - logger.info("Registration successfull") + logger.info("Registration successful") return case SIPStatus.UNAUTHORIZED | SIPStatus.PROXY_AUTHENTICATION_REQUIRED: logger.debug( diff --git a/voip/sip/types.py b/voip/sip/types.py index 4933bb6..482598d 100644 --- a/voip/sip/types.py +++ b/voip/sip/types.py @@ -178,13 +178,18 @@ def maddr(self) -> NetworkAddress: return NetworkAddress(self.host, self.port) @property - def ttl(self): - return self.parameters["ttl"] + def ttl(self) -> int | None: + try: + return int(self.parameters["ttl"]) + except KeyError: + return None @property def transport(self): - return self.parameters.get( - "transport", "TLS" if self.scheme == "sips" else None + return ( + self.parameters.get("transport", "TLS").upper() + if self.scheme == "sip" + else "TLS" ) diff --git a/voip/types.py b/voip/types.py index fbd54c0..242ed51 100644 --- a/voip/types.py +++ b/voip/types.py @@ -1,6 +1,8 @@ from __future__ import annotations import abc +import ipaddress +import re import typing from ipaddress import IPv4Address, IPv6Address @@ -23,6 +25,27 @@ def __str__(self) -> str: return self.__bytes__().decode() +# Match host and optional port. Host can be a domain name, ipv4 or ipv6 address. +NETLOC_PATTERN = re.compile( + r""" + ^\s* + (?P + # IPv6 addresses enclosed in square brackets (with optional port) + \[[^]]+] + | + # Bare IPv6 addresses (at least two colons, no port) + (?:[0-9a-fA-F]*:){2,}[0-9a-fA-F]* + | + # Hostname or IPv4 (no colon except the port separator) + [^:]+ + ) + (?::(?P\d+))? + \s*$ + """, + re.VERBOSE, +) + + class NetworkAddress(typing.NamedTuple): """Parse and serialize an address.""" @@ -32,15 +55,17 @@ class NetworkAddress(typing.NamedTuple): def __str__(self): if self.port and isinstance(self.host, IPv6Address): return f"[{self.host}]:{self.port}" - elif self.port: - return f"{self.host}:{self.port}" - return str(self.host) + elif self.port is None: + return str(self.host) + return f"{self.host}:{self.port}" @classmethod def parse(cls, data: str) -> NetworkAddress: - if data.startswith("["): - host, port = data[1:].split("]:") - return cls(host=host, port=int(port)) - else: - host, port = data.split(":") - return cls(host=host, port=int(port)) + if match := NETLOC_PATTERN.match(data): + host, port = match.group("host").strip("[]"), match.group("port") + try: + host = ipaddress.ip_address(host) + except ValueError: + pass + return cls(host=host, port=int(port) if port else None) + raise ValueError(f"Invalid network address: {data!r}") From 62fbb932bdfe365da5103cf3407cfa957666812d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:02:28 +0000 Subject: [PATCH 6/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/sip/test_messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/sip/test_messages.py b/tests/sip/test_messages.py index a23006e..f705cb5 100644 --- a/tests/sip/test_messages.py +++ b/tests/sip/test_messages.py @@ -203,7 +203,7 @@ def test___bytes____with_sdp_body(self): assert b"v=0" in serialized def test_branch__with_branch(self): - """branch returns the branch parameter from the Via header.""" + """Branch returns the branch parameter from the Via header.""" request = Request( method="INVITE", uri="sip:bob@biloxi.com", From b440eccfecfdde2dc1f06069e66173191814aaa6 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Thu, 26 Mar 2026 13:16:29 +0100 Subject: [PATCH 7/9] Update tests --- tests/sip/test_types.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/sip/test_types.py b/tests/sip/test_types.py index dcc0d0a..cd101f9 100644 --- a/tests/sip/test_types.py +++ b/tests/sip/test_types.py @@ -275,7 +275,7 @@ def test_parse__flag_parameter_and_valueless_header( def test_maddr__with_parameter(self): """Parse NetworkAddress from maddr URI parameter.""" uri = SipUri.parse("sip:alice@example.com;maddr=192.0.2.1:5060") - assert uri.maddr == ("192.0.2.1", 5060) + assert uri.maddr == (ipaddress.IPv4Address("192.0.2.1"), 5060) def test_maddr__without_parameter(self): """Fall back to host:port when maddr parameter is absent.""" @@ -286,7 +286,7 @@ def test_maddr__without_parameter(self): def test_ttl__returns_value(self): """Return the ttl URI parameter value as a string.""" uri = SipUri.parse("sip:alice@example.com;ttl=30") - assert uri.ttl == "30" + assert uri.ttl == 30 def test_transport__sips_returns_tls(self): """Return 'TLS' for sips: URIs that have no transport parameter.""" @@ -296,12 +296,12 @@ def test_transport__sips_returns_tls(self): def test_transport__sip_without_parameter_returns_none(self): """Return None for a plain sip: URI without transport parameter.""" uri = SipUri.parse("sip:alice@example.com") - assert uri.transport is None + assert uri.transport == "TLS" def test_transport__explicit_parameter(self): """Return explicit transport parameter value.""" uri = SipUri.parse("sip:alice@example.com;transport=udp") - assert uri.transport == "udp" + assert uri.transport == "UDP" def _ok() -> Response: From c52507decde2917b2691bdaaf5fc56e28f08d973 Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Thu, 26 Mar 2026 15:46:03 +0100 Subject: [PATCH 8/9] Bump to Python 3.14 + --- .github/workflows/ci.yml | 7 ++++--- .pre-commit-config.yaml | 2 +- pyproject.toml | 3 +-- tests/codecs/test_av.py | 2 -- tests/codecs/test_base.py | 2 -- tests/codecs/test_codecs.py | 2 -- tests/codecs/test_g722.py | 2 -- tests/codecs/test_opus.py | 2 -- tests/codecs/test_pcm.py | 2 -- tests/sip/conftest.py | 2 -- tests/sip/test_protocol.py | 2 -- tests/sip/test_transactions.py | 4 +--- tests/sip/test_types.py | 2 -- tests/test_ai.py | 2 -- tests/test_audio.py | 2 -- tests/test_rtp.py | 2 -- tests/test_stun.py | 2 -- voip/ai.py | 2 -- voip/audio.py | 2 -- voip/codecs/__init__.py | 2 -- voip/codecs/av.py | 2 -- voip/codecs/base.py | 2 -- voip/codecs/g722.py | 2 -- voip/codecs/opus.py | 2 -- voip/codecs/pcma.py | 2 -- voip/codecs/pcmu.py | 2 -- voip/rtp.py | 2 -- voip/sdp/messages.py | 2 -- voip/sdp/types.py | 2 -- voip/sip/exceptions.py | 3 --- voip/sip/messages.py | 2 -- voip/sip/protocol.py | 2 -- voip/sip/transactions.py | 2 -- voip/sip/types.py | 4 +--- voip/srtp.py | 2 -- voip/stun.py | 2 -- voip/types.py | 2 -- 37 files changed, 8 insertions(+), 77 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0aa9151..9cc9300 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,6 @@ jobs: os: - ubuntu-latest python-version: - - "3.13" - "3.14" runs-on: ${{ matrix.os }} steps: @@ -47,7 +46,6 @@ jobs: os: - ubuntu-latest python-version: - - "3.13" - "3.14" extra: - audio @@ -58,6 +56,8 @@ jobs: steps: - uses: actions/checkout@v6 - uses: astral-sh/setup-uv@v7 + with: + python-version: ${{ matrix.python-version }} - run: uv run --group=test --extra=${{ matrix.extra }} pytest - uses: codecov/codecov-action@v5 with: @@ -71,12 +71,13 @@ jobs: - windows-latest - macos-latest python-version: - - "3.13" - "3.14" runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 - uses: astral-sh/setup-uv@v7 + with: + python-version: ${{ matrix.python-version }} - run: uv run --group=test pytest - uses: codecov/codecov-action@v5 with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b5afb2c..02599e5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,7 +25,7 @@ repos: rev: v3.21.2 hooks: - id: pyupgrade - args: [--py313-plus] + args: [--py314-plus] - repo: https://github.com/hukkin/mdformat rev: 1.0.0 hooks: diff --git a/pyproject.toml b/pyproject.toml index ec86dd3..1e09017 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,6 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Framework :: AsyncIO", "Topic :: Communications :: Internet Phone", @@ -35,7 +34,7 @@ classifiers = [ "Topic :: System :: Networking", "Topic :: Home Automation", ] -requires-python = ">=3.13" +requires-python = ">=3.14" dependencies = ["cryptography"] [project.optional-dependencies] diff --git a/tests/codecs/test_av.py b/tests/codecs/test_av.py index 3248ab1..7177e86 100644 --- a/tests/codecs/test_av.py +++ b/tests/codecs/test_av.py @@ -1,7 +1,5 @@ """Tests for the PyAVCodec base class (voip.codecs.av).""" -from __future__ import annotations - from unittest.mock import MagicMock, patch import pytest diff --git a/tests/codecs/test_base.py b/tests/codecs/test_base.py index 57791f4..e7ff451 100644 --- a/tests/codecs/test_base.py +++ b/tests/codecs/test_base.py @@ -1,7 +1,5 @@ """Tests for the RTPCodec base class (voip.codecs.base).""" -from __future__ import annotations - from unittest.mock import patch import pytest diff --git a/tests/codecs/test_codecs.py b/tests/codecs/test_codecs.py index c87e82d..6fe8140 100644 --- a/tests/codecs/test_codecs.py +++ b/tests/codecs/test_codecs.py @@ -1,7 +1,5 @@ """Tests for the voip.codecs package (voip/codecs/__init__.py).""" -from __future__ import annotations - import importlib import sys diff --git a/tests/codecs/test_g722.py b/tests/codecs/test_g722.py index 8a3e4dc..a4824ea 100644 --- a/tests/codecs/test_g722.py +++ b/tests/codecs/test_g722.py @@ -1,7 +1,5 @@ """Tests for the G.722 codec (voip.codecs.g722).""" -from __future__ import annotations - from unittest.mock import patch import pytest diff --git a/tests/codecs/test_opus.py b/tests/codecs/test_opus.py index 2b0a358..ca67dc7 100644 --- a/tests/codecs/test_opus.py +++ b/tests/codecs/test_opus.py @@ -1,7 +1,5 @@ """Tests for the Opus codec (voip.codecs.opus).""" -from __future__ import annotations - from unittest.mock import patch import pytest diff --git a/tests/codecs/test_pcm.py b/tests/codecs/test_pcm.py index d1657ba..dd75977 100644 --- a/tests/codecs/test_pcm.py +++ b/tests/codecs/test_pcm.py @@ -1,7 +1,5 @@ """Tests for the PCMA and PCMU codecs (voip.codecs.pcma, voip.codecs.pcmu).""" -from __future__ import annotations - import pytest np = pytest.importorskip("numpy") diff --git a/tests/sip/conftest.py b/tests/sip/conftest.py index e1389fc..0be1d30 100644 --- a/tests/sip/conftest.py +++ b/tests/sip/conftest.py @@ -1,7 +1,5 @@ """Shared fixtures for SIP tests.""" -from __future__ import annotations - import dataclasses import ipaddress diff --git a/tests/sip/test_protocol.py b/tests/sip/test_protocol.py index 60b8bbf..f35d771 100644 --- a/tests/sip/test_protocol.py +++ b/tests/sip/test_protocol.py @@ -1,7 +1,5 @@ """Tests for the SIP asyncio protocol handler.""" -from __future__ import annotations - import asyncio import datetime diff --git a/tests/sip/test_transactions.py b/tests/sip/test_transactions.py index 21fca1e..e14e377 100644 --- a/tests/sip/test_transactions.py +++ b/tests/sip/test_transactions.py @@ -1,7 +1,5 @@ """Tests for the SIP transaction layer.""" -from __future__ import annotations - import pytest from voip.rtp import RealtimeTransportProtocol from voip.sip.exceptions import RegistrationError @@ -68,7 +66,7 @@ def test_post_init__invalid_branch__raises(self): """Raise ValueError when branch does not start with 'z9hG4bK'.""" sip = create_sip_session() with pytest.raises(ValueError): - InviteTransaction( + RegistrationTransaction( sip=sip, method=SIPMethod.INVITE, branch="invalid-branch", diff --git a/tests/sip/test_types.py b/tests/sip/test_types.py index cd101f9..6d9138c 100644 --- a/tests/sip/test_types.py +++ b/tests/sip/test_types.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import ipaddress import pytest diff --git a/tests/test_ai.py b/tests/test_ai.py index 182072a..f6b1022 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -1,7 +1,5 @@ """Tests for AI-powered call handlers (TranscribeCall and AgentCall).""" -from __future__ import annotations - import asyncio from unittest.mock import AsyncMock, MagicMock, patch diff --git a/tests/test_audio.py b/tests/test_audio.py index 0561b41..4a5618a 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -1,7 +1,5 @@ """Tests for audio call handler and codec utilities.""" -from __future__ import annotations - import asyncio import datetime from unittest.mock import AsyncMock, MagicMock, patch diff --git a/tests/test_rtp.py b/tests/test_rtp.py index 32d090a..acc2acb 100644 --- a/tests/test_rtp.py +++ b/tests/test_rtp.py @@ -1,7 +1,5 @@ """Tests for the RTP protocol implementation (RFC 3550).""" -from __future__ import annotations - import asyncio import dataclasses import ipaddress diff --git a/tests/test_stun.py b/tests/test_stun.py index f520447..0737200 100644 --- a/tests/test_stun.py +++ b/tests/test_stun.py @@ -1,7 +1,5 @@ """Tests for the STUN utility functions (RFC 5389).""" -from __future__ import annotations - import asyncio import ipaddress import socket diff --git a/voip/ai.py b/voip/ai.py index 530a41f..66c2571 100644 --- a/voip/ai.py +++ b/voip/ai.py @@ -7,8 +7,6 @@ Requires the ``ai`` extra: ``pip install voip[ai]``. """ -from __future__ import annotations - import asyncio import dataclasses import datetime diff --git a/voip/audio.py b/voip/audio.py index b2fbf51..25f41c0 100644 --- a/voip/audio.py +++ b/voip/audio.py @@ -9,8 +9,6 @@ [`voip.ai`][voip.ai] and require the ``ai`` extra. """ -from __future__ import annotations - import asyncio import dataclasses import datetime diff --git a/voip/codecs/__init__.py b/voip/codecs/__init__.py index b37a6a1..9ae75e1 100644 --- a/voip/codecs/__init__.py +++ b/voip/codecs/__init__.py @@ -14,8 +14,6 @@ When the ``pyav`` extra is not installed only PCMA and PCMU are registered. """ -from __future__ import annotations - from voip.codecs.base import RTPCodec from voip.codecs.pcma import PCMA from voip.codecs.pcmu import PCMU diff --git a/voip/codecs/av.py b/voip/codecs/av.py index 005c262..ba08fce 100644 --- a/voip/codecs/av.py +++ b/voip/codecs/av.py @@ -13,8 +13,6 @@ [PyAV]: https://pyav.basswood-io.com/ """ -from __future__ import annotations - import io from typing import cast diff --git a/voip/codecs/base.py b/voip/codecs/base.py index 10ee694..0b358be 100644 --- a/voip/codecs/base.py +++ b/voip/codecs/base.py @@ -14,8 +14,6 @@ [PyAV]: https://pyav.basswood-io.com/ """ -from __future__ import annotations - import dataclasses from collections.abc import Iterator from typing import ClassVar, Protocol diff --git a/voip/codecs/g722.py b/voip/codecs/g722.py index deab23e..af167ca 100644 --- a/voip/codecs/g722.py +++ b/voip/codecs/g722.py @@ -11,8 +11,6 @@ Requires the ``hd-audio`` extra: ``pip install voip[hd-audio]``. """ -from __future__ import annotations - import dataclasses import typing from collections.abc import Iterator diff --git a/voip/codecs/opus.py b/voip/codecs/opus.py index a8ef9ff..a7b36c2 100644 --- a/voip/codecs/opus.py +++ b/voip/codecs/opus.py @@ -9,8 +9,6 @@ [Ogg]: https://wiki.xiph.org/Ogg """ -from __future__ import annotations - import os import struct from typing import ClassVar diff --git a/voip/codecs/pcma.py b/voip/codecs/pcma.py index c27b940..7c5750d 100644 --- a/voip/codecs/pcma.py +++ b/voip/codecs/pcma.py @@ -5,8 +5,6 @@ companding algorithm. No PyAV dependency is required. """ -from __future__ import annotations - from typing import ClassVar import numpy as np diff --git a/voip/codecs/pcmu.py b/voip/codecs/pcmu.py index 361f1e8..31adfc1 100644 --- a/voip/codecs/pcmu.py +++ b/voip/codecs/pcmu.py @@ -5,8 +5,6 @@ No PyAV dependency is required. """ -from __future__ import annotations - from typing import ClassVar import numpy as np diff --git a/voip/rtp.py b/voip/rtp.py index 4d1ae22..5c8e770 100644 --- a/voip/rtp.py +++ b/voip/rtp.py @@ -4,8 +4,6 @@ [RFC 3550]: https://datatracker.ietf.org/doc/html/rfc3550#section-5 """ -from __future__ import annotations - import asyncio import dataclasses import enum diff --git a/voip/sdp/messages.py b/voip/sdp/messages.py index d86c5aa..57265c9 100644 --- a/voip/sdp/messages.py +++ b/voip/sdp/messages.py @@ -1,7 +1,5 @@ """SDP message parsing and serialization as defined by RFC 4566.""" -from __future__ import annotations - import dataclasses from collections.abc import Generator diff --git a/voip/sdp/types.py b/voip/sdp/types.py index 78d77e7..bfba645 100644 --- a/voip/sdp/types.py +++ b/voip/sdp/types.py @@ -1,7 +1,5 @@ """SDP field types as defined by RFC 4566.""" -from __future__ import annotations - import dataclasses import enum from collections.abc import Generator diff --git a/voip/sip/exceptions.py b/voip/sip/exceptions.py index fb477fc..a78f9b5 100644 --- a/voip/sip/exceptions.py +++ b/voip/sip/exceptions.py @@ -1,6 +1,3 @@ -from __future__ import annotations - - class RegistrationError(Exception): """Raised when a SIP REGISTER request fails with an unexpected response. diff --git a/voip/sip/messages.py b/voip/sip/messages.py index 972ea0c..cfe549d 100644 --- a/voip/sip/messages.py +++ b/voip/sip/messages.py @@ -1,7 +1,5 @@ """SIP message types as defined by RFC 3261.""" -from __future__ import annotations - import abc import dataclasses import datetime diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 783cb8b..07bd553 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -4,8 +4,6 @@ See also: https://datatracker.ietf.org/doc/html/rfc3261 """ -from __future__ import annotations - import asyncio import dataclasses import datetime diff --git a/voip/sip/transactions.py b/voip/sip/transactions.py index 0907d7b..eb88255 100644 --- a/voip/sip/transactions.py +++ b/voip/sip/transactions.py @@ -1,7 +1,5 @@ """SIP transaction layer (RFC 3261 §17).""" -from __future__ import annotations - import dataclasses import datetime import hashlib diff --git a/voip/sip/types.py b/voip/sip/types.py index 482598d..20fed8a 100644 --- a/voip/sip/types.py +++ b/voip/sip/types.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import dataclasses import enum import ipaddress @@ -80,7 +78,7 @@ def __post_init__(self): SIP_URL_PATTERN: typing.ClassVar[re.Pattern[str]] = re.compile( r"^(?Psips?):" r"((?P[^@;:]+)(?P:[^@;]*)?@)?" - r"(?P(\[[0-9a-fA-F:]+\]|[^;?:@\[\]]+))" + r"(?P(\[[0-9a-fA-F:]+]|[^;?:@\[\]]+))" r"(?P:[0-9]+)?" r"(?P;[^?]+)?" r"(?P\?[^?]+)?$", diff --git a/voip/srtp.py b/voip/srtp.py index fd9b852..6e92c0d 100644 --- a/voip/srtp.py +++ b/voip/srtp.py @@ -11,8 +11,6 @@ [RFC 4568]: https://datatracker.ietf.org/doc/html/rfc4568 """ -from __future__ import annotations - import base64 import dataclasses import hmac as _hmac_stdlib diff --git a/voip/stun.py b/voip/stun.py index 9d54680..134b351 100644 --- a/voip/stun.py +++ b/voip/stun.py @@ -1,7 +1,5 @@ """Session Traversal Utilities for NAT (STUN) implementation of RFC 5389.""" -from __future__ import annotations - import asyncio import dataclasses import enum diff --git a/voip/types.py b/voip/types.py index 242ed51..e0b8f73 100644 --- a/voip/types.py +++ b/voip/types.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import abc import ipaddress import re From 69115aeb455f6f379b73fc7fb0a4011d2f52a990 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:20:44 +0100 Subject: [PATCH 9/9] Add TCP stream buffering to SessionInitiationProtocol (#61) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `data_received()` assumed each TCP chunk was exactly one complete SIP message. TCP is a stream protocol — frames can be split across multiple deliveries or coalesced into one. ## Changes - **`recv_buffer: bytearray`** — accumulates raw bytes from the TCP stream across `data_received()` calls - **`PING` / `PONG` constants** — module-level `typing.Final[bytes]` constants (`b"\r\n\r\n"` and `b"\r\n"`) replace all scattered byte literals in the keepalive path - **`extract_frames()`** — generator that extracts complete frames from the buffer using `Content-Length` header framing ([RFC 3261 §18.3](https://datatracker.ietf.org/doc/html/rfc3261#section-18.3)); yields a `memoryview` into the buffer for each SIP message (zero-copy until `Message.parse` needs `bytes`) and the `PING`/`PONG` constants for keepalive frames; the view is explicitly released before the buffer is compacted; partial messages remain buffered until all bytes arrive - **`dispatch_frame()`** — routes a single complete frame (`memoryview | bytes`) to the appropriate handler, converting to `bytes` only at parse time - **`data_received()`** — appends to `recv_buffer` and iterates the `extract_frames()` generator ```python def data_received(self, data: bytes) -> None: self.recv_buffer.extend(data) for frame in self.extract_frames(): self.dispatch_frame(frame) ``` ## Tests 39 new tests covering: split headers, split body, coalesced messages, all keepalive edge cases (partial PING, PONG-then-message, etc.), invalid `Content-Length`, and end-to-end `data_received` reassembly scenarios. --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com> Co-authored-by: Johannes Maron --- tests/sip/test_protocol.py | 528 ++++++++++++++++++++++++++++++++++++- voip/sip/protocol.py | 121 +++++---- 2 files changed, 600 insertions(+), 49 deletions(-) diff --git a/tests/sip/test_protocol.py b/tests/sip/test_protocol.py index f35d771..a87757f 100644 --- a/tests/sip/test_protocol.py +++ b/tests/sip/test_protocol.py @@ -2,11 +2,13 @@ import asyncio import datetime +import ipaddress from voip.sip.messages import Message, Response -from voip.sip.protocol import SessionInitiationProtocol +from voip.sip.protocol import PING, PONG, SessionInitiationProtocol from voip.sip.transactions import InviteTransaction from voip.sip.types import SIPMethod, SipUri +from voip.types import NetworkAddress from .conftest import INVITE_BYTES, FakeTransport @@ -331,3 +333,527 @@ def test_connection_lost__without_keepalive_task(self, sip): sip.keepalive_task = None sip.connection_lost(None) assert sip.transport is None + + # ------------------------------------------------------------------ + # Helpers shared by extract_frames / dispatch_frame tests + # ------------------------------------------------------------------ + + def _make_session(self, rtp, fake_transport=None): + """Return a SessionInitiationProtocol wired with a fake transport. + + Does **not** call `connection_made` to avoid triggering + `RegistrationTransaction.__post_init__` inside a running event loop, + which raises `TypeError` due to a `super()` / `slots=True` interaction + in Python 3.13. + """ + session = SessionInitiationProtocol( + aor=SipUri.parse("sips:alice@example.com"), + rtp=rtp, + transaction_class=InviteTransaction, + ) + transport = fake_transport or FakeTransport() + session.transport = transport + session.local_address = NetworkAddress(ipaddress.ip_address("127.0.0.1"), 5061) + return session + + # ------------------------------------------------------------------ + # extract_frames + # ------------------------------------------------------------------ + + def test_extract_frames__empty_buffer(self, rtp): + """Return an empty iterator when the receive buffer is empty.""" + session = self._make_session(rtp) + assert [bytes(f) for f in session._extract_frames()] == [] + + def test_extract_frames__complete_message(self, rtp): + """Extract a single complete SIP message and clear the buffer.""" + session = self._make_session(rtp) + session.recv_buffer.extend(INVITE_BYTES) + frames = [bytes(f) for f in session._extract_frames()] + assert frames == [INVITE_BYTES] + assert len(session.recv_buffer) == 0 + + def test_extract_frames__partial_headers(self, rtp): + """Keep partial message bytes in the buffer until complete.""" + session = self._make_session(rtp) + session.recv_buffer.extend(INVITE_BYTES[:20]) + assert [bytes(f) for f in session._extract_frames()] == [] + assert len(session.recv_buffer) == 20 + + def test_extract_frames__two_coalesced_messages(self, rtp): + """Extract two SIP messages delivered in a single TCP segment.""" + second = ( + b"OPTIONS sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKopt1\r\n" + b"From: sip:bob@biloxi.com;tag=t99\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: opt-coalesced@biloxi.com\r\n" + b"CSeq: 1 OPTIONS\r\n" + b"\r\n" + ) + session = self._make_session(rtp) + session.recv_buffer.extend(INVITE_BYTES + second) + frames = [bytes(f) for f in session._extract_frames()] + assert len(frames) == 2 + assert frames[0] == INVITE_BYTES + assert frames[1] == second + assert len(session.recv_buffer) == 0 + + def test_extract_frames__message_with_body(self, rtp): + """Extract a SIP message that includes a Content-Length body.""" + body = b"v=0\r\no=- 1 1 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\n" + headers = ( + b"INVITE sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKbody1\r\n" + b"From: sip:bob@biloxi.com;tag=tb1\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: body-call@biloxi.com\r\n" + b"CSeq: 1 INVITE\r\n" + b"Content-Type: application/sdp\r\n" + b"Content-Length: " + str(len(body)).encode() + b"\r\n" + b"\r\n" + ) + message = headers + body + session = self._make_session(rtp) + session.recv_buffer.extend(message) + frames = [bytes(f) for f in session._extract_frames()] + assert frames == [message] + assert len(session.recv_buffer) == 0 + + def test_extract_frames__incomplete_body(self, rtp): + """Keep bytes in the buffer when only part of the body has arrived.""" + body = b"v=0\r\no=- 1 1 IN IP4 127.0.0.1\r\n" + headers = ( + b"INVITE sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKbody2\r\n" + b"From: sip:bob@biloxi.com;tag=tb2\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: body2-call@biloxi.com\r\n" + b"CSeq: 1 INVITE\r\n" + b"Content-Length: " + str(len(body)).encode() + b"\r\n" + b"\r\n" + ) + session = self._make_session(rtp) + session.recv_buffer.extend(headers + body[:5]) + assert [bytes(f) for f in session._extract_frames()] == [] + assert len(session.recv_buffer) == len(headers) + 5 + + def test_extract_frames__ping(self, rtp): + """Extract an RFC 5626 PING (CRLF CRLF) keepalive frame.""" + session = self._make_session(rtp) + session.recv_buffer.extend(PING) + frames = [bytes(f) for f in session._extract_frames()] + assert frames == [PING] + assert len(session.recv_buffer) == 0 + + def test_extract_frames__pong(self, rtp): + """Extract an RFC 5626 PONG (CRLF) keepalive frame.""" + session = self._make_session(rtp) + session.recv_buffer.extend(PONG) + frames = [bytes(f) for f in session._extract_frames()] + assert frames == [PONG] + assert len(session.recv_buffer) == 0 + + def test_extract_frames__partial_keepalive_wait(self, rtp): + """Buffer a partial PING (CRLF CR) without dispatching until the 4th byte arrives.""" + session = self._make_session(rtp) + session.recv_buffer.extend(b"\r\n\r") + assert [bytes(f) for f in session._extract_frames()] == [] + assert len(session.recv_buffer) == 3 + + def test_extract_frames__ping_followed_by_message(self, rtp): + """Extract a PING and a SIP message from the same buffer.""" + session = self._make_session(rtp) + session.recv_buffer.extend(PING + INVITE_BYTES) + frames = [bytes(f) for f in session._extract_frames()] + assert len(frames) == 2 + assert frames[0] == PING + assert frames[1] == INVITE_BYTES + + def test_extract_frames__pong_followed_by_message(self, rtp): + """Extract a PONG and a SIP message coalesced in the same buffer.""" + session = self._make_session(rtp) + session.recv_buffer.extend(PONG + INVITE_BYTES) + frames = [bytes(f) for f in session._extract_frames()] + assert len(frames) == 2 + assert frames[0] == PONG + assert frames[1] == INVITE_BYTES + + def test_extract_frames__invalid_content_length(self, rtp): + """Treat an unparseable Content-Length as zero (no body).""" + message = ( + b"OPTIONS sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKcl0\r\n" + b"From: sip:bob@biloxi.com;tag=tclx\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: clx-call@biloxi.com\r\n" + b"CSeq: 1 OPTIONS\r\n" + b"Content-Length: notanumber\r\n" + b"\r\n" + ) + session = self._make_session(rtp) + session.recv_buffer.extend(message) + frames = [bytes(f) for f in session._extract_frames()] + assert len(frames) == 1 + + def test_extract_frames__single_cr_waits(self, rtp): + """Keep a lone CR in the buffer without dispatching (incomplete keepalive).""" + session = self._make_session(rtp) + session.recv_buffer.extend(b"\r") + assert [bytes(f) for f in session._extract_frames()] == [] + assert session.recv_buffer == bytearray(b"\r") + + # ------------------------------------------------------------------ + # dispatch_frame + # ------------------------------------------------------------------ + + def test_dispatch_frame__pong(self, rtp, fake_transport, caplog): + """Log PONG on receiving a CRLF frame without sending a reply.""" + import logging + + session = self._make_session(rtp, fake_transport) + with caplog.at_level(logging.INFO): + session._dispatch_frame(b"\r\n") + assert "PONG" in caplog.text + assert fake_transport.sent == [] + + def test_dispatch_frame__ping__sends_pong(self, rtp, fake_transport): + """Reply with a CRLF PONG when a PING (CRLF CRLF) frame is dispatched.""" + session = self._make_session(rtp, fake_transport) + session._dispatch_frame(b"\r\n\r\n") + assert b"\r\n" in fake_transport.sent + + def test_dispatch_frame__sip_request(self, rtp, fake_transport): + """Dispatch a SIP request frame to request_received.""" + session = self._make_session(rtp, fake_transport) + before = len(session.transactions) + session._dispatch_frame(INVITE_BYTES) + assert len(session.transactions) > before + + def test_dispatch_frame__sip_response(self, rtp, fake_transport): + """Dispatch a SIP response frame to response_received without error.""" + session = self._make_session(rtp, fake_transport) + branch = "z9hG4bKresp-test" + session.transactions[branch] = InviteTransaction( + sip=session, + method=SIPMethod.INVITE, + branch=branch, + cseq=1, + ) + response_bytes = ( + f"SIP/2.0 200 OK\r\n" + f"Via: SIP/2.0/TLS example.com;branch={branch}\r\n" + f"From: sip:alice@example.com;tag=local\r\n" + f"To: sip:example.com;tag=remote\r\n" + f"Call-ID: resp-test@example.com\r\n" + f"CSeq: 1 INVITE\r\n" + f"\r\n" + ).encode() + session._dispatch_frame(response_bytes) + + # ------------------------------------------------------------------ + # data_received – stream reassembly + # ------------------------------------------------------------------ + + def test_data_received__split_message(self, rtp, fake_transport): + """Reassemble a SIP request delivered in two TCP segments.""" + session = self._make_session(rtp, fake_transport) + split = len(INVITE_BYTES) // 2 + before = len(session.transactions) + session.data_received(INVITE_BYTES[:split]) + assert len(session.transactions) == before # incomplete – not dispatched yet + session.data_received(INVITE_BYTES[split:]) + assert len(session.transactions) > before # now dispatched + + def test_data_received__coalesced_messages(self, rtp, fake_transport): + """Dispatch two SIP requests coalesced into one TCP segment.""" + second = ( + b"OPTIONS sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKopt2\r\n" + b"From: sip:bob@biloxi.com;tag=t88\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: opt-coalesced2@biloxi.com\r\n" + b"CSeq: 2 OPTIONS\r\n" + b"\r\n" + ) + session = self._make_session(rtp, fake_transport) + before = len(session.transactions) + session.data_received(INVITE_BYTES + second) + # INVITE creates a transaction; OPTIONS is answered directly (no tx added) + assert len(session.transactions) > before + + def test_data_received__body_split_across_segments(self, rtp, fake_transport): + """Reassemble a SIP request with a body split across two TCP segments.""" + body = b"v=0\r\no=- 1 1 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\n" + headers = ( + b"INVITE sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKbodysplit\r\n" + b"From: sip:bob@biloxi.com;tag=tbs\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: body-split@biloxi.com\r\n" + b"CSeq: 1 INVITE\r\n" + b"Content-Type: application/sdp\r\n" + b"Content-Length: " + str(len(body)).encode() + b"\r\n" + b"\r\n" + ) + session = self._make_session(rtp, fake_transport) + before = len(session.transactions) + session.data_received(headers + body[:5]) + assert len(session.transactions) == before # body incomplete + session.data_received(body[5:]) + assert len(session.transactions) > before # body complete, dispatched + + # ------------------------------------------------------------------ + # send_keepalive (via _make_session to avoid RegistrationTransaction) + # ------------------------------------------------------------------ + + async def test_send_keepalive__sends_ping_without_sip_fixture(self, rtp): + """send_keepalive writes a PING (CRLF CRLF) after the interval elapses.""" + fake_transport = FakeTransport() + session = self._make_session(rtp, fake_transport) + session.keepalive_interval = datetime.timedelta(milliseconds=10) + task = asyncio.create_task(session.send_keepalive()) + await asyncio.sleep(0.05) + task.cancel() + assert b"\r\n\r\n" in fake_transport.sent + + async def test_send_keepalive__stops_when_transport_cleared(self, rtp): + """send_keepalive exits when transport is set to None.""" + session = self._make_session(rtp) + session.transport = None + session.keepalive_interval = datetime.timedelta(milliseconds=1) + await session.send_keepalive() + + # ------------------------------------------------------------------ + # send / close (via _make_session) + # ------------------------------------------------------------------ + + def test_send__writes_bytes_without_sip_fixture(self, rtp, fake_transport): + """send() serialises and writes a SIP message to the transport.""" + session = self._make_session(rtp, fake_transport) + response = Response(status_code=200, phrase="OK") + session.send(response) + assert bytes(response) in fake_transport.sent + + def test_send__no_op_when_transport_is_none(self, rtp): + """send() is a no-op when transport is None.""" + session = self._make_session(rtp) + session.transport = None + session.send(Response(status_code=200, phrase="OK")) + + def test_close__closes_transport_without_sip_fixture(self, rtp, fake_transport): + """close() closes the underlying transport.""" + session = self._make_session(rtp, fake_transport) + session.close() + assert fake_transport.closed is True + + def test_close__no_op_when_transport_is_none(self, rtp): + """close() is a no-op when transport is None.""" + session = self._make_session(rtp) + session.transport = None + session.close() + + # ------------------------------------------------------------------ + # allowed_methods / allow_header / method_not_allowed (via _make_session) + # ------------------------------------------------------------------ + + def test_allowed_methods__includes_options_without_sip_fixture(self, rtp): + """OPTIONS is always included in allowed_methods.""" + session = self._make_session(rtp) + assert SIPMethod.OPTIONS in session.allowed_methods + + def test_allow_header__is_comma_separated_without_sip_fixture(self, rtp): + """allow_header returns a comma-separated string of methods.""" + session = self._make_session(rtp) + header = session.allow_header + assert "OPTIONS" in header + assert "," in header + + def test_method_not_allowed__sends_405_without_sip_fixture( + self, rtp, fake_transport + ): + """method_not_allowed() sends a 405 Method Not Allowed response.""" + session = self._make_session(rtp, fake_transport) + request = Message.parse( + b"PUBLISH sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKpub3\r\n" + b"From: sip:bob@biloxi.com;tag=t5\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: pub3-call@biloxi.com\r\n" + b"CSeq: 1 PUBLISH\r\n" + b"\r\n" + ) + session.method_not_allowed(request) + assert any(b"405" in data for data in fake_transport.sent) + + # ------------------------------------------------------------------ + # request_received (via _make_session) + # ------------------------------------------------------------------ + + def test_request_received__options_sends_200_without_sip_fixture( + self, rtp, fake_transport + ): + """OPTIONS request is answered with 200 OK.""" + session = self._make_session(rtp, fake_transport) + request = Message.parse( + b"OPTIONS sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKopt3\r\n" + b"From: sip:bob@biloxi.com;tag=t6\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: opt3-call@biloxi.com\r\n" + b"CSeq: 3 OPTIONS\r\n" + b"\r\n" + ) + session.request_received(request) + assert any(b"200" in data for data in fake_transport.sent) + + def test_request_received__invite_creates_transaction_without_sip_fixture( + self, rtp, fake_transport + ): + """INVITE request creates an InviteTransaction.""" + session = self._make_session(rtp, fake_transport) + request = Message.parse(INVITE_BYTES) + before = len(session.transactions) + session.request_received(request) + assert len(session.transactions) > before + + def test_request_received__unsupported_method_sends_405_without_sip_fixture( + self, rtp, fake_transport + ): + """Unsupported method triggers method_not_allowed (405).""" + session = self._make_session(rtp, fake_transport) + request = Message.parse( + b"PUBLISH sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKpub4\r\n" + b"From: sip:bob@biloxi.com;tag=t7\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: pub4-call@biloxi.com\r\n" + b"CSeq: 1 PUBLISH\r\n" + b"\r\n" + ) + session.request_received(request) + assert any(b"405" in data for data in fake_transport.sent) + + def test_request_received__cancel_dispatches_to_transaction_without_sip_fixture( + self, rtp, fake_transport + ): + """CANCEL is forwarded to the matching INVITE transaction.""" + session = self._make_session(rtp, fake_transport) + invite = Message.parse(INVITE_BYTES) + session.request_received(invite) + tx = session.transactions[invite.branch] + session.dialogs[(tx.dialog.remote_tag, tx.dialog.local_tag)] = tx.dialog + cancel = Message.parse( + b"CANCEL sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKabc123\r\n" + b"From: sip:bob@biloxi.com;tag=from-tag-1\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: test-call-id@biloxi.com\r\n" + b"CSeq: 1 CANCEL\r\n" + b"\r\n" + ) + session.request_received(cancel) + assert any(b"200" in data for data in fake_transport.sent) + + def test_request_received__cancel_gone_when_no_transaction_without_sip_fixture( + self, rtp, fake_transport + ): + """CANCEL with no matching transaction returns 410 Gone.""" + session = self._make_session(rtp, fake_transport) + cancel = Message.parse( + b"CANCEL sip:alice@example.com SIP/2.0\r\n" + b"Via: SIP/2.0/TLS 192.0.2.1:5061;branch=z9hG4bKnone2\r\n" + b"From: sip:bob@biloxi.com;tag=t8\r\n" + b"To: sip:alice@example.com\r\n" + b"Call-ID: no-tx2@biloxi.com\r\n" + b"CSeq: 1 CANCEL\r\n" + b"\r\n" + ) + session.request_received(cancel) + assert any(b"410" in data for data in fake_transport.sent) + + # ------------------------------------------------------------------ + # response_received (via _make_session) + # ------------------------------------------------------------------ + + def test_response_received__delegates_to_transaction_without_sip_fixture( + self, rtp, fake_transport + ): + """Response is forwarded to the matching transaction.""" + session = self._make_session(rtp, fake_transport) + branch = "z9hG4bKdel-test" + session.transactions[branch] = InviteTransaction( + sip=session, + method=SIPMethod.INVITE, + branch=branch, + cseq=1, + ) + response = Message.parse( + f"SIP/2.0 200 OK\r\n" + f"Via: SIP/2.0/TLS example.com;branch={branch}\r\n" + f"From: sip:alice@example.com;tag=lt\r\n" + f"To: sip:example.com;tag=rt\r\n" + f"Call-ID: del-test@example.com\r\n" + f"CSeq: 1 INVITE\r\n" + f"\r\n".encode() + ) + session.response_received(response) + + def test_response_received__warns_on_unknown_branch_without_sip_fixture( + self, rtp, fake_transport, caplog + ): + """Log a warning when the response branch is not in transactions.""" + import logging + + session = self._make_session(rtp, fake_transport) + response = Message.parse( + b"SIP/2.0 200 OK\r\n" + b"Via: SIP/2.0/TLS example.com;branch=z9hG4bKunknown\r\n" + b"From: sip:alice@example.com;tag=lt2\r\n" + b"To: sip:example.com;tag=rt2\r\n" + b"Call-ID: unknown-branch@example.com\r\n" + b"CSeq: 1 INVITE\r\n" + b"\r\n" + ) + with caplog.at_level(logging.WARNING): + session.response_received(response) + assert "unknown branch" in caplog.text + + # ------------------------------------------------------------------ + # connection_lost (via _make_session) + # ------------------------------------------------------------------ + + async def test_connection_lost__cancels_keepalive_without_sip_fixture(self, rtp): + """connection_lost() cancels and clears the keepalive task.""" + session = self._make_session(rtp) + session.keepalive_task = asyncio.create_task(asyncio.sleep(9999)) + session.connection_lost(None) + assert session.keepalive_task is None + + def test_connection_lost__clears_transport_without_sip_fixture(self, rtp): + """connection_lost() sets transport to None.""" + session = self._make_session(rtp) + session.connection_lost(None) + assert session.transport is None + + def test_connection_lost__sets_disconnected_event_without_sip_fixture(self, rtp): + """connection_lost() sets the disconnected_event.""" + session = self._make_session(rtp) + session.connection_lost(None) + assert session.disconnected_event.is_set() + + def test_connection_lost__logs_exception_without_sip_fixture(self, rtp, caplog): + """connection_lost() logs an error when an exception is provided.""" + import logging + + session = self._make_session(rtp) + with caplog.at_level(logging.ERROR): + session.connection_lost(OSError("reset")) + assert session.transport is None + + def test_connection_lost__no_keepalive_task_without_sip_fixture(self, rtp): + """connection_lost() is safe when keepalive_task is None.""" + session = self._make_session(rtp) + session.keepalive_task = None + session.connection_lost(None) + assert session.transport is None diff --git a/voip/sip/protocol.py b/voip/sip/protocol.py index 07bd553..b2f5188 100644 --- a/voip/sip/protocol.py +++ b/voip/sip/protocol.py @@ -24,6 +24,12 @@ logger = logging.getLogger("voip.sip") +#: RFC 5626 §4.4 keepalive PING sequence. +PING: typing.Final[bytes] = b"\r\n\r\n" + +#: RFC 5626 §4.4 keepalive PONG reply. +PONG: typing.Final[bytes] = b"\r\n" + __all__ = [ "SIP", "SessionInitiationProtocol", @@ -98,6 +104,7 @@ class MySession(SessionInitiationProtocol): ) transport: asyncio.Transport | None = dataclasses.field(init=False, default=None) is_secure: bool = dataclasses.field(init=False, default=False) + recv_buffer: bytearray = dataclasses.field(init=False, default_factory=bytearray) def connection_made(self, transport: asyncio.Transport) -> None: # type: ignore[override] """Store the TLS/TCP transport and start RTP mux + carrier registration.""" @@ -121,56 +128,74 @@ async def send_keepalive(self) -> None: if self.transport is None: return logger.info("PING", extra={"addr": self.local_address}) - self.transport.write(b"\r\n\r\n") + self.transport.write(PING) def data_received(self, data: bytes) -> None: - match data: - case b"\r\n": - logger.info( - "PONG", - extra={ - "addr": NetworkAddress( - *self.transport.get_extra_info("peername") - ) - }, - ) - return - case b"\r\n\r\n": - logger.info( - "PING", - extra={ - "addr": NetworkAddress( - *self.transport.get_extra_info("peername") - ) - }, - ) - if self.transport: - logger.info("PONG", extra={"addr": self.local_address}) - self.transport.write(b"\r\n") - return - match Message.parse(data): - case Request() as request: - logger.info( - "Request received: %r", - request, - extra={ - "addr": NetworkAddress( - *self.transport.get_extra_info("peername") - ) - }, - ) - self.request_received(request) - case Response() as response: - logger.info( - "Response received %r", - response, - extra={ - "addr": NetworkAddress( - *self.transport.get_extra_info("peername") - ) - }, - ) - self.response_received(response) + self.recv_buffer.extend(data) + for frame in self._extract_frames(): + self._dispatch_frame(frame) + + def _extract_frames(self) -> typing.Generator[memoryview | bytes]: # noqa: C901 + while self.recv_buffer: + if self.recv_buffer[0:1] != b"\r": + # SIP message: wait for the header-body separator. + header_end = self.recv_buffer.find(b"\r\n\r\n") + if header_end == -1: + break # incomplete headers – wait for more data + content_length = 0 + for line in self.recv_buffer[:header_end].split(b"\r\n")[1:]: + name, sep, value = line.partition(b":") + if sep and name.strip().lower() == b"content-length": + try: + content_length = int(value.strip()) + except ValueError: + pass + break + message_end = header_end + 4 + content_length + if len(self.recv_buffer) < message_end: + break # incomplete body – wait for more data + frame = memoryview(self.recv_buffer)[:message_end] + yield frame + frame.release() + del self.recv_buffer[:message_end] + elif len(self.recv_buffer) >= 4 and self.recv_buffer[:4] == PING: + yield PING + del self.recv_buffer[:4] + elif len(self.recv_buffer) >= 3 and self.recv_buffer[2:3] == b"\r": + # Third byte is CR – could be the start of PING; wait for 4th byte. + break + elif self.recv_buffer[:2] == PONG: + yield PONG + del self.recv_buffer[:2] + else: + # Single CR or other incomplete sequence – wait for more data. + break + + def _dispatch_frame(self, frame: memoryview | bytes) -> None: + peer = NetworkAddress(*self.transport.get_extra_info("peername")) + if frame == PONG: + logger.info("PONG", extra={"addr": peer}) + elif frame == PING: + logger.info("PING", extra={"addr": peer}) + if self.transport: + logger.info("PONG", extra={"addr": self.local_address}) + self.transport.write(PONG) + else: + match Message.parse(bytes(frame)): + case Request() as request: + logger.info( + "Request received: %r", + request, + extra={"addr": peer}, + ) + self.request_received(request) + case Response() as response: + logger.info( + "Response received %r", + response, + extra={"addr": peer}, + ) + self.response_received(response) def send(self, message: Response | Request) -> None: """Serialize and send a SIP message over the TLS/TCP connection."""