diff --git a/pyVoIP/SIP/client.py b/pyVoIP/SIP/client.py index 7b73ccd..3464bf0 100644 --- a/pyVoIP/SIP/client.py +++ b/pyVoIP/SIP/client.py @@ -7,7 +7,7 @@ InvalidAccountInfoError, ) from pyVoIP.helpers import Counter -from pyVoIP.networking.nat import NAT +from pyVoIP.networking.nat import NAT, AddressType from pyVoIP.SIP.message import ( SIPMessage, SIPStatus, @@ -83,6 +83,7 @@ def __init__( self.subscribeCounter = Counter() self.byeCounter = Counter() self.messageCounter = Counter() + self.referCounter = Counter() self.callID = Counter() self.sessID = Counter() @@ -232,7 +233,7 @@ def stop(self) -> None: if self.s: self.s.close() - def sendto(self, request: str, address=None): + def sendto(self, request: str, address=None) -> "VoIPConnection": if address is None: address = (self.server, self.port) return self.s.send(request.encode("utf8")) @@ -298,17 +299,16 @@ def __gen_uri( method = method.lower() assert method in ["sip", "sips"], "method must be sip or sips" - assert ( - type(user) is str and len(user) > 0 - ), "User must be a non-empty string" assert ( type(host) is str and len(host) > 0 - ), "User must be a non-empty string" + ), "Host must be a non-empty string" password = f":{password}" if password else "" port_str = f":{port}" if port != 5060 else "" params = params if params else "" - return f"{method}:{user}{password}@{host}{port_str}{params}" + if type(user) is str and len(user) > 0: + return f"{method}:{user}{password}@{host}{port_str}{params}" + return f"{method}:{host}{port_str}{params}" def __gen_via(self, to: str, branch: str) -> str: # SIP/2.0/ should still be the prefix even if using TLS per RFC 3261 @@ -833,6 +833,158 @@ def gen_invite( return invRequest + def gen_refer( + self, + request: SIPMessage, + user: Optional[str] = None, + uri: Optional[str] = None, + blind=True, + new_dialog=True, + ) -> str: + if new_dialog: + return self.__gen_refer_new_dialog(request, user, uri, blind) + return self.__gen_refer_same_dialog(request, user, uri, blind) + + def __gen_refer_new_dialog( + self, + request: SIPMessage, + user: Optional[str] = None, + uri: Optional[str] = None, + blind=True, + ) -> str: + if user is None and uri is None: + raise RuntimeError("Must specify a user or a URI to transfer to") + call_id = self.gen_call_id() + self.tagLibrary[call_id] = self.gen_tag() + tag = self.tagLibrary[call_id] + + c = request.headers["Contact"]["uri"] + refer = f"REFER {c} SIP/2.0\r\n" + refer += self._gen_response_via_header(request) + refer += "Max-Forwards: 70\r\n" + + method = "sips" if self.transport_mode is TransportMode.TLS else "sip" + refer += self.__gen_from_to( + "From", + self.user, + self.nat.get_host(self.bind_ip), + method, + header_parms=f";tag={tag}", + ) + + # Determine if To or From is local to decide who the refer is to + to_local = ( + self.nat.check_host(request.headers["To"]["host"]) + == AddressType.LOCAL + ) + + if to_local: + to_user = request.headers["From"]["user"] + to_host = request.headers["From"]["host"] + method = request.headers["From"]["uri-type"] + to_display_name = request.headers["From"]["display-name"] + to_password = request.headers["From"]["password"] + to_port = request.headers["From"]["port"] + remote_tag = request.headers["From"]["tag"] + else: + to_user = request.headers["To"]["user"] + to_host = request.headers["To"]["host"] + method = request.headers["To"]["uri-type"] + to_display_name = request.headers["To"]["display-name"] + to_password = request.headers["To"]["password"] + to_port = request.headers["To"]["port"] + remote_tag = request.headers["To"]["tag"] + + refer += self.__gen_from_to( + "To", + to_user, + to_host, + method, + to_display_name, + to_password, + to_port, + ) + + refer += f"Call-ID: {call_id}\r\n" + refer += f"CSeq: {self.referCounter.next()} REFER\r\n" + refer += f"Allow: {(', '.join(pyVoIP.SIPCompatibleMethods))}\r\n" + if user: + method = ( + "sips" if self.transport_mode is TransportMode.TLS else "sip" + ) + uri = self.__gen_uri( + method, + user, + self.nat.get_host(self.server), + port=self.bind_port, + ) + refer += f"Refer-To: {uri}\r\n" + sess_call_id = request.headers["Call-ID"] + local_tag = self.tagLibrary[sess_call_id] + refer += ( + f"Target-Dialog: {sess_call_id};local-tag={local_tag}" + + f";remote-tag={remote_tag}\r\n" + ) + refer += self.__gen_contact( + method, + self.user, + self.nat.get_host(self.server), + port=self.bind_port, + ) + if blind: + refer += "Refer-Sub: false\r\n" + refer += "Supported: norefersub\r\n" + refer += self.__gen_user_agent() + refer += "Content-Length: 0\r\n\r\n" + return refer + + def __gen_refer_same_dialog( + self, + request: SIPMessage, + user: Optional[str] = None, + uri: Optional[str] = None, + blind=True, + ) -> str: + tag = self.tagLibrary[request.headers["Call-ID"]] + c = request.headers["Contact"]["uri"] + refer = f"REFER {c} SIP/2.0\r\n" + refer += self._gen_response_via_header(request) + refer += "Max-Forwards: 70\r\n" + _from = request.headers["From"] + to = request.headers["To"] + if request.headers["From"]["tag"] == tag: + refer += self.__gen_from_to_via_request(request, "From", tag) + refer += f"To: {to['raw']}\r\n" + else: + refer += f"To: {_from['raw']}\r\n" + refer += self.__gen_from_to_via_request( + request, "To", tag, dsthdr="From" + ) + refer += f"Call-ID: {request.headers['Call-ID']}\r\n" + refer += f"CSeq: {self.referCounter.next()} REFER\r\n" + refer += f"Allow: {(', '.join(pyVoIP.SIPCompatibleMethods))}\r\n" + method = "sips" if self.transport_mode is TransportMode.TLS else "sip" + if user: + uri = self.__gen_uri( + method, + user, + self.nat.get_host(self.server), + port=self.bind_port, + ) + refer += f"Refer-To: {uri}\r\n" + refer += self.__gen_contact( + method, + self.user, + self.nat.get_host(self.server), + port=self.bind_port, + ) + if blind: + refer += "Refer-Sub: false\r\n" + refer += "Supported: norefersub\r\n" + refer += self.__gen_user_agent() + refer += "Content-Length: 0\r\n\r\n" + return refer + def _gen_bye_cancel(self, request: SIPMessage, cmd: str) -> str: tag = self.tagLibrary[request.headers["Call-ID"]] c = request.headers["Contact"]["uri"] diff --git a/pyVoIP/SIP/message.py b/pyVoIP/SIP/message.py index e14bafe..868abd1 100644 --- a/pyVoIP/SIP/message.py +++ b/pyVoIP/SIP/message.py @@ -1,7 +1,7 @@ from enum import Enum, IntEnum from pyVoIP import regex from pyVoIP.SIP.error import SIPParseError -from pyVoIP.types import TFC_HEADER +from pyVoIP.types import URI_HEADER from typing import Any, Callable, Dict, List, Optional, Union import pyVoIP @@ -291,7 +291,6 @@ def __init__(self, data: bytes): self.SIPCompatibleMethods = pyVoIP.SIPCompatibleMethods self.heading: List[str] = [] self.type: Optional[SIPMessageType] = None - self.to: Optional[TFC_HEADER] = None self.status = SIPStatus(491) self.headers: Dict[str, Any] = {"Via": []} self.body: Dict[str, Any] = {} @@ -317,9 +316,20 @@ def __init__(self, data: bytes): self.parse(data) except Exception as e: if type(e) is not SIPParseError: - raise SIPParseError(e) + raise SIPParseError(e) from e raise + @property + def to(self) -> Optional[URI_HEADER]: + """ + The to property specifies the URI in the first line of a SIP request. + """ + return self._to + + @to.setter + def to(self, value: str) -> None: + self._to = value + def summary(self) -> str: data = "" data += f"{' '.join(self.heading)}\n\n" @@ -362,7 +372,7 @@ def parse(self, data: bytes) -> None: ) """ - def __get_tfc_header(self, data: str) -> TFC_HEADER: + def __get_uri_header(self, data: str) -> URI_HEADER: info = data.split(";tag=") tag = "" if len(info) >= 2: @@ -448,14 +458,14 @@ def parse_header(self, header: str, data: str) -> None: else: _via[x] = None self.headers["Via"].append(_via) - elif header in ["To", "From", "Contact"]: - self.headers[header] = self.__get_tfc_header(data) + elif header in ["To", "From", "Contact", "Refer-To"]: + self.headers[header] = self.__get_uri_header(data) elif header == "CSeq": self.headers[header] = { "check": int(data.split(" ")[0]), "method": data.split(" ")[1], } - elif header == "Allow" or header == "Supported": + elif header in ["Allow", "Supported", "Require"]: self.headers[header] = data.split(", ") elif header == "Call-ID": self.headers[header] = data @@ -483,6 +493,26 @@ def parse_header(self, header: str, data: str) -> None: header_data[var] = data.strip('"') self.headers[header] = header_data self.authentication = header_data + elif header == "Target-Dialog": + # Target-Dialog (tdialog) is specified in RFC 4538 + params = data.split(";") + header_data: Dict[str, Any] = { + "callid": params.pop(0) + } # key is callid to be consitenent with RFC 4538 Section 7 + for x in params: + y = x.split("=") + header_data[y[0]] = y[1] + self.headers[header] = header_data + elif header == "Refer-Sub": + # Refer-Sub (norefersub) is specified in RFC 4488 + params = data.split(";") + header_data: Dict[str, Any] = { + "value": True if params.pop(0) == "true" else False + } # BNF states extens are possible + for x in params: + y = x.split("=") + header_data[y[0]] = y[1] + self.headers[header] = header_data else: try: self.headers[header] = int(data) @@ -790,7 +820,7 @@ def parse_sip_request(self, data: bytes) -> None: raise SIPParseError(f"SIP Version {self.version} not compatible.") self.method = self.heading[0] - self.to = self.__get_tfc_header(self.heading[1]) + self.to = self.__get_uri_header(self.heading[1]) self.parse_raw_header(headers_raw, self.parse_header) diff --git a/pyVoIP/VoIP/call.py b/pyVoIP/VoIP/call.py index d250555..647dddc 100644 --- a/pyVoIP/VoIP/call.py +++ b/pyVoIP/VoIP/call.py @@ -276,9 +276,97 @@ def answer(self) -> None: message = self.sip.gen_answer( self.request, self.session_id, m, self.sendmode ) - self.sip.sendto(message, self.request.headers["Via"][0]["address"]) + self.conn.send(message) + message = SIPMessage(self.conn.recv()) + if message.method != "ACK": + debug( + f"Received Message to OK other than ACK: {message.method}:\n\n" + + f"{message.summary()}", + f"Received Message to OK other than ACK: {message.method}", + ) self.state = CallState.ANSWERED + def transfer( + self, user: Optional[str] = None, uri: Optional[str] = None, blind=True + ) -> bool: + """ + Send REFER request to transfer call. If blind is true (default), the + call will immediately end after received a 200 or 202 response. + Otherwise, it will wait for the Transferee to report a successful + transfer. Or, if the transfer is unsuccessful, the call will continue. + This function returns true if the transfer is blind or successful, and + returns false if it is unsuccessful. + + If using a URI to transfer, you must include a complete URI to include + <> brackets as necessary. + """ + request = self.sip.gen_refer(self.request, user, uri, blind) + """ + Per RFC 5589 Section 5, REFER messages SHOULD be sent over a new dialog + """ + new_dialog = True + conn = self.sip.send(request) + conn.send(request) + response = SIPMessage(conn.recv()) + + if response.status not in [ + SIPStatus.OK, + SIPStatus.TRYING, + SIPStatus.ACCEPTED, + SIPStatus.BAD_EXTENSION, + ]: + # If we've not received any of these responses, the client likely + # does not accept out of dialog REFER requests. + conn.close() + new_dialog = False + conn = self.conn + request = self.sip.gen_refer( + self.request, user, uri, blind, new_dialog=False + ) + conn.send(request) + response = SIPMessage(conn.recv()) + + norefersub = True + if blind and response.status == SIPStatus.BAD_EXTENSION: + # If the client does not support norefersub, resend without it. + norefersub = False + if new_dialog: + conn.close() + request = self.sip.gen_refer(self.request, user, uri, blind=False) + if new_dialog: + conn = self.sip.send(request) + else: + conn.send(request) + response = SIPMessage(conn.recv()) + + if response.status not in [SIPStatus.OK, SIPStatus.ACCEPTED]: + return False + if blind: + if norefersub: + if new_dialog: + conn.close() + self.hangup() + return True + response = SIPMessage(conn.recv()) + while response.method == "NOTIFY" and response.body.get( + "content", b"" + ) in [ + b"", + b"SIP/2.0 100 Trying\r\n", + b"SIP/2.0 181 Ringing\r\n", + ]: + reply = self.sip.gen_ok(response) + conn.send(reply) + response = SIPMessage(conn.recv()) + if response.body.get("content", b"") == b"SIP/2.0 200 OK\r\n": + reply = self.sip.gen_ok(response) + conn.send(reply) + if new_dialog: + conn.close() + self.hangup() + return True + return False + def rtp_answered(self, request: SIP.SIPMessage) -> None: for i in request.body["m"]: assoc = {} diff --git a/pyVoIP/sock/sock.py b/pyVoIP/sock/sock.py index 8a9f4ff..58ed364 100644 --- a/pyVoIP/sock/sock.py +++ b/pyVoIP/sock/sock.py @@ -77,7 +77,7 @@ def update_tags(self, local_tag: str, remote_tag: str) -> None: def peak(self) -> bytes: return self.recv(8192, timeout=60, peak=True) - def recv(self, nbytes: int, timeout=0, peak=False) -> bytes: + def recv(self, nbytes: int = 8192, timeout=0, peak=False) -> bytes: if self._peak_buffer: data = self._peak_buffer if not peak: diff --git a/pyVoIP/types.py b/pyVoIP/types.py index 2e73dca..46edcdf 100644 --- a/pyVoIP/types.py +++ b/pyVoIP/types.py @@ -3,9 +3,9 @@ import ssl -TFC_HEADER = Dict[ +URI_HEADER = Dict[ str, Union[str, int] -] # To From and Contact SIPMessage headers +] # URI headers such as To, From, Contact, etc SOCKETS = Union[socket.socket, ssl.SSLSocket] diff --git a/tests/test_sip_requests.py b/tests/test_sip_requests.py index e28b1b9..490b7c5 100644 --- a/tests/test_sip_requests.py +++ b/tests/test_sip_requests.py @@ -317,6 +317,145 @@ "Content-Length": 3, }, ), + ( + b"REFER sips:3ld812adkjw@biloxi.example.com;gr=3413kj2ha SIP/2.0\r\nVia: SIP/2.0/TLS pc33.atlanta.example.com;branch=z9hG4bKna9\r\nMax-Forwards: 70\r\nTo: \r\nFrom: ;tag=1928301774\r\nCall-ID: a84b4c76e66710\r\nCSeq: 314159 REFER\r\nAllow: INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY\r\nSupported: gruu, replaces, tdialog\r\nRequire: tdialog\r\nRefer-To: \r\nRefer-Sub: false\r\nTarget-Dialog: 090459243588173445;local-tag=7553452;remote-tag=31kdl4i3k\r\nContact: \r\nContent-Length: 0\r\n\r\n", + { + "Via": [ + { + "type": "SIP/2.0/TLS", + "address": ("pc33.atlanta.example.com", 5060), + "branch": "z9hG4bKna9", + } + ], + "Max-Forwards": 70, + "To": { + "raw": "", + "tag": "", + "uri": "sips:3ld812adkjw@biloxi.example.com", + "uri-type": "sips", + "user": "3ld812adkjw", + "password": "", + "display-name": "", + "host": "biloxi.example.com", + "port": 5060, + }, + "From": { + "raw": ";tag=1928301774", + "tag": "1928301774", + "uri": "sips:transferor@atlanta.example.com", + "uri-type": "sips", + "user": "transferor", + "password": "", + "display-name": "", + "host": "atlanta.example.com", + "port": 5060, + }, + "Call-ID": "a84b4c76e66710", + "CSeq": {"check": 314159, "method": "REFER"}, + "Allow": [ + "INVITE", + "ACK", + "CANCEL", + "OPTIONS", + "BYE", + "REFER", + "NOTIFY", + ], + "Supported": ["gruu", "replaces", "tdialog"], + "Require": ["tdialog"], + "Refer-To": { + "raw": "", + "tag": "", + "uri": "sips:transfertarget@chicago.example.com", + "uri-type": "sips", + "user": "transfertarget", + "password": "", + "display-name": "", + "host": "chicago.example.com", + "port": 5060, + }, + "Refer-Sub": {"value": False}, + "Target-Dialog": { + "callid": "090459243588173445", + "local-tag": "7553452", + "remote-tag": "31kdl4i3k", + }, + "Contact": { + "raw": "", + "tag": "", + "uri": "sips:4889445d8kjtk3@atlanta.example.com", + "uri-type": "sips", + "user": "4889445d8kjtk3", + "password": "", + "display-name": "", + "host": "atlanta.example.com", + "port": 5060, + }, + "Content-Length": 0, + }, + ), + ( + b"NOTIFY sips:4889445d8kjtk3@atlanta.example.com;gr=723jd2d SIP/2.0\r\nVia: SIP/2.0/TLS 192.0.2.4;branch=z9hG4bKnas432\r\nMax-Forwards: 70\r\nTo: ;tag=1928301774\r\nFrom: ;tag=a6c85cf\r\nCall-ID: a84b4c76e66710\r\nCSeq: 73 NOTIFY\r\nContact: \r\nAllow: INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY\r\nSupported: replaces, tdialog\r\nEvent: refer\r\nSubscription-State: active;expires=60\r\nContent-Type: message/sipfrag\r\nContent-Length: 18\r\n\r\nSIP/2.0 100 Trying\r\n", + { + "Via": [ + { + "type": "SIP/2.0/TLS", + "address": ("192.0.2.4", 5060), + "branch": "z9hG4bKnas432", + } + ], + "Max-Forwards": 70, + "To": { + "raw": ";tag=1928301774", + "tag": "1928301774", + "uri": "sips:transferor@atlanta.example.com", + "uri-type": "sips", + "user": "transferor", + "password": "", + "display-name": "", + "host": "atlanta.example.com", + "port": 5060, + }, + "From": { + "raw": ";tag=a6c85cf", + "tag": "a6c85cf", + "uri": "sips:3ld812adkjw@biloxi.example.com", + "uri-type": "sips", + "user": "3ld812adkjw", + "password": "", + "display-name": "", + "host": "biloxi.example.com", + "port": 5060, + }, + "Call-ID": "a84b4c76e66710", + "CSeq": {"check": 73, "method": "NOTIFY"}, + "Contact": { + "raw": "", + "tag": "", + "uri": "sips:3ld812adkjw@biloxi.example.com", + "uri-type": "sips", + "user": "3ld812adkjw", + "password": "", + "display-name": "", + "host": "biloxi.example.com", + "port": 5060, + }, + "Allow": [ + "INVITE", + "ACK", + "CANCEL", + "OPTIONS", + "BYE", + "REFER", + "NOTIFY", + ], + "Supported": ["replaces", "tdialog"], + "Event": "refer", + "Subscription-State": "active;expires=60", + "Content-Type": "message/sipfrag", + "Content-Length": 18, + }, + ), ], ) def test_sip_headers(packet, expected):