├── .gitignore ├── LICENSE ├── README.rst ├── pynat.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | !/.gitignore 3 | __pycache__/ 4 | *.egg-info/ 5 | wip/ 6 | *build/ 7 | *dist/ 8 | MANIFEST 9 | *.bat 10 | update.sh 11 | changelog 12 | TODO 13 | *.so 14 | *.pyc 15 | *.pyd 16 | *.xcf 17 | standalone_build.py 18 | 19 | .DS_Store 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Ariel Antonitis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. |pypi| image:: https://img.shields.io/pypi/v/pynat.svg 2 | .. _pypi: https://pypi.python.org/pypi/pynat 3 | .. |license| image:: https://img.shields.io/github/license/arantonitis/pynat.svg 4 | .. _license: https://github.com/arantonitis/pynat/tree/master/LICENSE 5 | 6 | PyNAT 7 | ***** 8 | |pypi|_ |license|_ 9 | 10 | Discover external IP addresses and NAT topologies using STUN (Simple Traversal of UDP Through Network Address Translators). 11 | 12 | PyNAT follows `RFC 3489`_, and is inspired by a similar program for 13 | Python 2.x called PyStun_. PyNAT supports Python 2.7 and later. 14 | 15 | .. _RFC 3489: https://tools.ietf.org/html/rfc3489 16 | .. _PyStun: https://github.com/jtriley/pystun 17 | 18 | Installation 19 | ============ 20 | PyNAT requires Python 2.7 or later. 21 | 22 | From PyPI 23 | --------- 24 | Install PyNAT by running ``pip3 install pynat`` from the command line. 25 | 26 | .. note:: 27 | 28 | On some Linux systems, installation may require running pip with root permissions, or running ``pip3 install pynat --user``. The latter may require exporting `~/.local/bin` to PATH. 29 | 30 | From GitHub 31 | ----------- 32 | Clone or download the `git repo`_, navigate to the directory, and run:: 33 | 34 | python3 setup.py sdist 35 | cd dist 36 | pip3 install pynat-.tar.gz 37 | 38 | .. _git repo: https://github.com/arantonitis/pynat 39 | 40 | Usage 41 | ===== 42 | To get information about the network topology and external IP/port used, run ``pynat``:: 43 | 44 | Network type: UDP Firewall 45 | Internal address: 127.0.0.1:54320 46 | External address: 127.0.0.1:54320 47 | 48 | Run ``pynat -h`` or ``pynat --help`` for more options:: 49 | 50 | usage: pynat [-h] [--source_ip SOURCE_IP] [--source-port SOURCE_PORT] 51 | [--stun-host STUN_HOST] [--stun-port STUN_PORT] 52 | 53 | PyNAT v0.0.0 Discover external IP addresses and NAT topologies using STUN. 54 | Copyright (C) 2018 Ariel Antonitis. Licensed under the MIT License. 55 | 56 | optional arguments: 57 | -h, --help show this help message and exit 58 | --source_ip SOURCE_IP 59 | The source IPv4/IPv6 address to bind to. 60 | --source-port SOURCE_PORT 61 | The source port to bind to. 62 | --stun-host STUN_HOST 63 | The STUN host to use for queries. 64 | --stun-port STUN_PORT 65 | The port of the STUN host to use for queries. 66 | 67 | To use PyNAT inside a Python shell or project:: 68 | 69 | from pynat import get_ip_info 70 | topology, ext_ip, ext_port = get_ip_info() 71 | 72 | To also get information about the internal IP, if unknown:: 73 | 74 | topology, ext_ip, ext_port, int_ip = get_ip_info(include_internal=True) 75 | 76 | Development 77 | =========== 78 | PyNAT versioning functions on a ``MAJOR.MINOR.PATCH.[DEVELOP]`` model. Only stable, non development releases will be published to PyPI. Because PyNAT is still a beta project, the ``MAJOR`` increment will be 0. Minor increments represent new features. Patch increments represent problems fixed with existing features. 79 | -------------------------------------------------------------------------------- /pynat.py: -------------------------------------------------------------------------------- 1 | # PyNAT: Discover external IP addresses and NAT topologies using STUN. 2 | # Copyright (C) 2022 Ariel A. Licensed under the MIT License. 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy 5 | # of this software and associated documentation files (the "Software"), to deal 6 | # in the Software without restriction, including without limitation the rights 7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | # copies of the Software, and to permit persons to whom the Software is 9 | # furnished to do so, subject to the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be included in all 12 | # copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | # SOFTWARE. 21 | # 22 | # pynat.py 23 | """PyNAT v0.7.0 24 | 25 | Discover external IP addresses and NAT topologies using STUN. 26 | 27 | Copyright (C) 2022 Ariel A. Licensed under the MIT License. 28 | """ 29 | from six import text_type 30 | import sys 31 | import socket 32 | import random 33 | import codecs 34 | import argparse 35 | import ipaddress 36 | try: 37 | import secrets 38 | 39 | def randint(n): 40 | return secrets.randbits(n) 41 | except ImportError: 42 | def randint(n): 43 | return random.getrandbits(n) 44 | 45 | __version__ = '0.7.0' 46 | url = 'https://github.com/aarant/pynat' 47 | 48 | 49 | class PynatError(Exception): 50 | """ Raised when an error occurs during network discovery. """ 51 | 52 | 53 | # Non-NAT network topologies 54 | BLOCKED = 'Blocked' 55 | OPEN = 'Open' 56 | UDP_FIREWALL = 'UDP Firewall' 57 | # NAT topologies 58 | FULL_CONE = 'Full-cone NAT' 59 | RESTRICTED_CONE = 'Restricted-cone NAT' 60 | RESTRICTED_PORT = 'Restricted-port NAT' 61 | SYMMETRIC = 'Symmetric NAT' 62 | 63 | # Stun message types 64 | BIND_REQUEST_MSG = b'\x00\x01' 65 | BIND_RESPONSE_MSG = b'\x01\x01' 66 | MAGIC_COOKIE = b'\x21\x12\xA4\x42' 67 | 68 | # Stun attributes 69 | MAPPED_ADDRESS = b'\x00\x01' 70 | RESPONSE_ADDRESS = b'\x00\x02' 71 | CHANGE_REQUEST = b'\x00\x03' 72 | SOURCE_ADDRESS = b'\x00\x04' 73 | CHANGED_ADDRESS = b'\x00\x05' 74 | XOR_MAPPED_ADDRESS = b'\x00\x20' 75 | 76 | # List of classic STUN servers 77 | STUN_SERVERS = [('stun.ekiga.net', 3478), ('stun.ideasip.com', 3478), ('stun.voiparound.com', 3478), 78 | ('stun.voipbuster.com', 3478), ('stun.voipstunt.com', 3478), ('stun.voxgratia.org', 3478)] 79 | 80 | 81 | def ORD(ch): # compatible to python3 82 | return ch if type(ch) == int else ord(ch) 83 | 84 | 85 | def long_to_bytes(n, length): # compatible to PY2 and PY3 86 | # Equivalent to n.to_bytes(length,byteorder='big') in Python 3 87 | return bytes(bytearray((n >> i*8) & 0xff for i in range(length-1, -1, -1))) 88 | 89 | 90 | # Get the family of an IP address 91 | def get_address_family(addr): 92 | try: 93 | ipaddress.IPv4Interface(text_type(addr)) 94 | return socket.AF_INET 95 | except ipaddress.AddressValueError: 96 | try: 97 | ipaddress.IPv6Interface(text_type(addr)) 98 | return socket.AF_INET6 99 | except ipaddress.AddressValueError: 100 | raise PynatError('Invalid IP address: %s' % addr) 101 | 102 | 103 | # Send a STUN message to a server, with optional extra data 104 | def send_stun_message(sock, addr, msg_type, trans_id=None, send_data=b''): 105 | if trans_id is None: 106 | trans_id = long_to_bytes(randint(128), 16) 107 | msg_len = long_to_bytes(len(send_data), 2) 108 | data = msg_type+msg_len+trans_id+send_data 109 | sock.sendto(data, addr) 110 | return trans_id 111 | 112 | 113 | # Get a STUN Binding response from a server, with optional extra data 114 | def get_stun_response(sock, addr, trans_id=None, send_data=b'', max_timeouts=6): 115 | timeouts = 0 116 | response = None 117 | old_timeout = sock.gettimeout() 118 | sock.settimeout(0.5) 119 | while timeouts < max_timeouts: 120 | try: 121 | trans_id = send_stun_message(sock, addr, BIND_REQUEST_MSG, trans_id, send_data) 122 | recv, addr = sock.recvfrom(2048) # TODO: Why 2048 123 | except socket.timeout: 124 | timeouts += 1 125 | continue 126 | else: 127 | # Too short, not a valid message 128 | if len(recv) < 20: 129 | continue 130 | msg_type, recv_trans_id, attrs = recv[:2], recv[4:20], recv[20:] 131 | msg_len = int(codecs.encode(recv[2:4], 'hex'), 16) 132 | if msg_len != len(attrs): 133 | continue 134 | if msg_type != BIND_RESPONSE_MSG: 135 | continue 136 | if recv_trans_id != trans_id: 137 | continue 138 | response = {} 139 | i = 0 140 | while i < msg_len: 141 | attr_type, attr_length = attrs[i:i+2], int(codecs.encode(attrs[i+2:i+4], 'hex'), 16) 142 | attr_value = attrs[i+4:i+4+attr_length] 143 | i += 4 + attr_length 144 | if attr_length % 4 != 0: # If not on a 32-bit boundary, add padding bytes 145 | i += 4 - (attr_length % 4) 146 | if attr_type in [MAPPED_ADDRESS, SOURCE_ADDRESS, CHANGED_ADDRESS]: 147 | family, port = ORD(attr_value[1]), int(codecs.encode(attr_value[2:4], 'hex'), 16) 148 | if family == 0x01: # IPv4 149 | ip = socket.inet_ntop(socket.AF_INET, attr_value[4:8]) 150 | if attr_type == XOR_MAPPED_ADDRESS: 151 | cookie_int = int(codecs.encode(MAGIC_COOKIE, 'hex'), 16) 152 | port ^= cookie_int >> 16 153 | ip = int(codecs.encode(attr_value[4:8], 'hex'), 16) ^ cookie_int 154 | ip = socket.inet_ntoa(long_to_bytes(ip, 4)) 155 | response['xor_ip'], response['xor_port'] = ip, port 156 | elif attr_type == MAPPED_ADDRESS: 157 | response['ext_ip'], response['ext_port'] = ip, port 158 | elif attr_type == SOURCE_ADDRESS: 159 | response['src_ip'], response['src_port'] = ip, port 160 | elif attr_type == CHANGED_ADDRESS: 161 | response['change_ip'], response['change_port'] = ip, port 162 | else: # family == 0x02: # IPv6 163 | ip = socket.inet_ntop(socket.AF_INET6, attr_value[4:20]) 164 | if attr_type == XOR_MAPPED_ADDRESS: 165 | cookie_int = int(codecs.encode(MAGIC_COOKIE, 'hex'), 16) 166 | port ^= cookie_int >> 16 167 | ip = int(codecs.encode(attr_value[4:20], 'hex'), 16) ^ (cookie_int << 96 | trans_id) 168 | ip = socket.inet_ntop(socket.AF_INET6, long_to_bytes(ip, 32)) 169 | response['xor_ip'], response['xor_port'] = ip, port 170 | elif attr_type == MAPPED_ADDRESS: 171 | response['ext_ip'], response['ext_port'] = ip, port 172 | elif attr_type == SOURCE_ADDRESS: 173 | response['src_ip'], response['src_port'] = ip, port 174 | elif attr_type == CHANGED_ADDRESS: 175 | response['change_ip'], response['change_port'] = ip, port 176 | # Prefer, when possible, to use XORed IPs and ports 177 | xor_ip, xor_port = response.get('xor_ip', None), response.get('xor_port', None) 178 | if xor_ip is not None: 179 | response['ext_ip'] = xor_ip 180 | if xor_port is not None: 181 | response['ext_port'] = xor_port 182 | break 183 | sock.settimeout(old_timeout) 184 | return response 185 | 186 | 187 | # Retrieve the internal working IPv4 used to access the Internet 188 | def get_internal_ipv4(test_addr=('8.8.8.8', 80)): # By default, queries Google's DNS 189 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 190 | sock.connect(test_addr) 191 | ip = sock.getsockname()[0] 192 | sock.close() 193 | return ip 194 | 195 | 196 | # Retrieve the internal working IPv6 used to access the Internet 197 | def get_internal_ipv6(test_addr=('2001:4860:4860::8888', 80)): # By default, queries Google's DNS 198 | sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) 199 | sock.connect(test_addr) 200 | ip = sock.getsockname()[0] 201 | sock.close() 202 | return ip 203 | 204 | 205 | # Get a STUN binding response from a server, without any CHANGE_REQUEST flags 206 | def stun_test_1(sock, addr): 207 | return get_stun_response(sock, addr) 208 | 209 | 210 | # Get a STUN binding response from a server, asking it to change both the IP & port from which it replies 211 | def stun_test_2(sock, addr): 212 | return get_stun_response(sock, addr, send_data=CHANGE_REQUEST + b'\x00\x04' + b'\x00\x00\x00\x06') 213 | 214 | 215 | # Get a STUN binding response from a server, asking it to change just the port from which it replies 216 | def stun_test_3(sock, addr): 217 | return get_stun_response(sock, addr, send_data=CHANGE_REQUEST + b'\x00\x04' + b'\x00\x00\x00\x02') 218 | 219 | 220 | # Find a working classic STUN server from the list 221 | def find_stun_server(sock): 222 | # Randomize the list so as to avoid using the same server twice in a row 223 | random.shuffle(STUN_SERVERS) 224 | for stun_addr in STUN_SERVERS: 225 | try: 226 | response = get_stun_response(sock, stun_addr, max_timeouts=1) 227 | except socket.gaierror: # Host not found error 228 | continue 229 | else: 230 | if response is not None: # Have found a working server 231 | return stun_addr 232 | return None 233 | 234 | 235 | # Get the network topology, external IP, and external port 236 | def get_ip_info(source_ip='0.0.0.0', source_port=54320, stun_host=None, stun_port=3478, include_internal=False, 237 | sock=None): 238 | """ Get information about the network topology, external IP, and external port. 239 | 240 | Args: 241 | source_ip (str, optional): If not '0.0.0.0', the internal IP address to bind to. Defaults to '0.0.0.0'. 242 | source_port (int, optional): Source port to bind to. Defaults to 54320. 243 | stun_host (str, optional): Address of the STUN host to use. Defaults to None, in which case one is selected. 244 | stun_port (int, optional): Port of the STUN host to query. Defaults to 3478. 245 | include_internal (bool, optional): Whether to include internal IP address information. Defaults to False. 246 | sock (socket.socket, optional): Bound socket to connect with. If not provided, one will be created. 247 | 248 | Returns: 249 | tuple: (topology, external_ip, external_port). Topology & external_ip are strings, external_port is int. 250 | If `include_internal` is True, returns (topology, external_ip, external_port, internal_ip) instead. 251 | """ 252 | # If no socket is passed in, create one and close it when done 253 | ephemeral_sock = sock is None 254 | if sock is None: 255 | family = get_address_family(source_ip) 256 | sock = socket.socket(family, socket.SOCK_DGRAM) 257 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 258 | sock.bind((source_ip, source_port)) 259 | # Find a stun host if none was selected 260 | if stun_host is None: 261 | stun_addr = find_stun_server(sock) 262 | # If None was found, assume the network is blocked 263 | if stun_addr is None: 264 | if ephemeral_sock: 265 | sock.close() 266 | if include_internal: 267 | return BLOCKED, None, None, None 268 | else: 269 | return BLOCKED, None, None 270 | # If a stun host was specified, set stun_addr 271 | else: 272 | stun_addr = (stun_host, stun_port) 273 | # Determine the actual local, or source IP 274 | if source_ip == '0.0.0.0': 275 | # IPv4 276 | source_ip = get_internal_ipv4(stun_addr) 277 | elif source_ip == '::': 278 | # IPv6 279 | source_ip = get_internal_ipv6(stun_addr) 280 | # Perform Test 1, a simple STUN Request 281 | response = stun_test_1(sock, stun_addr) 282 | # If the test fails, assume the network blocked 283 | if response is None: 284 | if ephemeral_sock: 285 | sock.close() 286 | if include_internal: 287 | return BLOCKED, None, None, None 288 | else: 289 | return BLOCKED, None, None 290 | # Otherwise the network is not blocked and we can continue 291 | ext_ip, ext_port = response['ext_ip'], response['ext_port'] 292 | change_addr = response.get('change_ip'), response.get('change_port') 293 | # Either Open Internet or a UDP firewall, do test 2 294 | if ext_ip == source_ip and ext_port == source_port: 295 | response = stun_test_2(sock, stun_addr) 296 | # Open Internet 297 | if response is not None: 298 | topology = OPEN 299 | # Symmetric UDP Firewall 300 | else: 301 | topology = UDP_FIREWALL 302 | # Some type of NAT, do test 2 303 | else: 304 | response = stun_test_2(sock, stun_addr) 305 | # Full-cone NAT 306 | if response is not None: 307 | topology = FULL_CONE 308 | # Some other type of NAT, do test 1 on a new ip 309 | else: 310 | response = stun_test_1(sock, change_addr) 311 | # This should never occur 312 | if response is None: 313 | if ephemeral_sock: 314 | sock.close() 315 | raise PynatError('Error querying STUN server with changed address.') 316 | # Symmetric, restricted cone, or restricted port NAT 317 | else: 318 | recv_ext_ip, recv_ext_port = response['ext_ip'], response['ext_port'] 319 | # Some type of restricted NAT, do test 3 to the change_addr with a CHANGE_REQUEST for the port 320 | if recv_ext_ip == ext_ip and recv_ext_port == ext_port: 321 | response = stun_test_3(sock, change_addr) 322 | # Restricted cone NAT 323 | if response is not None: 324 | topology = RESTRICTED_CONE 325 | # Restricted port NAT 326 | else: 327 | topology = RESTRICTED_PORT 328 | # Symmetric NAT 329 | else: 330 | topology = SYMMETRIC 331 | if ephemeral_sock: 332 | sock.close() 333 | if include_internal: 334 | return topology, ext_ip, ext_port, source_ip 335 | return topology, ext_ip, ext_port 336 | 337 | 338 | def main(): 339 | try: 340 | parser = argparse.ArgumentParser(prog='pynat', description=__doc__) 341 | parser.add_argument('--source_ip', help='The source IPv4/IPv6 address to bind to.', type=str, default='0.0.0.0') 342 | parser.add_argument('--source-port', help='The source port to bind to.', type=int, default=54320) 343 | parser.add_argument('--stun-host', help='The STUN host to use for queries.', type=str) 344 | parser.add_argument('--stun-port', help='The port of the STUN host to use for queries.', type=int, default=3478) 345 | args = parser.parse_args() 346 | source_ip, source_port, stun_host, stun_port = args.source_ip, args.source_port, args.stun_host, args.stun_port 347 | topology, ext_ip, ext_port, source_ip = get_ip_info(source_ip, source_port, stun_host, stun_port, True) 348 | print('Network type:', topology, '\nInternal address: %s:%s' % (source_ip, source_port), 349 | '\nExternal address: %s:%s' % (ext_ip, ext_port)) 350 | except KeyboardInterrupt: 351 | sys.exit() 352 | else: 353 | sys.exit(0) 354 | 355 | 356 | if __name__ == '__main__': 357 | main() 358 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # PyNAT: Discover external IP addresses and NAT topologies using STUN. 2 | # Copyright (C) 2022 Ariel A. Licensed under the MIT license. 3 | # 4 | # setup.py 5 | from setuptools import setup 6 | 7 | from pynat import __version__, url 8 | 9 | with open('README.rst', 'r') as f: 10 | long_description = f.read() 11 | 12 | setup(name='pynat', 13 | version=__version__, 14 | description='Discover external IP addresses and NAT topologies using STUN.', 15 | long_description=long_description, 16 | author='Ariel A', 17 | author_email='arant@mit.edu', 18 | url=url, 19 | py_modules=['pynat'], 20 | package_data={'*': ['README.rst']}, 21 | entry_points={'console_scripts': ['pynat = pynat:main']}, 22 | license='MIT', 23 | classifiers=['License :: OSI Approved :: MIT License', 24 | 'Development Status :: 4 - Beta', 25 | 'Intended Audience :: Developers', 26 | 'Topic :: System :: Networking :: Firewalls', 27 | 'Programming Language :: Python :: 2.7', 28 | 'Programming Language :: Python :: 3.2', 29 | 'Programming Language :: Python :: 3.3', 30 | 'Programming Language :: Python :: 3.4', 31 | 'Programming Language :: Python :: 3.5', 32 | 'Programming Language :: Python :: 3.6', 33 | 'Programming Language :: Python :: 3.7'], 34 | python_requires='>=2.7') 35 | --------------------------------------------------------------------------------