From e70c796f1e27ed58b5b7f2f119286f695789d953 Mon Sep 17 00:00:00 2001 From: TJ Porter <taylerporter@gmail.com> Date: Tue, 11 Jul 2023 00:14:17 -0500 Subject: [PATCH 01/13] [ADD] Added `__gen_from_to` in `SIPClient` for generating From/To original headers. [ADD] Added `__gen_uri` in `SIPClient` for generating URIs for headers. [ADD] Added better error handling for database issues in `VoIPSocket`. [ADD] Added `get_database_dump` in `VoIPSocket`. [CHANGE] Renamed `SIPClient._gen_from_to` to `__gen_from_to_via_request`. [CHANGE] Renamed `SIPClient._gen_user_agent` to `__gen_user_agent`. [CHANGE] `SIPClient.invite` now also returns a `VoIPConnection`. [CHANGE] Changed signature of `SIPClient.trying_timeout_check` to include the `VoIPConnection` so the timeout check can use the same dialog. [CHANGE] Changed `VoIPCall` to accept its `VoIPConnection` so it can interact with its dialog. [FIX] Fixed SIP.recv error causing a crash it data was not received at all. [FIX] Fixed issue with `SIPClient.gen_invite` using malformed headers. [FIX] Fixed all instances of not capturing the return from `SIPClient.sendto` causing multiple `VoIPConnection`s to be created for the same dialog. [FIX] Fixed issue with `VoIPSocket.determine_tags` returning a null local tag. [FIX] Fixed issue where `pyVoIP.VoIP` was not installing. [REMOVE] Removed commented code. --- pyVoIP/SIP/client.py | 187 +++++++++++++++++++++++++++------------- pyVoIP/VoIP/__init__.py | 0 pyVoIP/VoIP/call.py | 2 + pyVoIP/VoIP/phone.py | 3 +- pyVoIP/sock/sock.py | 60 +++++++++---- 5 files changed, 176 insertions(+), 76 deletions(-) create mode 100644 pyVoIP/VoIP/__init__.py diff --git a/pyVoIP/SIP/client.py b/pyVoIP/SIP/client.py index 104a7ab..a7bc584 100644 --- a/pyVoIP/SIP/client.py +++ b/pyVoIP/SIP/client.py @@ -24,6 +24,7 @@ if TYPE_CHECKING: from pyVoIP import RTP + from pyVoIP.sock import VoIPConnection debug = pyVoIP.debug @@ -98,9 +99,15 @@ def recv(self) -> None: else: debug(f"SIPParseError in SIP.recv: {type(e)}, {e}") except Exception as e: - debug(f"SIP.recv error: {type(e)}, {e}\n\n{str(raw, 'utf8')}") - if pyVoIP.DEBUG: - raise + try: + debug( + f"SIP.recv error: {type(e)}, {e}\n\n{str(raw, 'utf8')}" + ) + except UnboundLocalError: + debug("SIP.recv error: Unable to recv") + finally: + if pyVoIP.DEBUG: + raise def parse_message(self, message: SIPMessage) -> None: if message.type != SIPMessageType.REQUEST: @@ -170,7 +177,6 @@ def start(self) -> None: from pyVoIP.sock.sock import VoIPSocket self.NSD = True - # self.s = socket.socket(socket.AF_INET, self.transport_mode.socket_type) self.s = VoIPSocket( self.transport_mode, self.bind_ip, @@ -210,7 +216,7 @@ def sendto(self, request: str, address=None): def send(self, request: str): return self.s.send(request.encode("utf8")) - def _gen_from_to( + def __gen_from_to_via_request( self, request: SIPMessage, hdr: str, @@ -230,10 +236,57 @@ def _gen_from_to( if tag: return f"{ret} <{uri}>;tag={tag}\r\n" - else: - return f"{ret} <{uri}>\r\n" + return f"{ret} <{uri}>\r\n" + + def __gen_from_to( + self, + header_type: str, + user: str, + host: str, + method="sip", + display_name: Optional[str] = None, + password: Optional[str] = None, + port=5060, + uri_params: Optional[str] = None, + header_parms: Optional[str] = None, + ) -> str: + header_type = header_type.capitalize() + + assert header_type in ["To", "From"], "header_type must be To or From" + assert ( + display_name is None or '"' not in display_name + ), f'{display_name=} cannot contain a `"`' - def _gen_user_agent(self) -> str: + uri = self.__gen_uri(method, user, host, password, port, uri_params) + display_name = f'"{display_name}" ' if display_name else "" + header_parms = f"{header_parms}" if header_parms else "" + return f"{header_type}: {display_name}<{uri}>{header_parms}\r\n" + + def __gen_uri( + self, + method: str, + user: str, + host: str, + password: Optional[str] = None, + port=5060, + params: Optional[str] = None, + ) -> str: + 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" + + 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}" + + def __gen_user_agent(self) -> str: return f"User-Agent: pyVoIP {pyVoIP.__version__}\r\n" def gen_call_id(self) -> str: @@ -261,14 +314,16 @@ def gen_sip_version_not_supported(self, request: SIPMessage) -> str: response = "SIP/2.0 505 SIP Version Not Supported\r\n" response += self._gen_response_via_header(request) response += f"From: {request.headers['From']['raw']}\r\n" - response += self._gen_from_to(request, "To", self.gen_tag()) + response += self.__gen_from_to_via_request( + request, "To", self.gen_tag() + ) response += f"Call-ID: {request.headers['Call-ID']}\r\n" response += ( f"CSeq: {request.headers['CSeq']['check']} " + f"{request.headers['CSeq']['method']}\r\n" ) response += f"Contact: {request.headers['Contact']['raw']}\r\n" - response += self._gen_user_agent() + response += self.__gen_user_agent() response += 'Warning: 399 GS "Unable to accept call"\r\n' response += f"Allow: {(', '.join(pyVoIP.SIPCompatibleMethods))}\r\n" response += "Content-Length: 0\r\n\r\n" @@ -308,7 +363,7 @@ def gen_digest( password = credentials["password"] nonce = request.authentication["nonce"] method = request.headers["CSeq"]["method"] - uri = f"sip:{self.server};transport={self.transport_mode}" + uri = f"sip:{self.server};transport={self.transport_mode}" # TODO: Fix TLS algo = request.authentication.get("algorithm", "md5").lower() if algo in ["sha512-256", "sha512-256-sess"]: hash_func = self._hash_sha512_256 @@ -458,7 +513,7 @@ def gen_first_request(self, deregister=False) -> str: regRequest += f'Allow: {(", ".join(pyVoIP.SIPCompatibleMethods))}\r\n' regRequest += "Max-Forwards: 70\r\n" regRequest += "Allow-Events: org.3gpp.nwinitdereg\r\n" - regRequest += self._gen_user_agent() + regRequest += self.__gen_user_agent() # Supported: 100rel, replaces, from-change, gruu regRequest += ( "Expires: " @@ -495,7 +550,7 @@ def gen_subscribe(self, response: SIPMessage) -> str: + f'"<urn:uuid:{self.urnUUID}>"\r\n' ) subRequest += "Max-Forwards: 70\r\n" - subRequest += self._gen_user_agent() + subRequest += self.__gen_user_agent() subRequest += f"Expires: {self.default_expires * 2}\r\n" subRequest += "Event: message-summary\r\n" subRequest += "Accept: application/simple-message-summary\r\n" @@ -535,7 +590,7 @@ def gen_register(self, request: SIPMessage, deregister=False) -> str: regRequest += f'Allow: {(", ".join(pyVoIP.SIPCompatibleMethods))}\r\n' regRequest += "Max-Forwards: 70\r\n" regRequest += "Allow-Events: org.3gpp.nwinitdereg\r\n" - regRequest += self._gen_user_agent() + regRequest += self.__gen_user_agent() regRequest += ( "Expires: " + f"{self.default_expires if not deregister else 0}\r\n" @@ -550,7 +605,9 @@ def gen_busy(self, request: SIPMessage) -> str: response = "SIP/2.0 486 Busy Here\r\n" response += self._gen_response_via_header(request) response += f"From: {request.headers['From']['raw']}\r\n" - response += self._gen_from_to(request, "To", self.gen_tag()) + response += self.__gen_from_to_via_request( + request, "To", self.gen_tag() + ) response += f"Call-ID: {request.headers['Call-ID']}\r\n" response += ( f"CSeq: {request.headers['CSeq']['check']} " @@ -558,7 +615,7 @@ def gen_busy(self, request: SIPMessage) -> str: ) response += f"Contact: {request.headers['Contact']['raw']}\r\n" # TODO: Add Supported - response += self._gen_user_agent() + response += self.__gen_user_agent() response += 'Warning: 399 GS "Unable to accept call"\r\n' response += f"Allow: {(', '.join(pyVoIP.SIPCompatibleMethods))}\r\n" response += "Content-Length: 0\r\n\r\n" @@ -569,13 +626,15 @@ def gen_ok(self, request: SIPMessage) -> str: okResponse = "SIP/2.0 200 OK\r\n" okResponse += self._gen_response_via_header(request) okResponse += f"From: {request.headers['From']['raw']}\r\n" - okResponse += self._gen_from_to(request, "To", self.gen_tag()) + okResponse += self.__gen_from_to_via_request( + request, "To", self.gen_tag() + ) okResponse += f"Call-ID: {request.headers['Call-ID']}\r\n" okResponse += ( f"CSeq: {request.headers['CSeq']['check']} " + f"{request.headers['CSeq']['method']}\r\n" ) - okResponse += self._gen_user_agent() + okResponse += self.__gen_user_agent() okResponse += f"Allow: {(', '.join(pyVoIP.SIPCompatibleMethods))}\r\n" okResponse += "Content-Length: 0\r\n\r\n" @@ -586,7 +645,7 @@ def gen_ringing(self, request: SIPMessage) -> str: regRequest = "SIP/2.0 180 Ringing\r\n" regRequest += self._gen_response_via_header(request) regRequest += f"From: {request.headers['From']['raw']}\r\n" - regRequest += self._gen_from_to(request, "To", tag) + regRequest += self.__gen_from_to_via_request(request, "To", tag) regRequest += f"Call-ID: {request.headers['Call-ID']}\r\n" regRequest += ( f"CSeq: {request.headers['CSeq']['check']} " @@ -594,7 +653,7 @@ def gen_ringing(self, request: SIPMessage) -> str: ) regRequest += f"Contact: {request.headers['Contact']['raw']}\r\n" # TODO: Add Supported - regRequest += self._gen_user_agent() + regRequest += self.__gen_user_agent() regRequest += f"Allow: {(', '.join(pyVoIP.SIPCompatibleMethods))}\r\n" regRequest += "Content-Length: 0\r\n\r\n" @@ -639,7 +698,7 @@ def gen_answer( regRequest = "SIP/2.0 200 OK\r\n" regRequest += self._gen_response_via_header(request) regRequest += f"From: {request.headers['From']['raw']}\r\n" - regRequest += self._gen_from_to(request, "To", tag) + regRequest += self.__gen_from_to_via_request(request, "To", tag) regRequest += f"Call-ID: {request.headers['Call-ID']}\r\n" regRequest += ( f"CSeq: {request.headers['CSeq']['check']} " @@ -650,7 +709,7 @@ def gen_answer( + f"<sip:{self.user}@{self.bind_ip}:{self.bind_port}>\r\n" ) # TODO: Add Supported - regRequest += self._gen_user_agent() + regRequest += self.__gen_user_agent() regRequest += f"Allow: {(', '.join(pyVoIP.SIPCompatibleMethods))}\r\n" regRequest += "Content-Type: application/sdp\r\n" regRequest += f"Content-Length: {len(body)}\r\n\r\n" @@ -694,7 +753,13 @@ def gen_invite( tag = self.gen_tag() self.tagLibrary[call_id] = tag - invRequest = f"INVITE sip:{number}@{self.server} SIP/2.0\r\n" + uri_method = ( + "sips" if self.transport_mode == TransportMode.TLS else "sip" + ) + to_uri = self.__gen_uri( + uri_method, number, self.server, port=self.port + ) + invRequest = f"INVITE {to_uri} SIP/2.0\r\n" invRequest += ( "Via: SIP/2.0/" + str(self.transport_mode) @@ -702,17 +767,25 @@ def gen_invite( + f"{branch}\r\n" ) invRequest += "Max-Forwards: 70\r\n" - invRequest += ( - "Contact: " - + f"<sip:{self.user}@{self.bind_ip}:{self.bind_port}>\r\n" + uri = self.__gen_uri( + uri_method, self.user, self.bind_ip, port=self.bind_port + ) + invRequest += f"Contact: <{uri}>\r\n" + invRequest += self.__gen_from_to( + "To", number, self.server, port=self.port + ) + invRequest += self.__gen_from_to( + "From", + self.user, + self.bind_ip, + port=self.bind_port, + header_parms=f";tag={tag}", ) - invRequest += f"To: <sip:{number}@{self.server}>\r\n" - invRequest += f"From: <sip:{self.user}@{self.bind_ip}>;tag={tag}\r\n" invRequest += f"Call-ID: {call_id}\r\n" invRequest += f"CSeq: {self.inviteCounter.next()} INVITE\r\n" invRequest += f"Allow: {(', '.join(pyVoIP.SIPCompatibleMethods))}\r\n" invRequest += "Content-Type: application/sdp\r\n" - invRequest += self._gen_user_agent() + invRequest += self.__gen_user_agent() invRequest += f"Content-Length: {len(body)}\r\n\r\n" invRequest += body @@ -726,11 +799,13 @@ def _gen_bye_cancel(self, request: SIPMessage, cmd: str) -> str: _from = request.headers["From"] to = request.headers["To"] if request.headers["From"]["tag"] == tag: - byeRequest += self._gen_from_to(request, "From", tag) + byeRequest += self.__gen_from_to_via_request(request, "From", tag) byeRequest += f"To: {to['raw']}\r\n" else: byeRequest += f"To: {_from['raw']}\r\n" - byeRequest += self._gen_from_to(request, "To", tag, dsthdr="From") + byeRequest += self.__gen_from_to_via_request( + request, "To", tag, dsthdr="From" + ) byeRequest += f"Call-ID: {request.headers['Call-ID']}\r\n" cseq = request.headers["CSeq"]["check"] byeRequest += f"CSeq: {cseq} {cmd}\r\n" @@ -738,7 +813,7 @@ def _gen_bye_cancel(self, request: SIPMessage, cmd: str) -> str: "Contact: " + f"<sip:{self.user}@{self.bind_ip}:{self.bind_port}>\r\n" ) - byeRequest += self._gen_user_agent() + byeRequest += self.__gen_user_agent() byeRequest += f"Allow: {(', '.join(pyVoIP.SIPCompatibleMethods))}\r\n" byeRequest += "Content-Length: 0\r\n\r\n" @@ -766,7 +841,7 @@ def gen_ack(self, request: SIPMessage) -> str: ackMessage += f'From: {display_name}<{_from["uri"]}>;tag={tag}\r\n' ackMessage += f"Call-ID: {request.headers['Call-ID']}\r\n" ackMessage += f"CSeq: {request.headers['CSeq']['check']} ACK\r\n" - ackMessage += self._gen_user_agent() + ackMessage += self.__gen_user_agent() ackMessage += "Content-Length: 0\r\n\r\n" return ackMessage @@ -801,16 +876,16 @@ def invite( number: str, ms: Dict[int, Dict[int, "RTP.PayloadType"]], sendtype: "RTP.TransmitType", - ) -> Tuple[SIPMessage, str, int]: + ) -> Tuple[SIPMessage, str, int, "VoIPConnection"]: branch = "z9hG4bK" + self.gen_call_id()[0:25] call_id = self.gen_call_id() sess_id = self.sessID.next() invite = self.gen_invite( number, str(sess_id), ms, sendtype, branch, call_id ) - self.sendto(invite) + conn = self.sendto(invite) debug("Invited") - response = SIPMessage(self.s.recv(8192)) + response = SIPMessage(conn.recv(8192)) while ( response.status != SIPStatus(401) @@ -822,7 +897,7 @@ def invite( break debug(f"Received Response: {response.summary()}") self.parse_message(response) - response = SIPMessage(self.s.recv(8192)) + response = SIPMessage(conn.recv(8192)) debug(f"Received Response: {response.summary()}") @@ -832,7 +907,7 @@ def invite( debug("Invite status OK") return SIPMessage(invite.encode("utf8")), call_id, sess_id ack = self.gen_ack(response) - self.sendto(ack) + conn.send(ack) debug("Acknowledged") auth = self.gen_authorization(response) @@ -843,9 +918,9 @@ def invite( "\r\nContent-Length", f"\r\n{auth}Content-Length" ) - self.sendto(invite) + conn.send(invite) - return SIPMessage(invite.encode("utf8")), call_id, sess_id + return SIPMessage(invite.encode("utf8")), call_id, sess_id, conn def gen_message( self, number: str, body: str, ctype: str, branch: str, call_id: str @@ -874,11 +949,11 @@ def message( branch = "z0hG4bK" + self.gen_call_id()[0:25] call_id = self.gen_call_id() msg = self.gen_message(number, body, ctype, branch, call_id) - self.sendto(msg) + conn = self.sendto(msg) debug("Message") auth = False while True: - response = SIPMessage(self.s.recv(8192)) + response = SIPMessage(conn.recv(8192)) debug(f"Received Response: {response.summary()}") self.parse_message(response) if response.status == SIPStatus(100): @@ -894,7 +969,7 @@ def message( msg = msg.replace( "\r\nContent-Length", "\r\n{auth}Content-Length" ) - self.sendto(msg) + conn.send(msg) continue if response.status == SIPStatus.OK: break @@ -905,14 +980,14 @@ def message( def bye(self, request: SIPMessage) -> None: message = self.gen_bye(request) # TODO: Handle bye to server vs. bye to connected client - self.sendto( + conn = self.sendto( message, ( request.headers["Contact"]["host"], request.headers["Contact"]["port"], ), ) - response = SIPMessage(self.s.recv(8192)) + response = SIPMessage(conn.recv(8192)) if response.status == SIPStatus(401): # Requires password auth = self.gen_authorization(response) @@ -920,13 +995,7 @@ def bye(self, request: SIPMessage) -> None: "\r\nContent-Length", f"\r\n{auth}Content-Length" ) # TODO: Handle bye to server vs. bye to connected client - self.sendto( - message, - ( - request.headers["Contact"]["host"], - request.headers["Contact"]["port"], - ), - ) + conn.send(message) else: debug("Received not a 401 on bye:") debug(response.summary()) @@ -942,7 +1011,7 @@ def deregister(self) -> bool: resp = conn.recv(8192) response = SIPMessage(resp) - response = self.trying_timeout_check(response) + response = self.trying_timeout_check(conn, response) if response.status == SIPStatus(401): # Unauthorized, likely due to being password protected. @@ -982,7 +1051,7 @@ def register(self) -> bool: resp = conn.recv(8192) response = SIPMessage(resp) - response = self.trying_timeout_check(response) + response = self.trying_timeout_check(conn, response) first_response = response if response.status == SIPStatus(400): @@ -998,7 +1067,7 @@ def register(self) -> bool: conn.send(regRequest) resp = conn.recv(8192) response = SIPMessage(resp) - response = self.trying_timeout_check(response) + response = self.trying_timeout_check(conn, response) if response.status == SIPStatus(401): # At this point, it's reasonable to assume that # this is caused by invalid credentials. @@ -1077,13 +1146,15 @@ def subscribe(self, lastresponse: SIPMessage) -> None: # TODO: check if needed and maybe implement fully subRequest = self.gen_subscribe(lastresponse) - self.sendto(subRequest) + conn = self.sendto(subRequest) - response = SIPMessage(self.s.recv(8192)) + response = SIPMessage(conn.recv(8192)) debug(f'Got response to subscribe: {str(response.heading, "utf8")}') - def trying_timeout_check(self, response: SIPMessage) -> SIPMessage: + def trying_timeout_check( + self, conn: "VoIPConnection", response: SIPMessage + ) -> SIPMessage: """ Some servers need time to process the response. When this happens, the first response you get from the server is diff --git a/pyVoIP/VoIP/__init__.py b/pyVoIP/VoIP/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyVoIP/VoIP/call.py b/pyVoIP/VoIP/call.py index 69e773f..be4975f 100644 --- a/pyVoIP/VoIP/call.py +++ b/pyVoIP/VoIP/call.py @@ -20,6 +20,7 @@ if TYPE_CHECKING: from pyVoIP.VoIP.phone import VoIPPhone + from pyVoIP.sock.sock import VoIPConnection class CallState(Enum): @@ -39,6 +40,7 @@ def __init__( request: SIP.SIPMessage, session_id: int, bind_ip: str, + conn: "VoIPConnection", ms: Optional[Dict[int, RTP.PayloadType]] = None, sendmode="sendonly", ): diff --git a/pyVoIP/VoIP/phone.py b/pyVoIP/VoIP/phone.py index 9cc5486..0620daf 100644 --- a/pyVoIP/VoIP/phone.py +++ b/pyVoIP/VoIP/phone.py @@ -330,7 +330,7 @@ def call( medias[port][dynamic_int] = pt dynamic_int += 1 debug(f"Making call with {medias=}") - request, call_id, sess_id = self.sip.invite( + request, call_id, sess_id, conn = self.sip.invite( number, medias, RTP.TransmitType.SENDRECV ) self.calls[call_id] = self.callClass( @@ -341,6 +341,7 @@ def call( self.bind_ip, ms=medias, sendmode=self.sendmode, + conn=conn, ) return self.calls[call_id] diff --git a/pyVoIP/sock/sock.py b/pyVoIP/sock/sock.py index 0acc040..2197475 100644 --- a/pyVoIP/sock/sock.py +++ b/pyVoIP/sock/sock.py @@ -2,6 +2,7 @@ from pyVoIP.types import KEY_PASSWORD, SOCKETS from pyVoIP.SIP import SIPMessage, SIPMessageType from pyVoIP.sock.transport import TransportMode +import json import math import pyVoIP import socket @@ -253,25 +254,49 @@ def __register_connection(self, connection: VoIPConnection) -> None: self.conns_lock.acquire() self.conns.append(connection) conn_id = len(self.conns) - 1 - conn = self.buffer.cursor() - conn.execute( - """INSERT INTO "listening" - ("call_id", "local_tag", "remote_tag", "connection") - VALUES - (?, ?, ?, ?)""", - ( - connection.call_id, - connection.local_tag, - connection.remote_tag, - conn_id, - ), - ) try: + conn = self.buffer.cursor() + conn.execute( + """INSERT INTO "listening" + ("call_id", "local_tag", "remote_tag", "connection") + VALUES + (?, ?, ?, ?)""", + ( + connection.call_id, + connection.local_tag, + connection.remote_tag, + conn_id, + ), + ) self.buffer.commit() + except sqlite3.IntegrityError as e: + e.add_note( + "Error is from registering connection for message: " + + f"{connection.message.summary()}" + ) + e.add_note("Internal Database Dump:\n" + self.get_database_dump()) + e.add_note( + f"({connection.call_id=}, {connection.local_tag=}, " + + f"{connection.remote_tag=}, {conn_id=})" + ) + raise except sqlite3.OperationalError: pass - conn.close() - self.conns_lock.release() + finally: + conn.close() + self.conns_lock.release() + + def get_database_dump(self) -> str: + conn = self.buffer.cursor() + ret = "" + try: + result = conn.execute('SELECT * FROM "listening";') + ret += "listening: " + json.dumps(result.fetchall()) + "\n\n" + result = conn.execute('SELECT * FROM "msgs";') + ret += "msgs: " + json.dumps(result.fetchall()) + "\n\n" + finally: + conn.close() + return ret def determine_tags(self, message: SIPMessage) -> Tuple[str, str]: """ @@ -289,7 +314,7 @@ def determine_tags(self, message: SIPMessage) -> Tuple[str, str]: from_port = from_header["port"] from_tag = from_header["tag"] if from_header["tag"] else None - if to_host == self.bind_ip and to_port == self.bind_port: + if to_host == self.bind_ip and to_port == self.bind_port and to_tag: return to_tag, from_tag elif from_host == self.bind_ip and from_port == self.bind_port: return from_tag, to_tag @@ -359,7 +384,8 @@ def run(self) -> None: raw_message = data.decode("utf8") conn = self.buffer.cursor() conn.execute( - "INSERT INTO msgs (call_id, local_tag, remote_tag, msg) VALUES (?, ?, ?, ?)", + "INSERT INTO msgs (call_id, local_tag, remote_tag, msg) " + + "VALUES (?, ?, ?, ?)", (call_id, local_tag, remote_tag, raw_message), ) try: From 6d2b2315617f9b051e3624aef0b8ecaa2ea69678 Mon Sep 17 00:00:00 2001 From: TJ Porter <taylerporter@gmail.com> Date: Mon, 7 Aug 2023 04:13:44 -0500 Subject: [PATCH 02/13] [ADD] Added type annotations. [ADD] Added ability to close a `VoIPConnection`. [CHANGE] Cleaned up register and deregister. [CHANGE] Changed `trying_timeout_check` to `__receive` and changed usage. [CHANGE] `VoIPConnection.recv` now raises a timeout error for UDP. [FIX] Fixed listening table not updating properly on receiving the remote tag. --- pyVoIP/SIP/client.py | 206 ++++++++++++++++++++----------------------- pyVoIP/sock/sock.py | 79 +++++++++++++++-- 2 files changed, 167 insertions(+), 118 deletions(-) diff --git a/pyVoIP/SIP/client.py b/pyVoIP/SIP/client.py index a7bc584..be21190 100644 --- a/pyVoIP/SIP/client.py +++ b/pyVoIP/SIP/client.py @@ -213,7 +213,7 @@ def sendto(self, request: str, address=None): address = (self.server, self.port) return self.s.send(request.encode("utf8")) - def send(self, request: str): + def send(self, request: str) -> "VoIPConnection": return self.s.send(request.encode("utf8")) def __gen_from_to_via_request( @@ -1005,54 +1005,75 @@ def cancel(self, request: SIPMessage) -> None: self.sendto(message) def deregister(self) -> bool: - firstRequest = self.gen_first_request(deregister=True) - conn = self.send(firstRequest) + first_request = self.gen_first_request(deregister=True) + conn = self.send(first_request) - resp = conn.recv(8192) - - response = SIPMessage(resp) - response = self.trying_timeout_check(conn, response) + response = self.__receive(conn) + first_response = response + conn.close() # Regardless of the response, the dialog is over if response.status == SIPStatus(401): # Unauthorized, likely due to being password protected. - regRequest = self.gen_register(response, deregister=True) - conn.send(regRequest) - resp = conn.recv(8192) - response = SIPMessage(resp) - if response.status == SIPStatus(401): - # At this point, it's reasonable to assume that - # this is caused by invalid credentials. - debug("Unauthorized") - raise InvalidAccountInfoError( - "Invalid Username or " - + "Password for SIP server " - + f"{self.server}:" - + f"{self.bind_port}" - ) - elif response.status == SIPStatus(400): - # Bad Request - # TODO: implement - # TODO: check if broken connection can be brought back - # with new urn:uuid or reply with expire 0 - self._handle_bad_request() - - if response.status == SIPStatus(500): - time.sleep(5) - return self.deregister() - - if response.status == SIPStatus.OK: + password_request = self.gen_register(response, deregister=True) + conn = self.send(password_request) + response = self.__receive(conn) + conn.close() + + if response.status == SIPStatus(400): + # Bad Request + # TODO: implement + # TODO: check if broken connection can be brought back + # with new urn:uuid or reply with expire 0 + self._handle_bad_request() + + elif response.status == SIPStatus(407): + # Proxy Authentication Required + # TODO: implement + debug("Proxy auth required") + + elif response.status == SIPStatus(500): + raise Exception("Received a 500 error when deregistering.") + + elif response.status == SIPStatus.OK: return True - return False - def register(self) -> bool: - firstRequest = self.gen_first_request() - conn = self.send(firstRequest) + elif response.status == SIPStatus(401): + # At this point, it's reasonable to assume that + # this is caused by invalid credentials. + debug("=" * 50) + debug("Unauthorized deregister, SIP Message Log:\n") + debug("SENT") + debug(first_request) + debug("\nRECEIVED") + debug(first_response.summary()) + debug("\nSENT (DO NOT SHARE THIS PACKET)") + debug(password_request) + debug("\nRECEIVED") + debug(response.summary()) + debug("=" * 50) + raise InvalidAccountInfoError( + f"Invalid Username or Password for SIP server {self.server}:" + + f"{self.bind_port}" + ) - resp = conn.recv(8192) + raise Exception( + f"Unable to deregister. Ended with {response.summary()}" + ) - response = SIPMessage(resp) - response = self.trying_timeout_check(conn, response) + def register(self) -> bool: + first_request = self.gen_first_request() + conn = self.send(first_request) + + response = self.__receive(conn) first_response = response + conn.close() # Regardless of the response, the dialog is over + + if response.status == SIPStatus(401): + # Unauthorized, likely due to being password protected. + password_request = self.gen_register(response) + conn = self.send(password_request) + response = self.__receive(conn) + conn.close() if response.status == SIPStatus(400): # Bad Request @@ -1061,63 +1082,15 @@ def register(self) -> bool: # with new urn:uuid or reply with expire 0 self._handle_bad_request() - if response.status == SIPStatus(401): - # Unauthorized, likely due to being password protected. - regRequest = self.gen_register(response) - conn.send(regRequest) - resp = conn.recv(8192) - response = SIPMessage(resp) - response = self.trying_timeout_check(conn, response) - if response.status == SIPStatus(401): - # At this point, it's reasonable to assume that - # this is caused by invalid credentials. - debug("=" * 50) - debug("Unauthorized, SIP Message Log:\n") - debug("SENT") - debug(firstRequest) - debug("\nRECEIVED") - debug(first_response.summary()) - debug("\nSENT (DO NOT SHARE THIS PACKET)") - debug(regRequest) - debug("\nRECEIVED") - debug(response.summary()) - debug("=" * 50) - raise InvalidAccountInfoError( - "Invalid Username or " - + "Password for SIP server " - + f"{self.server}:" - + f"{self.bind_port}" - ) - elif response.status == SIPStatus(400): - # Bad Request - # TODO: implement - # TODO: check if broken connection can be brought back - # with new urn:uuid or reply with expire 0 - self._handle_bad_request() - - if response.status == SIPStatus(407): + elif response.status == SIPStatus(407): # Proxy Authentication Required # TODO: implement debug("Proxy auth required") - # TODO: This must be done more reliable - if response.status not in [ - SIPStatus(400), - SIPStatus(401), - SIPStatus(407), - ]: - # Unauthorized - if response.status == SIPStatus(500): - time.sleep(5) - return self.register() - else: - # TODO: determine if needed here - self.parse_message(response) - - debug(response.summary()) - debug(response.raw) + elif response.status == SIPStatus(500): + raise Exception("Received a 500 error when registering.") - if response.status == SIPStatus.OK: + elif response.status == SIPStatus.OK: if self.NSD: # self.subscribe(response) self.registerThread = Timer( @@ -1128,13 +1101,28 @@ def register(self) -> bool: ) self.registerThread.start() return True - else: + + elif response.status == SIPStatus(401): + # At this point, it's reasonable to assume that + # this is caused by invalid credentials. + debug("=" * 50) + debug("Unauthorized, SIP Message Log:\n") + debug("SENT") + debug(first_request) + debug("\nRECEIVED") + debug(first_response.summary()) + debug("\nSENT (DO NOT SHARE THIS PACKET)") + debug(password_request) + debug("\nRECEIVED") + debug(response.summary()) + debug("=" * 50) raise InvalidAccountInfoError( - "Invalid Username or Password for " - + f"SIP server {self.server}:" + f"Invalid Username or Password for SIP server {self.server}:" + f"{self.bind_port}" ) + raise Exception(f"Unable to register. Ended with {response.summary()}") + def _handle_bad_request(self) -> None: # Bad Request # TODO: implement @@ -1152,25 +1140,21 @@ def subscribe(self, lastresponse: SIPMessage) -> None: debug(f'Got response to subscribe: {str(response.heading, "utf8")}') - def trying_timeout_check( - self, conn: "VoIPConnection", response: SIPMessage - ) -> SIPMessage: + def __receive(self, conn: "VoIPConnection") -> SIPMessage: """ Some servers need time to process the response. When this happens, the first response you get from the server is SIPStatus.TRYING. This while loop tries checks every second for an - updated response. It times out after 30 seconds. + updated response. It times out after 30 seconds with no response. """ - start_time = time.monotonic() - while response.status == SIPStatus.TRYING: - if (time.monotonic() - start_time) >= self.register_timeout: - raise TimeoutError( - f"Waited {self.register_timeout} seconds but server is " - + "still TRYING" - ) - - ready = select.select([self.s], [], [], self.register_timeout) - if ready[0]: - resp = self.s.recv(8192) - response = SIPMessage(resp) + try: + response = SIPMessage(conn.recv(8128, self.register_timeout)) + while response.status == SIPStatus.TRYING and self.NSD: + response = SIPMessage(conn.recv(8128, self.register_timeout)) + time.sleep(1) + except TimeoutError: + raise TimeoutError( + f"Waited {self.register_timeout} seconds but the server is " + + "still TRYING or has not responded." + ) return response diff --git a/pyVoIP/sock/sock.py b/pyVoIP/sock/sock.py index 2197475..90350c6 100644 --- a/pyVoIP/sock/sock.py +++ b/pyVoIP/sock/sock.py @@ -71,9 +71,11 @@ def __find_remote_tag(self) -> None: ) rows = result.fetchall() if rows: + print(f"Found remote: {rows[0][0]}") self.remote_tag = rows[0][0] def recv(self, nbytes: int, timeout=0) -> bytes: + timeout = time.monotonic() + timeout if timeout else math.inf if self.conn: # TODO: Timeout msg = None @@ -86,16 +88,19 @@ def recv(self, nbytes: int, timeout=0) -> bytes: connection=self, error=e, received=data ) self.send(br) + if time.monotonic() <= timeout: + raise TimeoutError() debug(f"RECEIVED:\n{msg.summary()}") return data else: self.__find_remote_tag() - timeout = time.monotonic() + timeout if timeout else math.inf while time.monotonic() <= timeout and not self.sock.SD: + # print("Trying to receive") + # print(self.sock.get_database_dump()) conn = self.sock.buffer.cursor() conn.row_factory = sqlite3.Row sql = ( - """SELECT * FROM "msgs" WHERE "call_id" = ? AND "local_tag" = ?""" + 'SELECT * FROM "msgs" WHERE "call_id"=? AND "local_tag"=?' + (""" AND "remote_tag" = ?""" if self.remote_tag else "") ) bindings = ( @@ -107,15 +112,21 @@ def recv(self, nbytes: int, timeout=0) -> bytes: row = result.fetchone() if not row: continue - conn.execute( - """DELETE FROM "msgs" WHERE "id" = ?""", (row["id"],) - ) + conn.execute('DELETE FROM "msgs" WHERE "id" = ?', (row["id"],)) try: self.sock.buffer.commit() except sqlite3.OperationalError: pass conn.close() return row["msg"].encode("utf8") + if time.monotonic() <= timeout: + raise TimeoutError() + + def close(self): + self.__find_remote_tag() + self.sock.deregister_connection(self) + if self.conn: + self.conn.close() class VoIPSocket(threading.Thread): @@ -230,23 +241,41 @@ def __get_connection( result = conn.execute( """SELECT "connection" FROM "listening" WHERE "call_id" = ? - AND "local_tag" = ?""", + AND "local_tag" = ? + AND "remote_tag" is NULL""", (call_id, local_tag), ) rows = result.fetchall() if rows: + conn.execute( + """UPDATE "listening" SET + "remote_tag" = ? WHERE + "call_id" = ? + AND "local_tag" = ? + AND "remote_tag" is NULL""", + (remote_tag, call_id, local_tag), + ) conn.close() return self.conns[rows[0][0]] # If we still didn't find one, maybe we got the local and remote wrong? result = conn.execute( """SELECT "connection" FROM "listening" WHERE "call_id" = ? - AND "local_tag" = ?""", + AND "local_tag" = ? + AND "remote_tag" is NULL""", (call_id, remote_tag), ) rows = result.fetchall() conn.close() if rows: + conn.execute( + """UPDATE "listening" SET + "remote_tag" = ? WHERE + "call_id" = ? + AND "local_tag" = ? + AND "remote_tag" is NULL""", + (local_tag, call_id, remote_tag), + ) return self.conns[rows[0][0]] return None @@ -286,6 +315,42 @@ def __register_connection(self, connection: VoIPConnection) -> None: conn.close() self.conns_lock.release() + def deregister_connection(self, connection: VoIPConnection) -> None: + self.conns_lock.acquire() + print(f"Deregistering {connection}") + print(f"{self.conns=}") + print(self.get_database_dump()) + try: + conn = self.buffer.cursor() + result = conn.execute( + """SELECT "connection" FROM "listening" + WHERE "call_id" = ? AND "local_tag" = ? + AND "remote_tag" = ?""", + ( + connection.call_id, + connection.local_tag, + connection.remote_tag, + ), + ) + row = result.fetchone() + conn_id = row[0] + """ + Need to set to None to not change the indexes of any other conn + """ + self.conns[conn_id] = None + conn.execute( + 'DELETE FROM "listening" WHERE "connection" = ?', (conn_id,) + ) + self.buffer.commit() + except sqlite3.OperationalError: + pass + finally: + conn.close() + print("Deregistered") + print(f"{self.conns=}") + print(self.get_database_dump()) + self.conns_lock.release() + def get_database_dump(self) -> str: conn = self.buffer.cursor() ret = "" From 9b50dbd4342c12fba2e0d8b78c0301407eaa0dc0 Mon Sep 17 00:00:00 2001 From: TJ Porter <taylerporter@gmail.com> Date: Mon, 7 Aug 2023 04:16:21 -0500 Subject: [PATCH 03/13] [FIX] Fixed flake8 error. --- pyVoIP/SIP/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyVoIP/SIP/client.py b/pyVoIP/SIP/client.py index be21190..aada151 100644 --- a/pyVoIP/SIP/client.py +++ b/pyVoIP/SIP/client.py @@ -19,7 +19,6 @@ import random import time import uuid -import select if TYPE_CHECKING: From 322757bf9a4380989822f11938c992ab24277add Mon Sep 17 00:00:00 2001 From: TJ Porter <taylerporter@gmail.com> Date: Mon, 7 Aug 2023 04:25:19 -0500 Subject: [PATCH 04/13] [FIX] Updated my flake8, fixed last flake8 error. --- pyVoIP/SIP/message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyVoIP/SIP/message.py b/pyVoIP/SIP/message.py index 0990f8e..5ab7368 100644 --- a/pyVoIP/SIP/message.py +++ b/pyVoIP/SIP/message.py @@ -368,7 +368,7 @@ def __get_tfc_header(self, data: str) -> TFC_HEADER: if direct: reg = regex.TO_FROM_DIRECT_MATCH match = reg.match(data) - if type(match) != regex.Match: + if match is None: raise SIPParseError( "Regex failed to match To/From.\n\n" + "Please open a GitHub Issue at " From f4b6d271c5786bf43d7566c591bafdbd423d1846 Mon Sep 17 00:00:00 2001 From: TJ Porter <taylerporter@gmail.com> Date: Sun, 15 Oct 2023 18:41:27 -0500 Subject: [PATCH 05/13] [ADD] Added container and volume remove to the Docker start script. [ADD] Added basic Network Address Translation (NAT) feature. [ADD] Added __gen_via in SIP.client. [ADD] Added __gen_contact in SIP.client. [ADD] Added some IPv6 handling. [CHANGE] Changed Docker start script to PowerShell. [FIX] Fixed not receiving replies due to network routing. [FIX] Fixed registration tests failing due to max contacts. [FIX] Fixed uri's not denoting sips when using TLS [FIX] Fixed Via headers from other hosts being incorrectly changed. [FIX] Fixed timeout errors being raised incorrectly. [FIX] Fixed deregister_connection for TCP and TLS sockets. [FIX] Fixed all registration tests. --- .github/workflows/pytest.yml | 2 +- docker/settings/pjsip.conf | 4 +- docker/start.bat | 4 - docker/start.ps1 | 5 ++ pyVoIP/SIP/client.py | 146 +++++++++++++++++++--------------- pyVoIP/VoIP/phone.py | 6 ++ pyVoIP/networking/__init__.py | 0 pyVoIP/networking/nat.py | 41 ++++++++++ pyVoIP/sock/sock.py | 11 +-- tests/test_functionality.py | 52 ++++++------ 10 files changed, 170 insertions(+), 101 deletions(-) delete mode 100644 docker/start.bat create mode 100644 docker/start.ps1 create mode 100644 pyVoIP/networking/__init__.py create mode 100644 pyVoIP/networking/nat.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 5d96984..85c40ec 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -33,7 +33,7 @@ jobs: - name: Start Docker run: | docker build docker -t pyvoip/tests - docker run -d -p 5060:5060/udp -p 5061-5062:5061-5062/tcp pyvoip/tests + docker run --add-host host.docker.internal:host-gateway -d -p 5060:5060/udp -p 5061-5062:5061-5062/tcp pyvoip/tests - name: pytest # run: pytest --check-func run: pytest diff --git a/docker/settings/pjsip.conf b/docker/settings/pjsip.conf index 9ab5789..be0391e 100644 --- a/docker/settings/pjsip.conf +++ b/docker/settings/pjsip.conf @@ -29,7 +29,7 @@ aors=nopass [nopass] type=aor -max_contacts=1 +max_contacts=999 [pass] type=endpoint @@ -48,4 +48,4 @@ password=Testing123! [pass] type=aor -max_contacts=1 +max_contacts=999 diff --git a/docker/start.bat b/docker/start.bat deleted file mode 100644 index 4b36fbf..0000000 --- a/docker/start.bat +++ /dev/null @@ -1,4 +0,0 @@ -@echo off -docker rmi pyvoip/tests -docker build . -t pyvoip/tests -docker run -d -p 5060:5060/udp -p 5061-5062:5061-5062/tcp pyvoip/tests diff --git a/docker/start.ps1 b/docker/start.ps1 new file mode 100644 index 0000000..d97dddf --- /dev/null +++ b/docker/start.ps1 @@ -0,0 +1,5 @@ +docker stop $(docker ps -a -q) +docker rm --force --volumes $(docker ps -a -q) +docker rmi pyvoip/tests +docker build . -t pyvoip/tests +docker run --add-host host.docker.internal:host-gateway -d -p 5060:5060/udp -p 5061-5062:5061-5062/tcp pyvoip/tests diff --git a/pyVoIP/SIP/client.py b/pyVoIP/SIP/client.py index aada151..366ab74 100644 --- a/pyVoIP/SIP/client.py +++ b/pyVoIP/SIP/client.py @@ -7,6 +7,7 @@ InvalidAccountInfoError, ) from pyVoIP.helpers import Counter +from pyVoIP.networking.nat import NAT from pyVoIP.SIP.message import ( SIPMessage, SIPStatus, @@ -37,6 +38,9 @@ def __init__( user: str, credentials_manager: CredentialsManager, bind_ip="0.0.0.0", + bind_network="0.0.0.0/0", + hostname: Optional[str] = None, + remote_hostname: Optional[str] = None, bind_port=5060, call_callback: Optional[Callable[[SIPMessage], Optional[str]]] = None, transport_mode: TransportMode = TransportMode.UDP, @@ -49,6 +53,7 @@ def __init__( self.port = port self.bind_ip = bind_ip self.bind_port = bind_port + self.nat = NAT(bind_ip, bind_network, hostname, remote_hostname) self.user = user self.credentials_manager = credentials_manager self.transport_mode = transport_mode @@ -285,6 +290,31 @@ def __gen_uri( params = params if params else "" return f"{method}:{user}{password}@{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 + # 8.1.1.7, as shown in RFC 5630 6.1 + return ( + "Via: " + + f"SIP/2.0/{str(self.transport_mode)}" + + f" {self.nat.get_host(to)}:{self.bind_port};branch={branch}\r\n" + ) + + def __gen_contact( + self, + method: str, + user: str, + host: str, + password: Optional[str] = None, + port=5060, + uriparams: Optional[str] = None, + params: list[str] = [], + ) -> str: + uri = self.__gen_uri(method, user, host, password, port, uriparams) + uri = f"<{uri}>" + if params: + uri += ";" + (";".join(params)) + return f"Contact: {uri}\r\n" + def __gen_user_agent(self) -> str: return f"User-Agent: pyVoIP {pyVoIP.__version__}\r\n" @@ -484,12 +514,8 @@ def gen_urn_uuid(self) -> str: def gen_first_request(self, deregister=False) -> str: regRequest = f"REGISTER sip:{self.server}:{self.port} SIP/2.0\r\n" - regRequest += ( - "Via: SIP/2.0/" - + str(self.transport_mode) - + f" {self.bind_ip}:{self.bind_port};" - + f"branch={self.gen_branch()};rport\r\n" - ) + regRequest += self.__gen_via(self.server, self.gen_branch()) + regRequest += ( f'From: "{self.user}" ' + f"<sip:{self.user}@{self.bind_ip}:{self.bind_port}>;tag=" @@ -501,13 +527,14 @@ def gen_first_request(self, deregister=False) -> str: ) regRequest += f"Call-ID: {self.gen_call_id()}\r\n" regRequest += f"CSeq: {self.registerCounter.next()} REGISTER\r\n" - regRequest += ( - "Contact: " - + f"<sip:{self.user}@{self.bind_ip}:{self.bind_port};" - + "transport=" - + str(self.transport_mode) - + ">;+sip.instance=" - + f'"<urn:uuid:{self.urnUUID}>"\r\n' + method = "sips" if self.transport_mode is TransportMode.TLS else "sip" + trans_mode = str(self.transport_mode) + regRequest += self.__gen_contact( + method, + self.user, + self.nat.get_host(self.server), + uriparams=f";transport={trans_mode}", + params=[f'+sip.instance="<urn:uuid:{self.urnUUID}>"'], ) regRequest += f'Allow: {(", ".join(pyVoIP.SIPCompatibleMethods))}\r\n' regRequest += "Max-Forwards: 70\r\n" @@ -525,12 +552,7 @@ def gen_first_request(self, deregister=False) -> str: def gen_subscribe(self, response: SIPMessage) -> str: subRequest = f"SUBSCRIBE sip:{self.user}@{self.server} SIP/2.0\r\n" - subRequest += ( - "Via: SIP/2.0/" - + str(self.transport_mode) - + f" {self.bind_ip}:{self.bind_port};" - + f"branch={self.gen_branch()};rport\r\n" - ) + subRequest += self.__gen_via(self.server, self.gen_branch()) subRequest += ( f'From: "{self.user}" ' + f"<sip:{self.user}@{self.server}>;tag=" @@ -540,13 +562,14 @@ def gen_subscribe(self, response: SIPMessage) -> str: subRequest += f'Call-ID: {response.headers["Call-ID"]}\r\n' subRequest += f"CSeq: {self.subscribeCounter.next()} SUBSCRIBE\r\n" # TODO: check if transport is needed - subRequest += ( - "Contact: " - + f"<sip:{self.user}@{self.bind_ip}:{self.bind_port};" - + "transport=" - + str(self.transport_mode) - + ">;+sip.instance=" - + f'"<urn:uuid:{self.urnUUID}>"\r\n' + method = "sips" if self.transport_mode is TransportMode.TLS else "sip" + trans_mode = str(self.transport_mode) + subRequest += self.__gen_contact( + method, + self.user, + self.nat.get_host(self.server), + uriparams=f";transport={trans_mode}", + params=[f'+sip.instance="<urn:uuid:{self.urnUUID}>"'], ) subRequest += "Max-Forwards: 70\r\n" subRequest += self.__gen_user_agent() @@ -560,12 +583,7 @@ def gen_subscribe(self, response: SIPMessage) -> str: def gen_register(self, request: SIPMessage, deregister=False) -> str: regRequest = f"REGISTER sip:{self.server}:{self.port} SIP/2.0\r\n" - regRequest += ( - "Via: SIP/2.0/" - + str(self.transport_mode) - + f" {self.bind_ip}:{self.bind_port};branch=" - + f"{self.gen_branch()};rport\r\n" - ) + regRequest += self.__gen_via(self.server, self.gen_branch()) regRequest += ( f'From: "{self.user}" ' + f"<sip:{self.user}@{self.bind_ip}:{self.bind_port}>;tag=" @@ -578,13 +596,14 @@ def gen_register(self, request: SIPMessage, deregister=False) -> str: call_id = request.headers.get("Call-ID", self.gen_call_id()) regRequest += f"Call-ID: {call_id}\r\n" regRequest += f"CSeq: {self.registerCounter.next()} REGISTER\r\n" - regRequest += ( - "Contact: " - + f"<sip:{self.user}@{self.bind_ip}:{self.bind_port};" - + "transport=" - + str(self.transport_mode) - + ">;+sip.instance=" - + f'"<urn:uuid:{self.urnUUID}>"\r\n' + method = "sips" if self.transport_mode is TransportMode.TLS else "sip" + trans_mode = str(self.transport_mode) + regRequest += self.__gen_contact( + method, + self.user, + self.nat.get_host(self.server), + uriparams=f";transport={trans_mode}", + params=[f'+sip.instance="<urn:uuid:{self.urnUUID}>"'], ) regRequest += f'Allow: {(", ".join(pyVoIP.SIPCompatibleMethods))}\r\n' regRequest += "Max-Forwards: 70\r\n" @@ -703,9 +722,12 @@ def gen_answer( f"CSeq: {request.headers['CSeq']['check']} " + f"{request.headers['CSeq']['method']}\r\n" ) - regRequest += ( - "Contact: " - + f"<sip:{self.user}@{self.bind_ip}:{self.bind_port}>\r\n" + method = "sips" if self.transport_mode is TransportMode.TLS else "sip" + trans_mode = str(self.transport_mode) + regRequest += self.__gen_contact( + method, + self.user, + self.nat.get_host(self.server), ) # TODO: Add Supported regRequest += self.__gen_user_agent() @@ -727,12 +749,12 @@ def gen_invite( ) -> str: # Generate body first for content length body = "v=0\r\n" - # TODO: Check IPv4/IPv6 body += ( - f"o=pyVoIP {sess_id} {int(sess_id)+2} IN IP4 {self.bind_ip}\r\n" + f"o=pyVoIP {sess_id} {int(sess_id)+2} IN IP" + + f"{self.nat.bind_ip.version} {self.bind_ip}\r\n" ) body += f"s=pyVoIP {pyVoIP.__version__}\r\n" - body += f"c=IN IP4 {self.bind_ip}\r\n" # TODO: Check IPv4/IPv6 + body += f"c=IN IP{self.nat.bind_ip.version} {self.bind_ip}\r\n" body += "t=0 0\r\n" for x in ms: # TODO: Check AVP mode from request @@ -759,24 +781,21 @@ def gen_invite( uri_method, number, self.server, port=self.port ) invRequest = f"INVITE {to_uri} SIP/2.0\r\n" - invRequest += ( - "Via: SIP/2.0/" - + str(self.transport_mode) - + f" {self.bind_ip}:{self.bind_port};branch=" - + f"{branch}\r\n" - ) + invRequest += self.__gen_via(self.server, branch) invRequest += "Max-Forwards: 70\r\n" - uri = self.__gen_uri( - uri_method, self.user, self.bind_ip, port=self.bind_port + method = "sips" if self.transport_mode is TransportMode.TLS else "sip" + invRequest += self.__gen_contact( + method, + self.user, + self.nat.get_host(self.server), ) - invRequest += f"Contact: <{uri}>\r\n" invRequest += self.__gen_from_to( "To", number, self.server, port=self.port ) invRequest += self.__gen_from_to( "From", self.user, - self.bind_ip, + self.nat.get_host(self.server), port=self.bind_port, header_parms=f";tag={tag}", ) @@ -808,9 +827,11 @@ def _gen_bye_cancel(self, request: SIPMessage, cmd: str) -> str: byeRequest += f"Call-ID: {request.headers['Call-ID']}\r\n" cseq = request.headers["CSeq"]["check"] byeRequest += f"CSeq: {cseq} {cmd}\r\n" - byeRequest += ( - "Contact: " - + f"<sip:{self.user}@{self.bind_ip}:{self.bind_port}>\r\n" + method = "sips" if self.transport_mode is TransportMode.TLS else "sip" + byeRequest += self.__gen_contact( + method, + self.user, + self.nat.get_host(self.server), ) byeRequest += self.__gen_user_agent() byeRequest += f"Allow: {(', '.join(pyVoIP.SIPCompatibleMethods))}\r\n" @@ -852,9 +873,7 @@ def _gen_response_via_header(self, request: SIPMessage) -> str: via = "" for h_via in request.headers["Via"]: v_line = ( - "Via: SIP/2.0/" - + str(self.transport_mode) - + " " + f"Via: {h_via['type']} " + f'{h_via["address"][0]}:{h_via["address"][1]}' ) if "branch" in h_via.keys(): @@ -925,10 +944,7 @@ def gen_message( self, number: str, body: str, ctype: str, branch: str, call_id: str ) -> str: msg = f"MESSAGE sip:{number}@{self.server} SIP/2.0\r\n" - msg += ( - f"Via: SIP/2.0/{self.transport_mode} " - + f"{self.bind_ip}:{self.bind_port};branch={branch}\r\n" - ) + msg += self.__gen_via(self.server, branch) msg += "Max-Forwards: 70\r\n" msg += f"To: <sip:{number}@{self.server}>\r\n" msg += ( diff --git a/pyVoIP/VoIP/phone.py b/pyVoIP/VoIP/phone.py index 0620daf..9f5bde3 100644 --- a/pyVoIP/VoIP/phone.py +++ b/pyVoIP/VoIP/phone.py @@ -40,6 +40,9 @@ def __init__( user: str, credentials_manager: CredentialsManager, bind_ip="0.0.0.0", + bind_network="0.0.0.0/0", + hostname: Optional[str] = None, + remote_hostname: Optional[str] = None, bind_port=5060, transport_mode=TransportMode.UDP, cert_file: Optional[str] = None, @@ -90,6 +93,9 @@ def __init__( user, credentials_manager, bind_ip=self.bind_ip, + bind_network=bind_network, + hostname=hostname, + remote_hostname=remote_hostname, bind_port=bind_port, call_callback=self.callback, transport_mode=self.transport_mode, diff --git a/pyVoIP/networking/__init__.py b/pyVoIP/networking/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyVoIP/networking/nat.py b/pyVoIP/networking/nat.py new file mode 100644 index 0000000..bea9de9 --- /dev/null +++ b/pyVoIP/networking/nat.py @@ -0,0 +1,41 @@ +from typing import Optional +import ipaddress +import socket + + +class NATError(Exception): + pass + + +class NAT: + def __init__( + self, + bind_ip: str, + network: str, + hostname: Optional[str] = None, + remote_hostname: Optional[str] = None, + ): + self.bind_ip = ipaddress.ip_address(bind_ip) + self.network = ipaddress.ip_network(network) + self.hostname = bind_ip if hostname is None else hostname + self.remote_hostname = remote_hostname + + def get_host(self, host: str): + """Return the hostname another client needs to connect to us.""" + try: + ip = ipaddress.ip_address(host) + except ValueError: + try: + ip = socket.gethostbyname(host) + except socket.gaierror: + raise NATError(f"Unable to resolve hostname {host}") + + if ip in self.network: + return self.hostname + else: + if self.remote_hostname is not None: + return self.remote_hostname + raise NATError( + "No remote hostname specified, " + + "cannot provide a return path for remote hosts." + ) diff --git a/pyVoIP/sock/sock.py b/pyVoIP/sock/sock.py index 90350c6..958670c 100644 --- a/pyVoIP/sock/sock.py +++ b/pyVoIP/sock/sock.py @@ -88,7 +88,7 @@ def recv(self, nbytes: int, timeout=0) -> bytes: connection=self, error=e, received=data ) self.send(br) - if time.monotonic() <= timeout: + if time.monotonic() >= timeout: raise TimeoutError() debug(f"RECEIVED:\n{msg.summary()}") return data @@ -119,7 +119,7 @@ def recv(self, nbytes: int, timeout=0) -> bytes: pass conn.close() return row["msg"].encode("utf8") - if time.monotonic() <= timeout: + if time.monotonic() >= timeout: raise TimeoutError() def close(self): @@ -316,6 +316,8 @@ def __register_connection(self, connection: VoIPConnection) -> None: self.conns_lock.release() def deregister_connection(self, connection: VoIPConnection) -> None: + if self.mode is not TransportMode.UDP: + return self.conns_lock.acquire() print(f"Deregistering {connection}") print(f"{self.conns=}") @@ -365,7 +367,7 @@ def get_database_dump(self) -> str: def determine_tags(self, message: SIPMessage) -> Tuple[str, str]: """ - Returns local_tag, remote_tag + Return local_tag, remote_tag """ # TODO: Eventually NAT will be supported for people who don't have a SIP ALG # We will need to take that into account when determining the remote tag. @@ -398,8 +400,7 @@ def determine_tags(self, message: SIPMessage) -> Tuple[str, str]: message.type == SIPMessageType.REQUEST and message.method != "ACK" ): return to_tag, from_tag - else: - return from_tag, to_tag + return from_tag, to_tag def bind(self, addr: Tuple[str, int]) -> None: self.s.bind(addr) diff --git a/tests/test_functionality.py b/tests/test_functionality.py index d36cd49..7e6f55b 100644 --- a/tests/test_functionality.py +++ b/tests/test_functionality.py @@ -13,6 +13,10 @@ ) REASON = "Not checking functionality" pyVoIP.set_tls_security(ssl.CERT_NONE) +SERVER_HOST = "127.0.0.1" +UDP_PORT = 5060 +TCP_PORT = 5061 +TLS_PORT = 5062 @pytest.fixture @@ -20,11 +24,11 @@ def phone(): cm = CredentialsManager() cm.add("pass", "Testing123!") phone = VoIPPhone( - "127.0.0.1", - 5060, + SERVER_HOST, + UDP_PORT, "pass", cm, - bind_ip="127.0.0.1", + hostname="host.docker.internal", bind_port=5059, ) phone.start() @@ -35,11 +39,11 @@ def phone(): @pytest.fixture def nopass_phone(): phone = VoIPPhone( - "127.0.0.1", - 5060, + SERVER_HOST, + UDP_PORT, "nopass", CredentialsManager(), - bind_ip="127.0.0.1", + hostname="host.docker.internal", bind_port=5059, ) phone.start() @@ -52,11 +56,11 @@ def nopass_phone(): @pytest.mark.skipif(TEST_CONDITION, reason=REASON) def test_nopass(): phone = VoIPPhone( - "127.0.0.1", - 5060, + SERVER_HOST, + UDP_PORT, "nopass", CredentialsManager(), - bind_ip="127.0.0.1", + hostname="host.docker.internal", bind_port=5059, ) assert phone.get_status() == PhoneStatus.INACTIVE @@ -77,11 +81,11 @@ def test_pass(): cm = CredentialsManager() cm.add("pass", "Testing123!") phone = VoIPPhone( - "127.0.0.1", - 5060, + SERVER_HOST, + UDP_PORT, "pass", cm, - bind_ip="127.0.0.1", + hostname="host.docker.internal", bind_port=5059, ) assert phone.get_status() == PhoneStatus.INACTIVE @@ -100,11 +104,11 @@ def test_pass(): @pytest.mark.skipif(TEST_CONDITION, reason=REASON) def test_tcp_nopass(): phone = VoIPPhone( - "127.0.0.1", - 5061, + SERVER_HOST, + TCP_PORT, "nopass", CredentialsManager(), - bind_ip="127.0.0.1", + hostname="host.docker.internal", bind_port=5059, transport_mode=TransportMode.TCP, ) @@ -126,11 +130,11 @@ def test_tcp_pass(): cm = CredentialsManager() cm.add("pass", "Testing123!") phone = VoIPPhone( - "127.0.0.1", - 5061, + SERVER_HOST, + TCP_PORT, "pass", cm, - bind_ip="127.0.0.1", + hostname="host.docker.internal", bind_port=5059, transport_mode=TransportMode.TCP, ) @@ -150,11 +154,11 @@ def test_tcp_pass(): @pytest.mark.skipif(TEST_CONDITION, reason=REASON) def test_tls_nopass(): phone = VoIPPhone( - "127.0.0.1", - 5062, + SERVER_HOST, + TLS_PORT, "nopass", CredentialsManager(), - bind_ip="127.0.0.1", + hostname="host.docker.internal", bind_port=5059, transport_mode=TransportMode.TLS, cert_file="certs/cert.crt", @@ -179,11 +183,11 @@ def test_tls_pass(): cm = CredentialsManager() cm.add("pass", "Testing123!") phone = VoIPPhone( - "127.0.0.1", - 5062, + SERVER_HOST, + TLS_PORT, "pass", cm, - bind_ip="127.0.0.1", + hostname="host.docker.internal", bind_port=5059, transport_mode=TransportMode.TLS, cert_file="certs/cert.crt", From d74d80dc63a6646d96861bdb330e3467090fe921 Mon Sep 17 00:00:00 2001 From: TJ Porter <taylerporter@gmail.com> Date: Sun, 15 Oct 2023 18:46:25 -0500 Subject: [PATCH 06/13] [FIX] Fixed typing issue impacting Python 3.8 [FIX] Fixed flake8 error --- pyVoIP/SIP/client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyVoIP/SIP/client.py b/pyVoIP/SIP/client.py index 366ab74..f4435f6 100644 --- a/pyVoIP/SIP/client.py +++ b/pyVoIP/SIP/client.py @@ -307,7 +307,7 @@ def __gen_contact( password: Optional[str] = None, port=5060, uriparams: Optional[str] = None, - params: list[str] = [], + params: List[str] = [], ) -> str: uri = self.__gen_uri(method, user, host, password, port, uriparams) uri = f"<{uri}>" @@ -723,7 +723,6 @@ def gen_answer( + f"{request.headers['CSeq']['method']}\r\n" ) method = "sips" if self.transport_mode is TransportMode.TLS else "sip" - trans_mode = str(self.transport_mode) regRequest += self.__gen_contact( method, self.user, From 5f01dc9ef82dc0619bada41732d3be2031f148b3 Mon Sep 17 00:00:00 2001 From: TJ Porter <taylerporter@gmail.com> Date: Sun, 15 Oct 2023 18:49:57 -0500 Subject: [PATCH 07/13] [CHANGE] Turned back on functionality tests. --- .github/workflows/pytest.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 85c40ec..97c2125 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -35,5 +35,4 @@ jobs: docker build docker -t pyvoip/tests docker run --add-host host.docker.internal:host-gateway -d -p 5060:5060/udp -p 5061-5062:5061-5062/tcp pyvoip/tests - name: pytest - # run: pytest --check-func - run: pytest + run: pytest --check-func From e26fa04268986b7299f126bd63d3a3cfd6528dd8 Mon Sep 17 00:00:00 2001 From: TJ Porter <taylerporter@gmail.com> Date: Sun, 29 Oct 2023 13:31:40 -0500 Subject: [PATCH 08/13] [CHANGE] Ran black on docs. --- docs/conf.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 69e281b..dbbb176 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,26 +19,24 @@ # -- Project information ----------------------------------------------------- -project = 'pyVoIP' -copyright = '2023, Tayler Porter' -author = 'Tayler J Porter' +project = "pyVoIP" +copyright = "2023, Tayler Porter" +author = "Tayler J Porter" # The full version, including alpha/beta/rc tags -release = '2.0.0' +release = "2.0.0" -master_doc = 'index' +master_doc = "index" # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = [ - "sphinx_rtd_theme" -] +extensions = ["sphinx_rtd_theme"] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -51,11 +49,11 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'sphinx_rtd_theme' +html_theme = "sphinx_rtd_theme" -#pygments_style = 'sphinx' +# pygments_style = 'sphinx' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] From 0b3f1b395acda845bdf13e50d822b6dce6e30797 Mon Sep 17 00:00:00 2001 From: TJ Porter <taylerporter@gmail.com> Date: Tue, 31 Oct 2023 00:16:21 -0500 Subject: [PATCH 09/13] [ADD] Added basic Network Address Translation. [ADD] Added receiver thread to VoIPCall for handling future requests. [ADD] Added SIP State DB location global variable for debugging. [ADD] Added AddressType enum to support NAT-based determine_tags functions. [ADD] Added check_host function in NAT to check the AddressType of a host. [ADD] Added filtering to func test if on Windows. [ADD] Added helper variables to ensure func tests run correctly on linux. [CHANGE] Cleaned up response checks in SIP client. [CHANGE] Broke up VoIPCall init into multiple functions. [CHANGE] Changed determine_tags to use NAT. [CHANGE] Changed SIPMessage to always raise SIPParseError upon any error. [CHANGE] Added pprint option to get_database_dump. [FIX] Fixed port not showing when not 5060 in Contact header. [FIX] Fixed invites not working for UDP. [FIX] Fixed missing NSD update in VoIPPhone stop. [FIX] Fixed messages not sending when they are a UDP response. [FIX] Fixed UDP socket recv function not receiving messages missing tags. [FIX] Fixed SQL connection issues. --- pyVoIP/SIP/client.py | 36 ++++-- pyVoIP/SIP/message.py | 7 +- pyVoIP/VoIP/call.py | 228 +++++++++++++++++++++--------------- pyVoIP/VoIP/phone.py | 1 + pyVoIP/__init__.py | 11 ++ pyVoIP/networking/nat.py | 28 ++++- pyVoIP/sock/sock.py | 98 +++++++++------- tests/test_functionality.py | 44 ++++++- 8 files changed, 300 insertions(+), 153 deletions(-) diff --git a/pyVoIP/SIP/client.py b/pyVoIP/SIP/client.py index f4435f6..1229538 100644 --- a/pyVoIP/SIP/client.py +++ b/pyVoIP/SIP/client.py @@ -30,6 +30,13 @@ debug = pyVoIP.debug +UNAUTORIZED_RESPONSE_CODES = [ + SIPStatus.UNAUTHORIZED, + SIPStatus.PROXY_AUTHENTICATION_REQUIRED, +] +INVITE_OK_RESPONSE_CODES = [SIPStatus.TRYING, SIPStatus.RINGING, SIPStatus.OK] + + class SIPClient: def __init__( self, @@ -185,6 +192,7 @@ def start(self) -> None: self.transport_mode, self.bind_ip, self.bind_port, + self.nat, self.cert_file, self.key_file, self.key_password, @@ -198,9 +206,11 @@ def start(self) -> None: self.s.start() # TODO: Check if we need to register with a server or proxy. self.register() + """ t = Timer(1, self.recv) t.name = "SIP Receive" t.start() + """ def stop(self) -> None: self.NSD = False @@ -533,6 +543,7 @@ def gen_first_request(self, deregister=False) -> str: method, self.user, self.nat.get_host(self.server), + port=self.bind_port, uriparams=f";transport={trans_mode}", params=[f'+sip.instance="<urn:uuid:{self.urnUUID}>"'], ) @@ -568,6 +579,7 @@ def gen_subscribe(self, response: SIPMessage) -> str: method, self.user, self.nat.get_host(self.server), + port=self.bind_port, uriparams=f";transport={trans_mode}", params=[f'+sip.instance="<urn:uuid:{self.urnUUID}>"'], ) @@ -602,6 +614,7 @@ def gen_register(self, request: SIPMessage, deregister=False) -> str: method, self.user, self.nat.get_host(self.server), + port=self.bind_port, uriparams=f";transport={trans_mode}", params=[f'+sip.instance="<urn:uuid:{self.urnUUID}>"'], ) @@ -727,6 +740,7 @@ def gen_answer( method, self.user, self.nat.get_host(self.server), + port=self.bind_port, ) # TODO: Add Supported regRequest += self.__gen_user_agent() @@ -787,6 +801,7 @@ def gen_invite( method, self.user, self.nat.get_host(self.server), + port=self.bind_port, ) invRequest += self.__gen_from_to( "To", number, self.server, port=self.port @@ -831,6 +846,7 @@ def _gen_bye_cancel(self, request: SIPMessage, cmd: str) -> str: method, self.user, self.nat.get_host(self.server), + port=self.bind_port, ) byeRequest += self.__gen_user_agent() byeRequest += f"Allow: {(', '.join(pyVoIP.SIPCompatibleMethods))}\r\n" @@ -905,10 +921,8 @@ def invite( response = SIPMessage(conn.recv(8192)) while ( - response.status != SIPStatus(401) - and response.status != SIPStatus(407) - and response.status != SIPStatus(100) - and response.status != SIPStatus(180) + response.status + not in UNAUTORIZED_RESPONSE_CODES + INVITE_OK_RESPONSE_CODES ) or response.headers["Call-ID"] != call_id: if not self.NSD: break @@ -918,14 +932,16 @@ def invite( debug(f"Received Response: {response.summary()}") - if response.status == SIPStatus(100) or response.status == SIPStatus( - 180 - ): - debug("Invite status OK") - return SIPMessage(invite.encode("utf8")), call_id, sess_id + if response.status in INVITE_OK_RESPONSE_CODES: + debug("Invite Accepted") + if response.status is SIPStatus.OK: + return response, call_id, sess_id, conn + return SIPMessage(invite.encode("utf8")), call_id, sess_id, conn + debug("Invite Requires Authorization") ack = self.gen_ack(response) conn.send(ack) debug("Acknowledged") + conn.close() # End of Dialog auth = self.gen_authorization(response) invite = self.gen_invite( @@ -935,7 +951,7 @@ def invite( "\r\nContent-Length", f"\r\n{auth}Content-Length" ) - conn.send(invite) + conn = self.sendto(invite) return SIPMessage(invite.encode("utf8")), call_id, sess_id, conn diff --git a/pyVoIP/SIP/message.py b/pyVoIP/SIP/message.py index 5ab7368..e14bafe 100644 --- a/pyVoIP/SIP/message.py +++ b/pyVoIP/SIP/message.py @@ -313,7 +313,12 @@ def __init__(self, data: bytes): "v": "Via", } - self.parse(data) + try: + self.parse(data) + except Exception as e: + if type(e) is not SIPParseError: + raise SIPParseError(e) + raise def summary(self) -> str: data = "" diff --git a/pyVoIP/VoIP/call.py b/pyVoIP/VoIP/call.py index be4975f..d250555 100644 --- a/pyVoIP/VoIP/call.py +++ b/pyVoIP/VoIP/call.py @@ -1,7 +1,9 @@ from enum import Enum from pyVoIP import RTP, SIP +from pyVoIP.SIP.error import SIPParseError +from pyVoIP.SIP.message import SIPMessage, SIPMessageType, SIPStatus from pyVoIP.VoIP.error import InvalidStateError -from threading import Lock +from threading import Lock, Timer from typing import Any, Dict, List, Optional, TYPE_CHECKING import audioop import io @@ -37,7 +39,7 @@ def __init__( self, phone: "VoIPPhone", callstate: CallState, - request: SIP.SIPMessage, + request: SIPMessage, session_id: int, bind_ip: str, conn: "VoIPConnection", @@ -51,6 +53,7 @@ def __init__( self.call_id = request.headers["Call-ID"] self.session_id = str(session_id) self.bind_ip = bind_ip + self.conn = conn self.rtp_port_high = self.phone.rtp_port_high self.rtp_port_low = self.phone.rtp_port_low self.sendmode = sendmode @@ -71,103 +74,134 @@ def __init__( self.assignedPorts: Any = {} if callstate == CallState.RINGING: - audio = [] - video = [] - for x in self.request.body["c"]: - self.connections += x["address_count"] - for x in self.request.body["m"]: - if x["type"] == "audio": - self.audioPorts += x["port_count"] - audio.append(x) - elif x["type"] == "video": - self.videoPorts += x["port_count"] - video.append(x) - else: - warnings.warn( - f"Unknown media description: {x['type']}", stacklevel=2 - ) - - # Ports Adjusted is used in case of multiple m tags. - if len(audio) > 0: - audioPortsAdj = self.audioPorts / len(audio) + self.init_incoming_call(request) + elif callstate == CallState.DIALING: + self.init_outgoing_call(ms) + + t = Timer(0, self.receiver) + t.name = f"Call {self.call_id} Receiver" + t.start() + + def receiver(self): + """Receive and handle incoming messages""" + while self.state is not CallState.ENDED and self.phone.NSD: + data = self.conn.recv(8192) + if data is None: + continue + try: + message = SIPMessage(data) + except SIPParseError: + continue + if message.type is SIPMessageType.RESPONSE: + if message.status is SIPStatus.OK: + if self.state in [ + CallState.DIALING, + CallState.RINGING, + CallState.PROGRESS, + ]: + self.answered(message) + elif message.status == SIPStatus.NOT_FOUND: + pass else: - audioPortsAdj = 0 - if len(video) > 0: - videoPortsAdj = self.videoPorts / len(video) + if message.method == "BYE": + self.bye(message) + + def init_outgoing_call(self, ms: Optional[Dict[int, RTP.PayloadType]]): + if ms is None: + raise RuntimeError( + "Media assignments are required when " + "initiating a call" + ) + self.ms = ms + for m in self.ms: + self.port = m + self.assignedPorts[m] = self.ms[m] + + def init_incoming_call(self, request: SIP.SIPMessage): + audio = [] + video = [] + for x in self.request.body["c"]: + self.connections += x["address_count"] + for x in self.request.body["m"]: + if x["type"] == "audio": + self.audioPorts += x["port_count"] + audio.append(x) + elif x["type"] == "video": + self.videoPorts += x["port_count"] + video.append(x) else: - videoPortsAdj = 0 + warnings.warn( + f"Unknown media description: {x['type']}", stacklevel=2 + ) - if not ( - (audioPortsAdj == self.connections or self.audioPorts == 0) - and (videoPortsAdj == self.connections or self.videoPorts == 0) - ): - # TODO: Throw error to PBX in this case - warnings.warn("Unable to assign ports for RTP.", stacklevel=2) - return - - for i in request.body["m"]: - assoc = {} - e = False - for x in i["methods"]: + # Ports Adjusted is used in case of multiple m tags. + if len(audio) > 0: + audioPortsAdj = self.audioPorts / len(audio) + else: + audioPortsAdj = 0 + if len(video) > 0: + videoPortsAdj = self.videoPorts / len(video) + else: + videoPortsAdj = 0 + + if not ( + (audioPortsAdj == self.connections or self.audioPorts == 0) + and (videoPortsAdj == self.connections or self.videoPorts == 0) + ): + # TODO: Throw error to PBX in this case + warnings.warn("Unable to assign ports for RTP.", stacklevel=2) + return + + for i in request.body["m"]: + assoc = {} + e = False + for x in i["methods"]: + try: + p = RTP.PayloadType(int(x)) + assoc[int(x)] = p + except ValueError: try: - p = RTP.PayloadType(int(x)) + p = RTP.PayloadType( + i["attributes"][x]["rtpmap"]["name"] + ) assoc[int(x)] = p except ValueError: - try: - p = RTP.PayloadType( - i["attributes"][x]["rtpmap"]["name"] - ) - assoc[int(x)] = p - except ValueError: - # Sometimes rtpmap raise a KeyError because fmtp - # is set instate - pt = i["attributes"][x]["rtpmap"]["name"] - warnings.warn( - f"RTP Payload type {pt} not found.", - stacklevel=20, - ) - # Resets the warning filter so this warning will - # come up again if it happens. However, this - # also resets all other warnings. - warnings.simplefilter("default") - p = RTP.PayloadType("UNKNOWN") - assoc[int(x)] = p - except KeyError: - # fix issue 42 - # When rtpmap is not found, also set the found - # element to UNKNOWN - warnings.warn( - f"RTP KeyError {x} not found.", stacklevel=20 - ) - p = RTP.PayloadType("UNKNOWN") - assoc[int(x)] = p - - if e: - raise RTP.RTPParseError( - f"RTP Payload type {pt} not found." - ) - - # Make sure codecs are compatible. - codecs = {} - for m in assoc: - if assoc[m] in pyVoIP.RTPCompatibleCodecs: - codecs[m] = assoc[m] - # TODO: If no codecs are compatible then send error to PBX. - - port = self.phone.request_port() - self.create_rtp_clients( - codecs, self.bind_ip, port, request, i["port"] - ) - elif callstate == CallState.DIALING: - if ms is None: - raise RuntimeError( - "Media assignments are required when " - + "initiating a call" - ) - self.ms = ms - for m in self.ms: - self.port = m - self.assignedPorts[m] = self.ms[m] + # Sometimes rtpmap raise a KeyError because fmtp + # is set instate + pt = i["attributes"][x]["rtpmap"]["name"] + warnings.warn( + f"RTP Payload type {pt} not found.", + stacklevel=20, + ) + # Resets the warning filter so this warning will + # come up again if it happens. However, this + # also resets all other warnings. + warnings.simplefilter("default") + p = RTP.PayloadType("UNKNOWN") + assoc[int(x)] = p + except KeyError: + # fix issue 42 + # When rtpmap is not found, also set the found + # element to UNKNOWN + warnings.warn( + f"RTP KeyError {x} not found.", stacklevel=20 + ) + p = RTP.PayloadType("UNKNOWN") + assoc[int(x)] = p + + if e: + raise RTP.RTPParseError(f"RTP Payload type {pt} not found.") + + # Make sure codecs are compatible. + codecs = {} + for m in assoc: + if assoc[m] in pyVoIP.RTPCompatibleCodecs: + codecs[m] = assoc[m] + # TODO: If no codecs are compatible then send error to PBX. + + port = self.phone.request_port() + self.create_rtp_clients( + codecs, self.bind_ip, port, request, i["port"] + ) def create_rtp_clients( self, @@ -280,6 +314,8 @@ def answered(self, request: SIP.SIPMessage) -> None: elif self.state != CallState.PROGRESS: return self.state = CallState.ANSWERED + ack = self.phone.sip.gen_ack(request) + self.conn.send(ack) def progress(self, request: SIP.SIPMessage) -> None: if self.state != CallState.DIALING: @@ -377,7 +413,7 @@ def cancel(self) -> None: self.sip.cancel(self.request) self.state = CallState.CANCELING - def bye(self) -> None: + def bye(self, request: SIPMessage) -> None: if ( self.state == CallState.ANSWERED or self.state == CallState.PROGRESS @@ -385,7 +421,9 @@ def bye(self) -> None: ): for x in self.RTPClients: x.stop() - self.state = CallState.ENDED + self.state = CallState.ENDED + ok = self.phone.sip.gen_ok(request) + self.conn.send(ok) if self.request.headers["Call-ID"] in self.phone.calls: del self.phone.calls[self.request.headers["Call-ID"]] diff --git a/pyVoIP/VoIP/phone.py b/pyVoIP/VoIP/phone.py index 9f5bde3..9833bdc 100644 --- a/pyVoIP/VoIP/phone.py +++ b/pyVoIP/VoIP/phone.py @@ -311,6 +311,7 @@ def stop(self) -> None: except InvalidStateError: pass self.sip.stop() + self.NSD = False self._status = PhoneStatus.INACTIVE def call( diff --git a/pyVoIP/__init__.py b/pyVoIP/__init__.py index 858b3d4..e62e2f7 100644 --- a/pyVoIP/__init__.py +++ b/pyVoIP/__init__.py @@ -50,6 +50,17 @@ # https://docs.python.org/3/library/ssl.html#ssl.SSLContext.verify_mode TLS_VERIFY_MODE = ssl.CERT_REQUIRED +""" +DO NOT CHANGE IN PRODUCTION. + +This variable allows you to save the SIP message state database to a file +instead of storing it in memory which is the default. This is useful for +debugging, however pyVoIP does not delete the database afterwards which will +cause an Exception upon restarting pyVoIP. For this reason, we recommend you +do not change this variable in production. +""" +SIP_STATE_DB_LOCATION = ":memory:" + def set_tls_security(verify_mode: ssl.VerifyMode) -> None: """ diff --git a/pyVoIP/networking/nat.py b/pyVoIP/networking/nat.py index bea9de9..e07470f 100644 --- a/pyVoIP/networking/nat.py +++ b/pyVoIP/networking/nat.py @@ -1,3 +1,4 @@ +from enum import Enum from typing import Optional import ipaddress import socket @@ -7,6 +8,13 @@ class NATError(Exception): pass +class AddressType(Enum): + """Used for determining remote or local tags in SIP messages""" + + REMOTE = 0 + LOCAL = 1 + + class NAT: def __init__( self, @@ -26,7 +34,7 @@ def get_host(self, host: str): ip = ipaddress.ip_address(host) except ValueError: try: - ip = socket.gethostbyname(host) + ip = ipaddress.ip_address(socket.gethostbyname(host)) except socket.gaierror: raise NATError(f"Unable to resolve hostname {host}") @@ -39,3 +47,21 @@ def get_host(self, host: str): "No remote hostname specified, " + "cannot provide a return path for remote hosts." ) + + def check_host(self, host: str) -> AddressType: + """Determine if a host is a remote computer or not.""" + if host in [self.remote_hostname, self.hostname]: + return AddressType.LOCAL + try: + ip = ipaddress.ip_address(host) + if ip == self.bind_ip: + return AddressType.LOCAL + return AddressType.REMOTE + except ValueError: + try: + ip = ipaddress.ip_address(socket.gethostbyname(host)) + if ip == self.bind_ip: + return AddressType.LOCAL + return AddressType.REMOTE + except socket.gaierror: + return AddressType.REMOTE diff --git a/pyVoIP/sock/sock.py b/pyVoIP/sock/sock.py index 958670c..8937c90 100644 --- a/pyVoIP/sock/sock.py +++ b/pyVoIP/sock/sock.py @@ -1,9 +1,13 @@ from typing import List, Optional, Tuple, Union +from pyVoIP import SIP_STATE_DB_LOCATION from pyVoIP.types import KEY_PASSWORD, SOCKETS from pyVoIP.SIP import SIPMessage, SIPMessageType +from pyVoIP.SIP.error import SIPParseError +from pyVoIP.networking.nat import NAT, AddressType from pyVoIP.sock.transport import TransportMode import json import math +import pprint import pyVoIP import socket import sqlite3 @@ -48,12 +52,15 @@ def __init__( def send(self, data: Union[bytes, str]) -> None: if type(data) is str: data = data.encode("utf8") - msg = SIPMessage(data) + try: + msg = SIPMessage(data) + except SIPParseError: + return if not self.conn: # If UDP if msg.type == SIPMessageType.REQUEST: addr = (msg.to["host"], msg.to["port"]) else: - addr = msg.headers["Via"][0] + addr = msg.headers["Via"][0]["address"] self.sock.s.sendto(data, addr) else: self.conn.send(data) @@ -71,7 +78,7 @@ def __find_remote_tag(self) -> None: ) rows = result.fetchall() if rows: - print(f"Found remote: {rows[0][0]}") + # print(f"Found remote: {rows[0][0]}") self.remote_tag = rows[0][0] def recv(self, nbytes: int, timeout=0) -> bytes: @@ -83,7 +90,7 @@ def recv(self, nbytes: int, timeout=0) -> bytes: data = self.conn.recv(nbytes) try: msg = SIPMessage(data) - except Exception as e: + except SIPParseError as e: br = self.sock.gen_bad_request( connection=self, error=e, received=data ) @@ -101,20 +108,34 @@ def recv(self, nbytes: int, timeout=0) -> bytes: conn.row_factory = sqlite3.Row sql = ( 'SELECT * FROM "msgs" WHERE "call_id"=? AND "local_tag"=?' - + (""" AND "remote_tag" = ?""" if self.remote_tag else "") ) + if self.remote_tag: + sql += ( + ' UNION SELECT * FROM "msgs" WHERE "call_id"=? AND ' + + '"local_tag"=? AND "remote_tag"=?' + ) bindings = ( - (self.call_id, self.local_tag, self.remote_tag) + ( + self.call_id, + self.local_tag, + self.call_id, + self.local_tag, + self.remote_tag, + ) if self.remote_tag else (self.call_id, self.local_tag) ) result = conn.execute(sql, bindings) row = result.fetchone() if not row: + conn.close() continue - conn.execute('DELETE FROM "msgs" WHERE "id" = ?', (row["id"],)) try: self.sock.buffer.commit() + conn.execute( + 'DELETE FROM "msgs" WHERE "id" = ?', (row["id"],) + ) + self.sock.buffer.commit() except sqlite3.OperationalError: pass conn.close() @@ -135,6 +156,7 @@ def __init__( mode: TransportMode, bind_ip: str, bind_port: int, + nat: NAT, cert_file: Optional[str] = None, key_file: Optional[str] = None, key_password: KEY_PASSWORD = None, @@ -148,6 +170,7 @@ def __init__( self.s = socket.socket(socket.AF_INET, mode.socket_type) self.bind_ip: str = bind_ip self.bind_port: int = bind_port + self.nat = nat self.server_context: Optional[ssl.SSLContext] = None if mode.tls_mode: self.server_context = ssl.SSLContext( @@ -160,7 +183,9 @@ def __init__( ) self.s = self.server_context.wrap_socket(self.s, server_side=True) - self.buffer = sqlite3.connect(":memory:", check_same_thread=False) + self.buffer = sqlite3.connect( + SIP_STATE_DB_LOCATION, check_same_thread=False + ) """ RFC 3261 Section 12, Paragraph 2 states: "A dialog is identified at each UA with a dialog ID, which consists @@ -319,9 +344,9 @@ def deregister_connection(self, connection: VoIPConnection) -> None: if self.mode is not TransportMode.UDP: return self.conns_lock.acquire() - print(f"Deregistering {connection}") - print(f"{self.conns=}") - print(self.get_database_dump()) + debug(f"Deregistering {connection}") + debug(f"{self.conns=}") + debug(self.get_database_dump()) try: conn = self.buffer.cursor() result = conn.execute( @@ -348,57 +373,38 @@ def deregister_connection(self, connection: VoIPConnection) -> None: pass finally: conn.close() - print("Deregistered") - print(f"{self.conns=}") - print(self.get_database_dump()) self.conns_lock.release() - def get_database_dump(self) -> str: + def get_database_dump(self, pretty=False) -> str: conn = self.buffer.cursor() ret = "" try: result = conn.execute('SELECT * FROM "listening";') - ret += "listening: " + json.dumps(result.fetchall()) + "\n\n" + result1 = result.fetchall() result = conn.execute('SELECT * FROM "msgs";') - ret += "msgs: " + json.dumps(result.fetchall()) + "\n\n" + result2 = result.fetchall() finally: conn.close() - return ret + if pretty: + ret += "listening: " + pprint.pformat(result1) + "\n\n" + ret += "msgs: " + pprint.pformat(result2) + "\n\n" + else: + ret += "listening: " + json.dumps(result1) + "\n\n" + ret += "msgs: " + json.dumps(result2) + "\n\n" + return ret def determine_tags(self, message: SIPMessage) -> Tuple[str, str]: """ Return local_tag, remote_tag """ - # TODO: Eventually NAT will be supported for people who don't have a SIP ALG - # We will need to take that into account when determining the remote tag. to_header = message.headers["To"] from_header = message.headers["From"] to_host = to_header["host"] - to_port = to_header["port"] to_tag = to_header["tag"] if to_header["tag"] else None - from_host = from_header["host"] - from_port = from_header["port"] from_tag = from_header["tag"] if from_header["tag"] else None - if to_host == self.bind_ip and to_port == self.bind_port and to_tag: - return to_tag, from_tag - elif from_host == self.bind_ip and from_port == self.bind_port: - return from_tag, to_tag - # If there is not an exact match, see if the host at least matches. - # (But not if the hosts are the same) as asterisk likes to strip - # ports. - elif to_host != from_host: - if to_host == self.bind_ip: - return to_tag, from_tag - elif from_host == self.bind_ip: - return from_tag, to_tag - # If there is still not a match, guess the to or from tag based - # on if the message. Requests except ACK likely have us as To, - # for everthing else we're likely the From - elif ( - message.type == SIPMessageType.REQUEST and message.method != "ACK" - ): + if self.nat.check_host(to_host) is AddressType.LOCAL: return to_tag, from_tag return from_tag, to_tag @@ -421,7 +427,10 @@ def run(self) -> None: data = self.s.recv(8192) except OSError: continue - message = SIPMessage(data) + try: + message = SIPMessage(data) + except SIPParseError: + continue debug("\n\nReceived UDP Message:") debug(message.summary()) else: @@ -431,7 +440,10 @@ def run(self) -> None: continue debug(f"Received new {self.mode} connection from {addr}.") data = conn.recv(8192) - message = SIPMessage(data) + try: + message = SIPMessage(data) + except SIPParseError: + continue debug("\n\nReceived SIP Message:") debug(message.summary()) diff --git a/tests/test_functionality.py b/tests/test_functionality.py index 7e6f55b..22cebe7 100644 --- a/tests/test_functionality.py +++ b/tests/test_functionality.py @@ -2,22 +2,42 @@ from pyVoIP.VoIP.call import CallState from pyVoIP.VoIP.phone import PhoneStatus, VoIPPhone from pyVoIP.sock.transport import TransportMode +import json +import os import pytest import pyVoIP import ssl +import subprocess import sys import time + +IS_WINDOWS = True if os.name == "nt" else False TEST_CONDITION = ( "--check-functionality" not in sys.argv and "--check-func" not in sys.argv ) + +if not TEST_CONDITION and not IS_WINDOWS: + obj = json.loads( + subprocess.check_output(["docker", "network", "inspect", "bridge"]) + ) + DOCKER_GATEWAY = obj[0]["IPAM"]["Config"][0]["Gateway"] + CONTAINER_ID = list(obj[0]["Containers"].keys())[0] + CONTAINER_IP = obj[0]["Containers"][CONTAINER_ID]["IPv4Address"].split( + "/" + )[0] + REASON = "Not checking functionality" +NT_REASON = "Test always fails on Windows" pyVoIP.set_tls_security(ssl.CERT_NONE) -SERVER_HOST = "127.0.0.1" +SERVER_HOST = "127.0.0.1" if IS_WINDOWS else CONTAINER_IP +BIND_IP = "0.0.0.0" if IS_WINDOWS else DOCKER_GATEWAY UDP_PORT = 5060 TCP_PORT = 5061 TLS_PORT = 5062 +CALL_TIMEOUT = 2 # 2 seconds to answer. + @pytest.fixture def phone(): @@ -29,6 +49,7 @@ def phone(): "pass", cm, hostname="host.docker.internal", + bind_ip=BIND_IP, bind_port=5059, ) phone.start() @@ -44,6 +65,7 @@ def nopass_phone(): "nopass", CredentialsManager(), hostname="host.docker.internal", + bind_ip=BIND_IP, bind_port=5059, ) phone.start() @@ -61,6 +83,7 @@ def test_nopass(): "nopass", CredentialsManager(), hostname="host.docker.internal", + bind_ip=BIND_IP, bind_port=5059, ) assert phone.get_status() == PhoneStatus.INACTIVE @@ -86,6 +109,7 @@ def test_pass(): "pass", cm, hostname="host.docker.internal", + bind_ip=BIND_IP, bind_port=5059, ) assert phone.get_status() == PhoneStatus.INACTIVE @@ -109,6 +133,7 @@ def test_tcp_nopass(): "nopass", CredentialsManager(), hostname="host.docker.internal", + bind_ip=BIND_IP, bind_port=5059, transport_mode=TransportMode.TCP, ) @@ -135,6 +160,7 @@ def test_tcp_pass(): "pass", cm, hostname="host.docker.internal", + bind_ip=BIND_IP, bind_port=5059, transport_mode=TransportMode.TCP, ) @@ -159,6 +185,7 @@ def test_tls_nopass(): "nopass", CredentialsManager(), hostname="host.docker.internal", + bind_ip=BIND_IP, bind_port=5059, transport_mode=TransportMode.TLS, cert_file="certs/cert.crt", @@ -188,6 +215,7 @@ def test_tls_pass(): "pass", cm, hostname="host.docker.internal", + bind_ip=BIND_IP, bind_port=5059, transport_mode=TransportMode.TLS, cert_file="certs/cert.crt", @@ -205,27 +233,33 @@ def test_tls_pass(): assert phone.get_status() == PhoneStatus.INACTIVE -@pytest.mark.skip @pytest.mark.udp @pytest.mark.calling @pytest.mark.skipif(TEST_CONDITION, reason=REASON) +@pytest.mark.skipif(IS_WINDOWS, reason=NT_REASON) def test_make_call(phone): call = phone.call("answerme") + start = time.time() while call.state == CallState.DIALING: time.sleep(0.1) + if start + CALL_TIMEOUT < time.time(): + raise TimeoutError("Call was not answered before the timeout.") assert call.state == CallState.ANSWERED call.hangup() assert call.state == CallState.ENDED -@pytest.mark.skip @pytest.mark.udp @pytest.mark.calling @pytest.mark.skipif(TEST_CONDITION, reason=REASON) +@pytest.mark.skipif(IS_WINDOWS, reason=NT_REASON) def test_make_nopass_call(nopass_phone): call = nopass_phone.call("answerme") + start = time.time() while call.state == CallState.DIALING: time.sleep(0.1) + if start + CALL_TIMEOUT < time.time(): + raise TimeoutError("Call was not answered before the timeout.") assert call.state == CallState.ANSWERED call.hangup() assert call.state == CallState.ENDED @@ -235,10 +269,14 @@ def test_make_nopass_call(nopass_phone): @pytest.mark.udp @pytest.mark.calling @pytest.mark.skipif(TEST_CONDITION, reason=REASON) +@pytest.mark.skipif(IS_WINDOWS, reason=NT_REASON) def test_remote_hangup(phone): call = phone.call("answerme") + start = time.time() while call.state == CallState.DIALING: time.sleep(0.1) + if start + CALL_TIMEOUT < time.time(): + raise TimeoutError("Call was not answered before the timeout.") assert call.state == CallState.ANSWERED time.sleep(5) assert call.state == CallState.ENDED From 9fed4a0552346ad1da86a95dfd2f9f2619143592 Mon Sep 17 00:00:00 2001 From: TJ Porter <taylerporter@gmail.com> Date: Sat, 11 Nov 2023 22:17:00 -0600 Subject: [PATCH 10/13] [CHANGE] Broke up VoIPSocket.run into TCP/TLS and UDP run functions. [FIX] Fixed multiple SQL issues. [FIX] Fixed line length. [FIX] Fixed __get_connection. --- pyVoIP/sock/sock.py | 173 +++++++++++++++++++------------------------- 1 file changed, 75 insertions(+), 98 deletions(-) diff --git a/pyVoIP/sock/sock.py b/pyVoIP/sock/sock.py index 8937c90..d682c78 100644 --- a/pyVoIP/sock/sock.py +++ b/pyVoIP/sock/sock.py @@ -214,7 +214,7 @@ def __init__( conn.execute( """CREATE TABLE "listening" ( "call_id" TEXT NOT NULL, - "local_tag" TEXT NOT NULL, + "local_tag" TEXT, "remote_tag" TEXT, "connection" INTEGER NOT NULL UNIQUE, PRIMARY KEY("call_id", "local_tag", "remote_tag") @@ -231,7 +231,8 @@ def __init__( def gen_bad_request( self, connection=None, message=None, error=None, received=None ) -> bytes: - body = f"<error><message>{error}</message><received>{received}</received></error>" + body = f"<error><message>{error}</message>" + body += f"<received>{received}</received></error>" bad_request = "SIP/2.0 400 Malformed Request\r\n" bad_request += ( f"Via: SIP/2.0/{self.mode} {self.bind_ip}:{self.bind_port}\r\n" @@ -250,58 +251,29 @@ def __get_connection( local_tag, remote_tag = self.determine_tags(message) call_id = message.headers["Call-ID"] conn = self.buffer.cursor() - result = conn.execute( - """SELECT "connection" FROM "listening" WHERE - "call_id" = ? - AND "local_tag" = ? - AND "remote_tag" = ?""", - (call_id, local_tag, remote_tag), - ) + sql = 'SELECT "connection" FROM "listening" WHERE "call_id" IS ?' + sql += ' AND "local_tag" IS ? AND "remote_tag" IS ?' + result = conn.execute(sql, (call_id, local_tag, remote_tag)) rows = result.fetchall() if rows: conn.close() return self.conns[rows[0][0]] + debug("New Connection Started") # If we didn't find one lets look for something that doesn't have - # a remote tag - result = conn.execute( - """SELECT "connection" FROM "listening" WHERE - "call_id" = ? - AND "local_tag" = ? - AND "remote_tag" is NULL""", - (call_id, local_tag), - ) + # one of the tags + sql = 'SELECT "connection" FROM "listening" WHERE "call_id" = ?' + sql += ' AND ("local_tag" IS NULL OR "local_tag" = ?)' + sql += ' AND ("remote_tag" IS NULL OR "remote_tag" = ?)' + result = conn.execute(sql, (call_id, local_tag, remote_tag)) rows = result.fetchall() if rows: - conn.execute( - """UPDATE "listening" SET - "remote_tag" = ? WHERE - "call_id" = ? - AND "local_tag" = ? - AND "remote_tag" is NULL""", - (remote_tag, call_id, local_tag), - ) + if local_tag and remote_tag: + sql = 'UPDATE "listening" SET "remote_tag" = ?, ' + sql += '"local_tag" = ? WHERE "connection" = ?' + conn.execute(sql, (remote_tag, local_tag, rows[0][0])) conn.close() return self.conns[rows[0][0]] - # If we still didn't find one, maybe we got the local and remote wrong? - result = conn.execute( - """SELECT "connection" FROM "listening" WHERE - "call_id" = ? - AND "local_tag" = ? - AND "remote_tag" is NULL""", - (call_id, remote_tag), - ) - rows = result.fetchall() conn.close() - if rows: - conn.execute( - """UPDATE "listening" SET - "remote_tag" = ? WHERE - "call_id" = ? - AND "local_tag" = ? - AND "remote_tag" is NULL""", - (local_tag, call_id, remote_tag), - ) - return self.conns[rows[0][0]] return None def __register_connection(self, connection: VoIPConnection) -> None: @@ -349,10 +321,11 @@ def deregister_connection(self, connection: VoIPConnection) -> None: debug(self.get_database_dump()) try: conn = self.buffer.cursor() + sql = 'SELECT "connection" FROM "listening" WHERE "call_id" = ?' + sql += ' AND ("local_tag" IS NULL OR "local_tag" = ?)' + sql += ' AND ("remote_tag" IS NULL OR "remote_tag" = ?)' result = conn.execute( - """SELECT "connection" FROM "listening" - WHERE "call_id" = ? AND "local_tag" = ? - AND "remote_tag" = ?""", + sql, ( connection.call_id, connection.local_tag, @@ -417,60 +390,64 @@ def bind(self, addr: Tuple[str, int]) -> None: def _listen(self, backlog=0) -> None: return self.s.listen(backlog) - def run(self) -> None: - self.bind((self.bind_ip, self.bind_port)) - if self.mode != TransportMode.UDP: - self._listen() + def _tcp_tls_run(self) -> None: + self._listen() while not self.SD: - if self.mode == TransportMode.UDP: - try: - data = self.s.recv(8192) - except OSError: - continue - try: - message = SIPMessage(data) - except SIPParseError: - continue - debug("\n\nReceived UDP Message:") - debug(message.summary()) - else: - try: - conn, addr = self.s.accept() - except OSError: - continue - debug(f"Received new {self.mode} connection from {addr}.") - data = conn.recv(8192) - try: - message = SIPMessage(data) - except SIPParseError: - continue - debug("\n\nReceived SIP Message:") - debug(message.summary()) - - if not self.__connection_exists(message): - if self.mode == TransportMode.UDP: - self.__register_connection( - VoIPConnection(self, None, message) - ) - else: - self.__register_connection( - VoIPConnection(self, conn, message) - ) + try: + conn, addr = self.s.accept() + except OSError: + continue + debug(f"Received new {self.mode} connection from {addr}.") + data = conn.recv(8192) + try: + message = SIPMessage(data) + except SIPParseError: + continue + debug("\n\nReceived SIP Message:") + debug(message.summary()) + self._handle_incoming_message(conn, message) - call_id = message.headers["Call-ID"] - local_tag, remote_tag = self.determine_tags(message) - raw_message = data.decode("utf8") - conn = self.buffer.cursor() - conn.execute( - "INSERT INTO msgs (call_id, local_tag, remote_tag, msg) " - + "VALUES (?, ?, ?, ?)", - (call_id, local_tag, remote_tag, raw_message), - ) + def _udp_run(self) -> None: + while not self.SD: try: - self.buffer.commit() - except sqlite3.OperationalError: - pass - conn.close() + data = self.s.recv(8192) + except OSError: + continue + try: + message = SIPMessage(data) + except SIPParseError: + continue + debug("\n\nReceived UDP Message:") + debug(message.summary()) + self._handle_incoming_message(None, message) + + def _handle_incoming_message( + self, conn: Optional[SOCKETS], message: SIPMessage + ): + if not self.__connection_exists(message): + self.__register_connection(VoIPConnection(self, conn, message)) + + call_id = message.headers["Call-ID"] + local_tag, remote_tag = self.determine_tags(message) + raw_message = message.raw.decode("utf8") + conn = self.buffer.cursor() + conn.execute( + "INSERT INTO msgs (call_id, local_tag, remote_tag, msg) " + + "VALUES (?, ?, ?, ?)", + (call_id, local_tag, remote_tag, raw_message), + ) + try: + self.buffer.commit() + except sqlite3.OperationalError: + pass + conn.close() + + def run(self) -> None: + self.bind((self.bind_ip, self.bind_port)) + if self.mode == TransportMode.UDP: + self._udp_run() + else: + self._tcp_tls_run() def close(self) -> None: self.SD = True From 0902757be42ba40c8c74954ad9b4947091a14d54 Mon Sep 17 00:00:00 2001 From: TJ Porter <taylerporter@gmail.com> Date: Mon, 27 Nov 2023 07:54:31 -0600 Subject: [PATCH 11/13] [CHANGE] Began migration from SIPClient.recv to handle_new_connection [CHANGE] Added VoIPConnection to callback in VoIPPhone [CHANGE] Changed _callback_MSG_Invite to use VoIPConnection instead of creating a new dialog [CHANGE] Changed _create_call to be PEP8 compliant [CHANGE] Moved TCP/TLS recv and UDP recv code to different functions for better readability and maintainability [FIX] Fixed errors if calling RTP.stop when not started [FIX] Fixed issue with code not responding to SIP_STATE_DB_LOCATION [REMOVE] Removed call re-negotiation code as it seemed to not work as intended --- pyVoIP/RTP.py | 8 +- pyVoIP/SIP/client.py | 26 +++++-- pyVoIP/VoIP/phone.py | 37 ++++------ pyVoIP/sock/sock.py | 170 +++++++++++++++++++++++-------------------- 4 files changed, 131 insertions(+), 110 deletions(-) diff --git a/pyVoIP/RTP.py b/pyVoIP/RTP.py index c146900..ddccf3b 100644 --- a/pyVoIP/RTP.py +++ b/pyVoIP/RTP.py @@ -349,8 +349,12 @@ def start(self) -> None: def stop(self) -> None: self.NSD = False - self.sin.close() - self.sout.close() + if hasattr(self, "sin"): + if self.sin: + self.sin.close() + if hasattr(self, "sout"): + if self.sout: + self.sout.close() def read(self, length: int = 160, blocking: bool = True) -> bytes: if not blocking: diff --git a/pyVoIP/SIP/client.py b/pyVoIP/SIP/client.py index 1229538..7b73ccd 100644 --- a/pyVoIP/SIP/client.py +++ b/pyVoIP/SIP/client.py @@ -49,7 +49,9 @@ def __init__( hostname: Optional[str] = None, remote_hostname: Optional[str] = None, bind_port=5060, - call_callback: Optional[Callable[[SIPMessage], Optional[str]]] = None, + call_callback: Optional[ + Callable[["VoIPConnection", SIPMessage], Optional[str]] + ] = None, transport_mode: TransportMode = TransportMode.UDP, cert_file: Optional[str] = None, key_file: Optional[str] = None, @@ -120,6 +122,20 @@ def recv(self) -> None: if pyVoIP.DEBUG: raise + def handle_new_connection(self, conn: "VoIPConnection") -> None: + message = SIPMessage(conn.peak()) + if message.type == SIPMessageType.REQUEST: + if message.method == "INVITE": + self._handle_invite(conn) + + def _handle_invite(self, conn: "VoIPConnection") -> None: + message = SIPMessage(conn.peak()) + if self.call_callback is None: + request = self.gen_busy(message) + conn.send(request) + else: + self.call_callback(conn, message) + def parse_message(self, message: SIPMessage) -> None: if message.type != SIPMessageType.REQUEST: if message.status in ( @@ -143,12 +159,6 @@ def parse_message(self, message: SIPMessage) -> None: "TODO: Add 500 Error on Receiving SIP Response", ) return - elif message.method == "INVITE": - if self.call_callback is None: - request = self.gen_busy(message) - self.sendto(request, message.headers["Via"]["address"]) - else: - self.call_callback(message) elif message.method == "BYE": # TODO: If callCallback is None, the call doesn't exist, 481 if self.call_callback: @@ -192,7 +202,7 @@ def start(self) -> None: self.transport_mode, self.bind_ip, self.bind_port, - self.nat, + self, self.cert_file, self.key_file, self.key_password, diff --git a/pyVoIP/VoIP/phone.py b/pyVoIP/VoIP/phone.py index 9833bdc..fb441db 100644 --- a/pyVoIP/VoIP/phone.py +++ b/pyVoIP/VoIP/phone.py @@ -1,6 +1,7 @@ from enum import Enum from pyVoIP import SIP, RTP from pyVoIP.credentials import CredentialsManager +from pyVoIP.sock.sock import VoIPConnection from pyVoIP.sock.transport import TransportMode from pyVoIP.types import KEY_PASSWORD from pyVoIP.VoIP.call import CallState, VoIPCall @@ -101,12 +102,14 @@ def __init__( transport_mode=self.transport_mode, ) - def callback(self, request: SIP.SIPMessage) -> Optional[str]: + def callback( + self, conn: VoIPConnection, request: SIP.SIPMessage + ) -> Optional[str]: # debug("Callback: "+request.summary()) if request.type == pyVoIP.SIP.SIPMessageType.REQUEST: # debug("This is a message") if request.method == "INVITE": - self._callback_MSG_Invite(request) + self._callback_MSG_Invite(conn, request) elif request.method == "BYE": self._callback_MSG_Bye(request) elif request.method == "OPTIONS": @@ -131,23 +134,13 @@ def callback(self, request: SIP.SIPMessage) -> Optional[str]: def get_status(self) -> PhoneStatus: return self._status - def _callback_MSG_Invite(self, request: SIP.SIPMessage) -> None: + def _callback_MSG_Invite( + self, conn: VoIPConnection, request: SIP.SIPMessage + ) -> None: call_id = request.headers["Call-ID"] - if call_id in self.calls: - debug("Re-negotiation detected!") - # TODO: this seems "dangerous" if for some reason sip server - # handles 2 and more bindings it will cause duplicate RTP-Clients - # to spawn. - - # CallState.Ringing seems important here to prevent multiple - # answering and RTP-Client spawning. Find out when renegotiation - # is relevant. - if self.calls[call_id].state != CallState.RINGING: - self.calls[call_id].renegotiate(request) - return # Raise Error if self.callClass is None: message = self.sip.gen_busy(request) - self.sip.sendto(message, request.headers["Via"][0]["address"]) + conn.send(message) else: debug("New call!") sess_id = None @@ -157,8 +150,8 @@ def _callback_MSG_Invite(self, request: SIP.SIPMessage) -> None: self.session_ids.append(proposed) sess_id = proposed message = self.sip.gen_ringing(request) - self.sip.sendto(message, request.headers["Via"][0]["address"]) - call = self._create_Call(request, sess_id) + conn.send(message) + call = self._create_call(conn, request, sess_id) try: t = Timer(1, call.ringing, [request]) t.name = f"Phone Call: {call_id}" @@ -167,9 +160,8 @@ def _callback_MSG_Invite(self, request: SIP.SIPMessage) -> None: self.threadLookup[t] = call_id except Exception: message = self.sip.gen_busy(request) - self.sip.sendto( + conn.send( message, - request.headers["Via"][0]["address"], ) raise @@ -275,7 +267,9 @@ def _callback_RESP_Unavailable(self, request: SIP.SIPMessage) -> None: ack = self.sip.gen_ack(request) self.sip.sendto(ack) - def _create_Call(self, request: SIP.SIPMessage, sess_id: int) -> VoIPCall: + def _create_call( + self, conn: VoIPConnection, request: SIP.SIPMessage, sess_id: int + ) -> VoIPCall: """ Create VoIP call object. Should be separated to enable better subclassing. @@ -287,6 +281,7 @@ def _create_Call(self, request: SIP.SIPMessage, sess_id: int) -> VoIPCall: request, sess_id, self.bind_ip, + conn=conn, sendmode=self.recvmode, ) return self.calls[call_id] diff --git a/pyVoIP/sock/sock.py b/pyVoIP/sock/sock.py index d682c78..8a74833 100644 --- a/pyVoIP/sock/sock.py +++ b/pyVoIP/sock/sock.py @@ -1,5 +1,4 @@ -from typing import List, Optional, Tuple, Union -from pyVoIP import SIP_STATE_DB_LOCATION +from typing import TYPE_CHECKING, List, Optional, Tuple, Union from pyVoIP.types import KEY_PASSWORD, SOCKETS from pyVoIP.SIP import SIPMessage, SIPMessageType from pyVoIP.SIP.error import SIPParseError @@ -16,6 +15,10 @@ import time +if TYPE_CHECKING: + from pyVoIP.SIP.client import SIPClient + + debug = pyVoIP.debug @@ -38,6 +41,7 @@ def __init__( self.local_tag, self.remote_tag = self.sock.determine_tags( self.message ) + self._peak_buffer: Optional[bytes] = None if conn and message.type == SIPMessageType.REQUEST: if self.sock.mode.tls_mode: client_context = ssl.create_default_context() @@ -66,85 +70,80 @@ def send(self, data: Union[bytes, str]) -> None: self.conn.send(data) debug(f"SENT:\n{msg.summary()}") - def __find_remote_tag(self) -> None: - if self.remote_tag is not None: - return - conn = self.sock.buffer.cursor() - result = conn.execute( - """SELECT "remote_tag" FROM "listening" WHERE - "call_id" = ? - AND "local_tag" = ?""", - (self.call_id, self.local_tag), - ) - rows = result.fetchall() - if rows: - # print(f"Found remote: {rows[0][0]}") - self.remote_tag = rows[0][0] + def update_tags(self, local_tag: str, remote_tag: str) -> None: + self.local_tag = local_tag + self.remote_tag = remote_tag - def recv(self, nbytes: int, timeout=0) -> bytes: - timeout = time.monotonic() + timeout if timeout else math.inf - if self.conn: - # TODO: Timeout - msg = None - while not msg and not self.sock.SD: - data = self.conn.recv(nbytes) - try: - msg = SIPMessage(data) - except SIPParseError as e: - br = self.sock.gen_bad_request( - connection=self, error=e, received=data - ) - self.send(br) - if time.monotonic() >= timeout: - raise TimeoutError() - debug(f"RECEIVED:\n{msg.summary()}") + def peak(self) -> bytes: + return self.recv(8192, timeout=60, peak=True) + + def recv(self, nbytes: int, timeout=0, peak=False) -> bytes: + if self._peak_buffer: + data = self._peak_buffer + if not peak: + self._peak_buffer = None return data + if self.conn: + return self._tcp_tls_recv(nbytes, timeout, peak) else: - self.__find_remote_tag() - while time.monotonic() <= timeout and not self.sock.SD: - # print("Trying to receive") - # print(self.sock.get_database_dump()) - conn = self.sock.buffer.cursor() - conn.row_factory = sqlite3.Row - sql = ( - 'SELECT * FROM "msgs" WHERE "call_id"=? AND "local_tag"=?' - ) - if self.remote_tag: - sql += ( - ' UNION SELECT * FROM "msgs" WHERE "call_id"=? AND ' - + '"local_tag"=? AND "remote_tag"=?' - ) - bindings = ( - ( - self.call_id, - self.local_tag, - self.call_id, - self.local_tag, - self.remote_tag, - ) - if self.remote_tag - else (self.call_id, self.local_tag) + return self._udp_recv(nbytes, timeout, peak) + + def _tcp_tls_recv(self, nbytes: int, timeout=0, peak=False) -> bytes: + # TODO: Timeout + msg = None + while not msg and not self.sock.SD: + data = self.conn.recv(nbytes) + try: + msg = SIPMessage(data) + except SIPParseError as e: + br = self.sock.gen_bad_request( + connection=self, error=e, received=data ) - result = conn.execute(sql, bindings) - row = result.fetchone() - if not row: - conn.close() - continue - try: - self.sock.buffer.commit() - conn.execute( - 'DELETE FROM "msgs" WHERE "id" = ?', (row["id"],) - ) - self.sock.buffer.commit() - except sqlite3.OperationalError: - pass + self.send(br) + if time.monotonic() >= timeout: + raise TimeoutError() + if peak: + self._peak_buffer = data + debug(f"RECEIVED:\n{msg.summary()}") + return data + + def _udp_recv(self, nbytes: int, timeout=0, peak=False) -> bytes: + timeout = time.monotonic() + timeout if timeout else math.inf + while time.monotonic() <= timeout and not self.sock.SD: + # print("Trying to receive") + # print(self.sock.get_database_dump()) + conn = self.sock.buffer.cursor() + conn.row_factory = sqlite3.Row + sql = ( + 'SELECT * FROM "msgs" WHERE "call_id"=? AND ' + + '"local_tag" IS ? AND "remote_tag" IS ?' + ) + bindings = ( + self.call_id, + self.local_tag, + self.remote_tag, + ) + result = conn.execute(sql, bindings) + row = result.fetchone() + if not row: + conn.close() + continue + if peak: + # If peaking, return before deleting from the database conn.close() return row["msg"].encode("utf8") - if time.monotonic() >= timeout: - raise TimeoutError() + try: + self.sock.buffer.commit() + conn.execute('DELETE FROM "msgs" WHERE "id" = ?', (row["id"],)) + self.sock.buffer.commit() + except sqlite3.OperationalError: + pass + conn.close() + return row["msg"].encode("utf8") + if time.monotonic() >= timeout: + raise TimeoutError() def close(self): - self.__find_remote_tag() self.sock.deregister_connection(self) if self.conn: self.conn.close() @@ -156,7 +155,7 @@ def __init__( mode: TransportMode, bind_ip: str, bind_port: int, - nat: NAT, + sip: "SIPClient", cert_file: Optional[str] = None, key_file: Optional[str] = None, key_password: KEY_PASSWORD = None, @@ -170,7 +169,8 @@ def __init__( self.s = socket.socket(socket.AF_INET, mode.socket_type) self.bind_ip: str = bind_ip self.bind_port: int = bind_port - self.nat = nat + self.sip = sip + self.nat: NAT = sip.nat self.server_context: Optional[ssl.SSLContext] = None if mode.tls_mode: self.server_context = ssl.SSLContext( @@ -184,7 +184,7 @@ def __init__( self.s = self.server_context.wrap_socket(self.s, server_side=True) self.buffer = sqlite3.connect( - SIP_STATE_DB_LOCATION, check_same_thread=False + pyVoIP.SIP_STATE_DB_LOCATION, check_same_thread=False ) """ RFC 3261 Section 12, Paragraph 2 states: @@ -209,7 +209,12 @@ def __init__( );""" ) conn.execute( - """CREATE INDEX "msg_index" ON msgs ("call_id", "local_tag", "remote_tag");""" + """CREATE INDEX "msg_index" ON msgs """ + + """("call_id", "local_tag", "remote_tag");""" + ) + conn.execute( + """CREATE INDEX "msg_index_2" ON msgs """ + + """("call_id", "remote_tag", "local_tag");""" ) conn.execute( """CREATE TABLE "listening" ( @@ -271,12 +276,13 @@ def __get_connection( sql = 'UPDATE "listening" SET "remote_tag" = ?, ' sql += '"local_tag" = ? WHERE "connection" = ?' conn.execute(sql, (remote_tag, local_tag, rows[0][0])) + self.conns[rows[0][0]].update_tags(local_tag, remote_tag) conn.close() return self.conns[rows[0][0]] conn.close() return None - def __register_connection(self, connection: VoIPConnection) -> None: + def __register_connection(self, connection: VoIPConnection) -> int: self.conns_lock.acquire() self.conns.append(connection) conn_id = len(self.conns) - 1 @@ -311,6 +317,7 @@ def __register_connection(self, connection: VoIPConnection) -> None: finally: conn.close() self.conns_lock.release() + return conn_id def deregister_connection(self, connection: VoIPConnection) -> None: if self.mode is not TransportMode.UDP: @@ -424,8 +431,11 @@ def _udp_run(self) -> None: def _handle_incoming_message( self, conn: Optional[SOCKETS], message: SIPMessage ): + conn_id = None if not self.__connection_exists(message): - self.__register_connection(VoIPConnection(self, conn, message)) + conn_id = self.__register_connection( + VoIPConnection(self, conn, message) + ) call_id = message.headers["Call-ID"] local_tag, remote_tag = self.determine_tags(message) @@ -441,6 +451,8 @@ def _handle_incoming_message( except sqlite3.OperationalError: pass conn.close() + if conn_id: + self.sip.handle_new_connection(self.conns[conn_id]) def run(self) -> None: self.bind((self.bind_ip, self.bind_port)) From a215093793e081f3c54eb909a8f2a1df171254de Mon Sep 17 00:00:00 2001 From: TJ Porter <taylerporter@gmail.com> Date: Fri, 29 Dec 2023 23:44:44 -0600 Subject: [PATCH 12/13] [FIX] Changed sqlite3 to use autocommit mode which fixed random errors --- pyVoIP/sock/sock.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/pyVoIP/sock/sock.py b/pyVoIP/sock/sock.py index 8a74833..bb2cd4f 100644 --- a/pyVoIP/sock/sock.py +++ b/pyVoIP/sock/sock.py @@ -133,9 +133,7 @@ def _udp_recv(self, nbytes: int, timeout=0, peak=False) -> bytes: conn.close() return row["msg"].encode("utf8") try: - self.sock.buffer.commit() conn.execute('DELETE FROM "msgs" WHERE "id" = ?', (row["id"],)) - self.sock.buffer.commit() except sqlite3.OperationalError: pass conn.close() @@ -184,7 +182,9 @@ def __init__( self.s = self.server_context.wrap_socket(self.s, server_side=True) self.buffer = sqlite3.connect( - pyVoIP.SIP_STATE_DB_LOCATION, check_same_thread=False + pyVoIP.SIP_STATE_DB_LOCATION, + isolation_level=None, + check_same_thread=False, ) """ RFC 3261 Section 12, Paragraph 2 states: @@ -225,10 +225,6 @@ def __init__( PRIMARY KEY("call_id", "local_tag", "remote_tag") );""" ) - try: - self.buffer.commit() - except sqlite3.OperationalError: - pass conn.close() self.conns_lock = threading.Lock() self.conns: List[VoIPConnection] = [] @@ -300,7 +296,6 @@ def __register_connection(self, connection: VoIPConnection) -> int: conn_id, ), ) - self.buffer.commit() except sqlite3.IntegrityError as e: e.add_note( "Error is from registering connection for message: " @@ -348,7 +343,6 @@ def deregister_connection(self, connection: VoIPConnection) -> None: conn.execute( 'DELETE FROM "listening" WHERE "connection" = ?', (conn_id,) ) - self.buffer.commit() except sqlite3.OperationalError: pass finally: @@ -446,10 +440,6 @@ def _handle_incoming_message( + "VALUES (?, ?, ?, ?)", (call_id, local_tag, remote_tag, raw_message), ) - try: - self.buffer.commit() - except sqlite3.OperationalError: - pass conn.close() if conn_id: self.sip.handle_new_connection(self.conns[conn_id]) From 72cf17f67e267353f6e48ede198e5ec379d3d446 Mon Sep 17 00:00:00 2001 From: TJ Porter <taylerporter@gmail.com> Date: Sat, 30 Dec 2023 00:15:01 -0600 Subject: [PATCH 13/13] [FIX] Fixed issue causing all TCP/TLS communications to isntantly timeout. --- pyVoIP/sock/sock.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyVoIP/sock/sock.py b/pyVoIP/sock/sock.py index bb2cd4f..8a9f4ff 100644 --- a/pyVoIP/sock/sock.py +++ b/pyVoIP/sock/sock.py @@ -89,6 +89,7 @@ def recv(self, nbytes: int, timeout=0, peak=False) -> bytes: return self._udp_recv(nbytes, timeout, peak) def _tcp_tls_recv(self, nbytes: int, timeout=0, peak=False) -> bytes: + timeout = time.monotonic() + timeout if timeout else math.inf # TODO: Timeout msg = None while not msg and not self.sock.SD: