From f4b6d271c5786bf43d7566c591bafdbd423d1846 Mon Sep 17 00:00:00 2001 From: TJ Porter Date: Sun, 15 Oct 2023 18:41:27 -0500 Subject: [PATCH] [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";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.instance=" - + f'""\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=""'], ) 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";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.instance=" - + f'""\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=""'], ) 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";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.instance=" - + f'""\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=""'], ) 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"\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"\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: \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",