Skip to content

Commit 32369f8

Browse files
authored
Compliant cancel message (#29)
* Compliant cancel message Make the cancel message more compliant with the SIP specification. * Fix formatting * Handle 487 response Send an ACK for "487 Request Terminated" response to Cancel message.
1 parent 1e9a028 commit 32369f8

File tree

2 files changed

+102
-16
lines changed

2 files changed

+102
-16
lines changed

tests/test_sip.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"""Test voip_utils SIP functionality."""
22

3-
from voip_utils.sip import SipEndpoint, get_sip_endpoint
3+
from voip_utils.sip import CallInfo, SdpInfo, SipDatagramProtocol, SipEndpoint, SipMessage, get_sip_endpoint
4+
from unittest.mock import Mock
45

6+
_CRLF = "\r\n"
57

68
def test_parse_header_for_uri():
79
endpoint = SipEndpoint('"Test Name" <sip:[email protected]>')
@@ -84,3 +86,44 @@ def test_get_sip_endpoint_with_scheme():
8486
assert endpoint.description is None
8587
assert endpoint.username is None
8688
assert endpoint.uri == "sips:example.com"
89+
90+
class MockSipDatagramProtocol(SipDatagramProtocol):
91+
def on_call(self, call_info: CallInfo):
92+
pass
93+
94+
def test_cancel():
95+
protocol = MockSipDatagramProtocol(SdpInfo("username", 5, "session", "version"))
96+
source = get_sip_endpoint("testsource")
97+
destination = get_sip_endpoint("destination")
98+
invite_lines = [
99+
f"INVITE {destination.uri} SIP/2.0",
100+
f"Via: SIP/2.0/UDP {source.host}:{source.port}",
101+
f"From: {source.sip_header}",
102+
f"Contact: {source.sip_header}",
103+
f"To: {destination.sip_header}",
104+
f"Call-ID: 100",
105+
"CSeq: 50 INVITE",
106+
f"User-Agent: test-agent 1.0",
107+
"Allow: INVITE, ACK, OPTIONS, CANCEL, BYE, SUBSCRIBE, NOTIFY, INFO, REFER, UPDATE",
108+
"Accept: application/sdp, application/dtmf-relay",
109+
"Content-Type: application/sdp",
110+
"Content-Length: 0",
111+
"",
112+
]
113+
invite_text = _CRLF.join(invite_lines) + _CRLF
114+
invite_msg = SipMessage.parse_sip(invite_text, False)
115+
116+
call_info = CallInfo(
117+
caller_endpoint=destination,
118+
local_endpoint=source,
119+
caller_rtp_port=12345,
120+
server_ip=source.host,
121+
headers=invite_msg.headers,
122+
)
123+
124+
transport = Mock()
125+
protocol.connection_made(transport)
126+
protocol.cancel_call(call_info)
127+
128+
transport.sendto.assert_called_once_with(b'CANCEL sip:destination SIP/2.0\r\nVia: SIP/2.0/UDP testsource:5060\r\nFrom: sip:testsource\r\nTo: sip:destination\r\nCall-ID: 100\r\nCSeq: 50 CANCEL\r\nUser-Agent: voip-utils 1.0\r\nContent-Length: 0\r\n\r\n', ('destination', 5060))
129+

voip_utils/sip.py

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ class SipMessage:
8585
body: str
8686

8787
@staticmethod
88-
def parse_sip(message: str) -> SipMessage:
88+
def parse_sip(message: str, header_lowercase: bool = True) -> SipMessage:
8989
"""Parse a SIP message into a SipMessage object."""
9090
lines = message.splitlines()
9191

@@ -115,7 +115,7 @@ def parse_sip(message: str) -> SipMessage:
115115
break
116116
else:
117117
key, value = line.split(":", maxsplit=1)
118-
headers[key.lower()] = value.strip()
118+
headers[key.lower() if header_lowercase else key] = value.strip()
119119

120120
body = message[offset:]
121121
return SipMessage(protocol, method, request_uri, code, reason, headers, body)
@@ -220,6 +220,11 @@ def get_rtp_info(body: str) -> RtpInfo:
220220
return RtpInfo(rtp_ip=rtp_ip, rtp_port=rtp_port, payload_type=opus_payload_type)
221221

222222

223+
def get_header(headers: dict[str, str], name: str) -> tuple[str, str] | None:
224+
"""Get a header entry using a case insensitive key comparison."""
225+
return next(((k, v) for k, v in headers.items() if k.lower() == name.lower()), None)
226+
227+
223228
class SipDatagramProtocol(asyncio.DatagramProtocol, ABC):
224229
"""UDP server for the Session Initiation Protocol (SIP)."""
225230

@@ -289,25 +294,28 @@ def outgoing_call(
289294
(destination.host, destination.port),
290295
)
291296

297+
invite_msg = SipMessage.parse_sip(invite_text, False)
298+
292299
return CallInfo(
293300
caller_endpoint=destination,
294301
local_endpoint=source,
295302
caller_rtp_port=rtp_port,
296303
server_ip=source.host,
297-
headers={"call-id": call_id},
304+
headers=invite_msg.headers,
298305
)
299306

300307
def hang_up(self, call_info: CallInfo):
301308
"""Hang up the call when finished"""
302309
if self.transport is None:
303310
raise RuntimeError("No transport available for sending hangup.")
304311

312+
call_id = get_header(call_info.headers, "call-id")[1]
305313
bye_lines = [
306314
f"BYE {call_info.caller_endpoint.uri} SIP/2.0",
307315
f"Via: SIP/2.0/UDP {call_info.local_endpoint.host}:{call_info.local_endpoint.port}",
308316
f"From: {call_info.local_endpoint.sip_header}",
309317
f"To: {call_info.caller_endpoint.sip_header}",
310-
f"Call-ID: {call_info.headers['call-id']}",
318+
f"Call-ID: {call_id}",
311319
"CSeq: 51 BYE",
312320
f"User-Agent: {VOIP_UTILS_AGENT} 1.0",
313321
"Content-Length: 0",
@@ -328,17 +336,27 @@ def cancel_call(self, call_info: CallInfo):
328336
if self.transport is None:
329337
raise RuntimeError("No transport available for sending cancel.")
330338

331-
cancel_lines = [
332-
f"CANCEL {call_info.caller_endpoint.uri} SIP/2.0",
333-
f"Via: SIP/2.0/UDP {call_info.local_endpoint.host}:{call_info.local_endpoint.port}",
334-
f"From: {call_info.local_endpoint.sip_header}",
335-
f"To: {call_info.caller_endpoint.sip_header}",
336-
f"Call-ID: {call_info.headers['call-id']}",
337-
"CSeq: 51 CANCEL",
338-
f"User-Agent: {VOIP_UTILS_AGENT} 1.0",
339-
"Content-Length: 0",
340-
"",
339+
required_headers = ("via", "from", "to", "call-id")
340+
341+
cancel_headers = [
342+
f"{k}: {v}"
343+
for k, v in call_info.headers.items()
344+
if k.lower() in required_headers
341345
]
346+
347+
cseq_header, cseq_value = get_header(call_info.headers, "cseq")
348+
cseq_num = cseq_value.split()[0]
349+
350+
cancel_lines = (
351+
[f"CANCEL {call_info.caller_endpoint.uri} SIP/2.0"]
352+
+ cancel_headers
353+
+ [
354+
f"{cseq_header}: {cseq_num} CANCEL",
355+
f"User-Agent: {VOIP_UTILS_AGENT} 1.0",
356+
"Content-Length: 0",
357+
"",
358+
]
359+
)
342360
_LOGGER.debug("Canceling call...")
343361
cancel_text = _CRLF.join(cancel_lines) + _CRLF
344362
cancel_bytes = cancel_text.encode("utf-8")
@@ -347,7 +365,7 @@ def cancel_call(self, call_info: CallInfo):
347365
(call_info.caller_endpoint.host, call_info.caller_endpoint.port),
348366
)
349367

350-
self._end_outgoing_call(call_info.headers["call-id"])
368+
self._end_outgoing_call(get_header(call_info.headers, "call-id")[1])
351369
self.on_hangup(call_info)
352370

353371
def _register_outgoing_call(self, call_id: str, rtp_port: int):
@@ -482,6 +500,31 @@ def datagram_received(self, data: bytes, addr):
482500
# TODO: Verify that the call / sequence IDs match our outgoing INVITE
483501
_LOGGER.debug("Received response [%s]", message)
484502
is_ok = smsg.code == "200" and smsg.reason == "OK"
503+
if smsg.code == "487":
504+
# A 487 Request Terminated will be sent in response to a Cancel message
505+
_LOGGER.debug("Got 487 Request Terminated")
506+
caller_endpoint = None
507+
if smsg.headers.get("to") is not None:
508+
caller_endpoint = SipEndpoint(smsg.headers.get("to", ""))
509+
else:
510+
caller_endpoint = get_sip_endpoint(
511+
caller_ip, port=caller_sip_port
512+
)
513+
cseq_num = get_header(smsg.headers, "cseq")[1].split()[0]
514+
ack_lines = [
515+
f"ACK {caller_endpoint.uri} SIP/2.0",
516+
f"Via: {smsg.headers['via']}",
517+
f"From: {smsg.headers['from']}",
518+
f"To: {smsg.headers['to']}",
519+
f"Call-ID: {smsg.headers['call-id']}",
520+
f"CSeq: {cseq_num} ACK",
521+
f"User-Agent: {VOIP_UTILS_AGENT} 1.0",
522+
"Content-Length: 0",
523+
]
524+
ack_text = _CRLF.join(ack_lines) + _CRLF
525+
ack_bytes = ack_text.encode("utf-8")
526+
self.transport.sendto(ack_bytes, (caller_ip, caller_sip_port))
527+
return
485528
if not is_ok:
486529
_LOGGER.debug("Received non-OK response [%s]", message)
487530
return

0 commit comments

Comments
 (0)