# Copyright (c) 2018, Neil Booth # # All rights reserved. # # The MIT License (MIT) # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """SOCKS proxying.""" import sys import asyncio import collections import ipaddress import socket import struct from functools import partial __all__ = ('SOCKSUserAuth', 'SOCKS4', 'SOCKS4a', 'SOCKS5', 'SOCKSProxy', 'SOCKSError', 'SOCKSProtocolError', 'SOCKSFailure') SOCKSUserAuth = collections.namedtuple("SOCKSUserAuth", "username password") class SOCKSError(Exception): """Base class for SOCKS exceptions. Each raised exception will be an instance of a derived class.""" class SOCKSProtocolError(SOCKSError): """Raised when the proxy does not follow the SOCKS protocol""" class SOCKSFailure(SOCKSError): """Raised when the proxy refuses or fails to make a connection""" class NeedData(Exception): pass class SOCKSBase(object): @classmethod def name(cls): return cls.__name__ def __init__(self): self._buffer = bytes() self._state = self._start def _read(self, size): if len(self._buffer) < size: raise NeedData(size - len(self._buffer)) result = self._buffer[:size] self._buffer = self._buffer[size:] return result def receive_data(self, data): self._buffer += data def next_message(self): return self._state() class SOCKS4(SOCKSBase): """SOCKS4 protocol wrapper.""" # See http://ftp.icm.edu.pl/packages/socks/socks4/SOCKS4.protocol REPLY_CODES = { 90: 'request granted', 91: 'request rejected or failed', 92: ('request rejected because SOCKS server cannot connect ' 'to identd on the client'), 93: ('request rejected because the client program and identd ' 'report different user-ids') } def __init__(self, dst_host, dst_port, auth): super().__init__() self._dst_host = self._check_host(dst_host) self._dst_port = dst_port self._auth = auth @classmethod def _check_host(cls, host): if not isinstance(host, ipaddress.IPv4Address): try: host = ipaddress.IPv4Address(host) except ValueError: raise SOCKSProtocolError( f'SOCKS4 requires an IPv4 address: {host}') from None return host def _start(self): self._state = self._first_response if isinstance(self._dst_host, ipaddress.IPv4Address): # SOCKS4 dst_ip_packed = self._dst_host.packed host_bytes = b'' else: # SOCKS4a dst_ip_packed = b'\0\0\0\1' host_bytes = self._dst_host.encode() + b'\0' if isinstance(self._auth, SOCKSUserAuth): user_id = self._auth.username.encode() else: user_id = b'' # Send TCP/IP stream CONNECT request return b''.join([b'\4\1', struct.pack('>H', self._dst_port), dst_ip_packed, user_id, b'\0', host_bytes]) def _first_response(self): # Wait for 8-byte response data = self._read(8) if data[0] != 0: raise SOCKSProtocolError(f'invalid {self.name()} proxy ' f'response: {data}') reply_code = data[1] if reply_code != 90: msg = self.REPLY_CODES.get( reply_code, f'unknown {self.name()} reply code {reply_code}') raise SOCKSFailure(f'{self.name()} proxy request failed: {msg}') # Other fields ignored return None class SOCKS4a(SOCKS4): @classmethod def _check_host(cls, host): if not isinstance(host, (str, ipaddress.IPv4Address)): raise SOCKSProtocolError( f'SOCKS4a requires an IPv4 address or host name: {host}') return host class SOCKS5(SOCKSBase): """SOCKS protocol wrapper.""" # See https://tools.ietf.org/html/rfc1928 ERROR_CODES = { 1: 'general SOCKS server failure', 2: 'connection not allowed by ruleset', 3: 'network unreachable', 4: 'host unreachable', 5: 'connection refused', 6: 'TTL expired', 7: 'command not supported', 8: 'address type not supported', } def __init__(self, dst_host, dst_port, auth): super().__init__() self._dst_bytes = self._destination_bytes(dst_host, dst_port) self._auth_bytes, self._auth_methods = self._authentication(auth) def _destination_bytes(self, host, port): if isinstance(host, ipaddress.IPv4Address): addr_bytes = b'\1' + host.packed elif isinstance(host, ipaddress.IPv6Address): addr_bytes = b'\4' + host.packed elif isinstance(host, str): host = host.encode() if len(host) > 255: raise SOCKSProtocolError(f'hostname too long: ' f'{len(host)} bytes') addr_bytes = b'\3' + bytes([len(host)]) + host else: raise SOCKSProtocolError(f'SOCKS5 requires an IPv4 address, IPv6 ' f'address, or host name: {host}') return addr_bytes + struct.pack('>H', port) def _authentication(self, auth): if isinstance(auth, SOCKSUserAuth): user_bytes = auth.username.encode() if not 0 < len(user_bytes) < 256: raise SOCKSProtocolError(f'username {auth.username} has ' f'invalid length {len(user_bytes)}') pwd_bytes = auth.password.encode() if not 0 < len(pwd_bytes) < 256: raise SOCKSProtocolError(f'password has invalid length ' f'{len(pwd_bytes)}') return b''.join([bytes([1, len(user_bytes)]), user_bytes, bytes([len(pwd_bytes)]), pwd_bytes]), [0, 2] return b'', [0] def _start(self): self._state = self._first_response return (b'\5' + bytes([len(self._auth_methods)]) + bytes(m for m in self._auth_methods)) def _first_response(self): # Wait for 2-byte response data = self._read(2) if data[0] != 5: raise SOCKSProtocolError(f'invalid SOCKS5 proxy response: {data}') if data[1] not in self._auth_methods: raise SOCKSFailure('SOCKS5 proxy rejected authentication methods') # Authenticate if user-password authentication if data[1] == 2: self._state = self._auth_response return self._auth_bytes return self._request_connection() def _auth_response(self): data = self._read(2) if data[0] != 1: raise SOCKSProtocolError(f'invalid SOCKS5 proxy auth ' f'response: {data}') if data[1] != 0: raise SOCKSFailure(f'SOCKS5 proxy auth failure code: ' f'{data[1]}') return self._request_connection() def _request_connection(self): # Send connection request self._state = self._connect_response return b'\5\1\0' + self._dst_bytes def _connect_response(self): data = self._read(5) if data[0] != 5 or data[2] != 0 or data[3] not in (1, 3, 4): raise SOCKSProtocolError(f'invalid SOCKS5 proxy response: {data}') if data[1] != 0: raise SOCKSFailure(self.ERROR_CODES.get( data[1], f'unknown SOCKS5 error code: {data[1]}')) if data[3] == 1: addr_len = 3 # IPv4 elif data[3] == 3: addr_len = data[4] # Hostname else: addr_len = 15 # IPv6 self._state = partial(self._connect_response_rest, addr_len) return self.next_message() def _connect_response_rest(self, addr_len): self._read(addr_len + 2) return None class SOCKSProxy(object): def __init__(self, address, protocol, auth): """A SOCKS proxy at an address following a SOCKS protocol. auth is an authentication method to use when connecting, or None. address is a (host, port) pair; for IPv6 it can instead be a (host, port, flowinfo, scopeid) 4-tuple. """ self.address = address self.protocol = protocol self.auth = auth # Set on each successful connection via the proxy to the # result of socket.getpeername() self.peername = None def __str__(self): auth = 'username' if self.auth else 'none' return f'{self.protocol.name()} proxy at {self.address}, auth: {auth}' async def _handshake(self, client, sock, loop): while True: count = 0 try: message = client.next_message() except NeedData as e: count = e.args[0] else: if message is None: return await loop.sock_sendall(sock, message) if count: data = await loop.sock_recv(sock, count) if not data: raise SOCKSProtocolError("EOF received") client.receive_data(data) async def _connect_one(self, host, port): """Connect to the proxy and perform a handshake requesting a connection to (host, port). Return the open socket on success, or the exception on failure. """ client = self.protocol(host, port, self.auth) sock = socket.socket() loop = asyncio.get_event_loop() try: # A non-blocking socket is required by loop socket methods sock.setblocking(False) await loop.sock_connect(sock, self.address) await self._handshake(client, sock, loop) self.peername = sock.getpeername() return sock except Exception as e: # Don't close - see https://github.com/kyuupichan/aiorpcX/issues/8 if sys.platform.startswith('linux') or sys.platform == "darwin": sock.close() return e async def _connect(self, addresses): """Connect to the proxy and perform a handshake requesting a connection to each address in addresses. Return an (open_socket, address) pair on success. """ assert len(addresses) > 0 exceptions = [] for address in addresses: host, port = address[:2] sock = await self._connect_one(host, port) if isinstance(sock, socket.socket): return sock, address exceptions.append(sock) strings = set(f'{exc!r}' for exc in exceptions) raise (exceptions[0] if len(strings) == 1 else OSError(f'multiple exceptions: {", ".join(strings)}')) async def _detect_proxy(self): """Return True if it appears we can connect to a SOCKS proxy, otherwise False. """ if self.protocol is SOCKS4a: host, port = 'www.apple.com', 80 else: host, port = ipaddress.IPv4Address('8.8.8.8'), 53 sock = await self._connect_one(host, port) if isinstance(sock, socket.socket): sock.close() return True # SOCKSFailure indicates something failed, but that we are # likely talking to a proxy return isinstance(sock, SOCKSFailure) @classmethod async def auto_detect_address(cls, address, auth): """Try to detect a SOCKS proxy at address using the authentication method (or None). SOCKS5, SOCKS4a and SOCKS are tried in order. If a SOCKS proxy is detected a SOCKSProxy object is returned. Returning a SOCKSProxy does not mean it is functioning - for example, it may have no network connectivity. If no proxy is detected return None. """ for protocol in (SOCKS5, SOCKS4a, SOCKS4): proxy = cls(address, protocol, auth) if await proxy._detect_proxy(): return proxy return None @classmethod async def auto_detect_host(cls, host, ports, auth): """Try to detect a SOCKS proxy on a host on one of the ports. Calls auto_detect for the ports in order. Returns SOCKS are tried in order; a SOCKSProxy object for the first detected proxy is returned. Returning a SOCKSProxy does not mean it is functioning - for example, it may have no network connectivity. If no proxy is detected return None. """ for port in ports: address = (host, port) proxy = await cls.auto_detect_address(address, auth) if proxy: return proxy return None async def create_connection(self, protocol_factory, host, port, *, resolve=False, ssl=None, family=0, proto=0, flags=0): """Set up a connection to (host, port) through the proxy. If resolve is True then host is resolved locally with getaddrinfo using family, proto and flags, otherwise the proxy is asked to resolve host. The function signature is similar to loop.create_connection() with the same result. The attribute _address is set on the protocol to the address of the successful remote connection. Additionally raises SOCKSError if something goes wrong with the proxy handshake. """ loop = asyncio.get_event_loop() if resolve: infos = await loop.getaddrinfo(host, port, family=family, type=socket.SOCK_STREAM, proto=proto, flags=flags) addresses = [info[4] for info in infos] else: addresses = [(host, port)] sock, address = await self._connect(addresses) def set_address(): protocol = protocol_factory() protocol._address = address return protocol return await loop.create_connection( set_address, sock=sock, ssl=ssl, server_hostname=host if ssl else None)