diff --git a/.coveragerc b/.coveragerc index bc72263..da7be25 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,7 +1,7 @@ [run] branch = True source = aiosocks, tests -omit = site-packages +omit = site-packages,aiosocks/test_utils.py [html] directory = coverage \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 414efe1..4ff1c7e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,7 +18,7 @@ install: - pip install --upgrade pip wheel - pip install --upgrade setuptools - pip install pip - - pip install flake8 + - pip install flake8==3.3.0 - pip install pyflakes==1.1.0 - pip install coverage - pip install pytest diff --git a/README.rst b/README.rst index 9ce7743..8757a04 100644 --- a/README.rst +++ b/README.rst @@ -15,7 +15,7 @@ SOCKS proxy client for asyncio and aiohttp Dependencies ------------ python 3.5+ -aiohttp 2.1+ +aiohttp 2.3.2+ Features -------- @@ -175,8 +175,10 @@ aiohttp usage proxy_auth=ba) as resp: if resp.status == 200: print(await resp.text()) - except aiohttp.ProxyConnectionError: + except aiohttp.ClientProxyConnectionError: # connection problem + except aiohttp.ClientConnectorError: + # ssl error, certificate error, etc except aiosocks.SocksError: # communication problem @@ -185,22 +187,3 @@ aiohttp usage loop = asyncio.get_event_loop() loop.run_until_complete(load_github_main()) loop.close() - -Proxy from environment -^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - import os - from aiosocks.connector import ProxyConnector, ProxyClientRequest - - os.environ['socks4_proxy'] = 'socks4://127.0.0.1:333' - # or - os.environ['socks5_proxy'] = 'socks5://127.0.0.1:444' - - conn = ProxyConnector() - - with aiohttp.ClientSession(connector=conn, request_class=ProxyClientRequest) as session: - async with session.get('http://github.com/', proxy_from_env=True) as resp: - if resp.status == 200: - print(await resp.text()) diff --git a/aiosocks/__init__.py b/aiosocks/__init__.py index 1726d82..a1246d1 100644 --- a/aiosocks/__init__.py +++ b/aiosocks/__init__.py @@ -8,7 +8,7 @@ from .helpers import ( ) from .protocols import Socks4Protocol, Socks5Protocol, DEFAULT_LIMIT -__version__ = '0.2.4' +__version__ = '0.2.5' __all__ = ('Socks4Protocol', 'Socks5Protocol', 'Socks4Auth', 'Socks5Auth', 'Socks4Addr', 'Socks5Addr', 'SocksError', @@ -79,7 +79,7 @@ async def create_connection(protocol_factory, proxy, proxy_auth, dst, *, try: await waiter - except: + except: # noqa transport.close() raise diff --git a/aiosocks/connector.py b/aiosocks/connector.py index 3e9f47d..afdaea8 100644 --- a/aiosocks/connector.py +++ b/aiosocks/connector.py @@ -1,30 +1,24 @@ try: import aiohttp from aiohttp.connector import sentinel + from aiohttp.client_exceptions import certificate_errors, ssl_errors except ImportError: raise ImportError('aiosocks.SocksConnector require aiohttp library') - -from yarl import URL -from urllib.request import getproxies - -from .errors import SocksError, SocksConnectionError +from .errors import SocksConnectionError from .helpers import Socks4Auth, Socks5Auth, Socks4Addr, Socks5Addr from . import create_connection __all__ = ('ProxyConnector', 'ProxyClientRequest') -class ProxyClientRequest(aiohttp.ClientRequest): - def update_proxy(self, proxy, proxy_auth, proxy_from_env): - if proxy_from_env and not proxy: - proxies = getproxies() +from distutils.version import StrictVersion - proxy_url = proxies.get(self.original_url.scheme) - if not proxy_url: - proxy_url = proxies.get('socks4') or proxies.get('socks5') +if StrictVersion(aiohttp.__version__) < StrictVersion('2.3.2'): + raise RuntimeError('aiosocks.connector depends on aiohttp 2.3.2+') - proxy = URL(proxy_url) if proxy_url else None +class ProxyClientRequest(aiohttp.ClientRequest): + def update_proxy(self, proxy, proxy_auth, proxy_headers): if proxy and proxy.scheme not in ['http', 'socks4', 'socks5']: raise ValueError( "Only http, socks4 and socks5 proxies are supported") @@ -41,9 +35,9 @@ class ProxyClientRequest(aiohttp.ClientRequest): not isinstance(proxy_auth, Socks5Auth): raise ValueError("proxy_auth must be None or Socks5Auth() " "tuple for socks5 proxy") - self.proxy = proxy self.proxy_auth = proxy_auth + self.proxy_headers = proxy_headers class ProxyConnector(aiohttp.TCPConnector): @@ -69,20 +63,41 @@ class ProxyConnector(aiohttp.TCPConnector): else: return await self._create_socks_connection(req) + async def _wrap_create_socks_connection(self, *args, req, **kwargs): + try: + return await create_connection(*args, **kwargs) + except certificate_errors as exc: + raise aiohttp.ClientConnectorCertificateError( + req.connection_key, exc) from exc + except ssl_errors as exc: + raise aiohttp.ClientConnectorSSLError( + req.connection_key, exc) from exc + except (OSError, SocksConnectionError) as exc: + raise aiohttp.ClientProxyConnectionError( + req.connection_key, exc) from exc + async def _create_socks_connection(self, req): - if req.ssl: - sslcontext = self.ssl_context - else: - sslcontext = None + sslcontext = self._get_ssl_context(req) + fingerprint, hashfunc = self._get_fingerprint_and_hashfunc(req) if not self._remote_resolve: - dst_hosts = list(await self._resolve_host(req.host, req.port)) - dst = dst_hosts[0]['host'], dst_hosts[0]['port'] + try: + dst_hosts = list(await self._resolve_host(req.host, req.port)) + dst = dst_hosts[0]['host'], dst_hosts[0]['port'] + except OSError as exc: + raise aiohttp.ClientConnectorError( + req.connection_key, exc) from exc else: dst = req.host, req.port - proxy_hosts = await self._resolve_host(req.proxy.host, req.proxy.port) - exc = None + try: + proxy_hosts = await self._resolve_host( + req.proxy.host, req.proxy.port) + except OSError as exc: + raise aiohttp.ClientConnectorError( + req.connection_key, exc) from exc + + last_exc = None for hinfo in proxy_hosts: if req.proxy.scheme == 'socks4': @@ -91,45 +106,37 @@ class ProxyConnector(aiohttp.TCPConnector): proxy = Socks5Addr(hinfo['host'], hinfo['port']) try: - transp, proto = await create_connection( + transp, proto = await self._wrap_create_socks_connection( self._factory, proxy, req.proxy_auth, dst, loop=self._loop, remote_resolve=self._remote_resolve, ssl=sslcontext, family=hinfo['family'], proto=hinfo['proto'], flags=hinfo['flags'], - local_addr=self._local_addr, + local_addr=self._local_addr, req=req, server_hostname=req.host if sslcontext else None) - - self._validate_ssl_fingerprint(transp, req.host, req.port) - return transp, proto - except (OSError, SocksError, SocksConnectionError) as e: - exc = e + except aiohttp.ClientConnectorError as exc: + last_exc = exc + continue + + has_cert = transp.get_extra_info('sslcontext') + if has_cert and fingerprint: + sock = transp.get_extra_info('socket') + if not hasattr(sock, 'getpeercert'): + # Workaround for asyncio 3.5.0 + # Starting from 3.5.1 version + # there is 'ssl_object' extra info in transport + sock = transp._ssl_protocol._sslpipe.ssl_object + # gives DER-encoded cert as a sequence of bytes (or None) + cert = sock.getpeercert(binary_form=True) + assert cert + got = hashfunc(cert).digest() + expected = fingerprint + if got != expected: + transp.close() + if not self._cleanup_closed_disabled: + self._cleanup_closed_transports.append(transp) + last_exc = aiohttp.ServerFingerprintMismatch( + expected, got, req.host, req.port) + continue + return transp, proto else: - if isinstance(exc, SocksConnectionError): - raise aiohttp.ClientProxyConnectionError(*exc.args) - if isinstance(exc, SocksError): - raise exc - else: - raise aiohttp.ClientOSError( - exc.errno, 'Can not connect to %s:%s [%s]' % - (req.host, req.port, exc.strerror)) from exc - - def _validate_ssl_fingerprint(self, transp, host, port): - has_cert = transp.get_extra_info('sslcontext') - if has_cert and self._fingerprint: - sock = transp.get_extra_info('socket') - if not hasattr(sock, 'getpeercert'): - # Workaround for asyncio 3.5.0 - # Starting from 3.5.1 version - # there is 'ssl_object' extra info in transport - sock = transp._ssl_protocol._sslpipe.ssl_object - # gives DER-encoded cert as a sequence of bytes (or None) - cert = sock.getpeercert(binary_form=True) - assert cert - got = self._hashfunc(cert).digest() - expected = self._fingerprint - if got != expected: - transp.close() - if not self._cleanup_closed_disabled: - self._cleanup_closed_transports.append(transp) - raise aiohttp.ServerFingerprintMismatch( - expected, got, host, port) + raise last_exc diff --git a/aiosocks/test_utils.py b/aiosocks/test_utils.py index 6facce7..5bb3e13 100644 --- a/aiosocks/test_utils.py +++ b/aiosocks/test_utils.py @@ -113,7 +113,7 @@ class FakeSocks4Srv: data.append(byte[0]) writer.write(byte) await writer.drain() - except: + except: # noqa break def factory(): diff --git a/tests/test_connector.py b/tests/test_connector.py index db52bc9..4df1b11 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -1,3 +1,5 @@ +import ssl + import aiosocks import aiohttp import pytest @@ -9,22 +11,21 @@ from aiosocks.connector import ProxyConnector, ProxyClientRequest from aiosocks.helpers import Socks4Auth, Socks5Auth -async def test_connect_proxy_ip(): +async def test_connect_proxy_ip(loop): tr, proto = mock.Mock(name='transport'), mock.Mock(name='protocol') with mock.patch('aiosocks.connector.create_connection', make_mocked_coro((tr, proto))): - loop_mock = mock.Mock() - loop_mock.getaddrinfo = make_mocked_coro( - [[0, 0, 0, 0, ['127.0.0.1', 1080]]]) + loop.getaddrinfo = make_mocked_coro( + [[0, 0, 0, 0, ['127.0.0.1', 1080]]]) req = ProxyClientRequest( - 'GET', URL('http://python.org'), loop=loop_mock, + 'GET', URL('http://python.org'), loop=loop, proxy=URL('socks5://proxy.org')) - connector = ProxyConnector(loop=loop_mock) + connector = ProxyConnector(loop=loop) conn = await connector.connect(req) - assert loop_mock.getaddrinfo.called + assert loop.getaddrinfo.called assert conn.protocol is proto conn.close() @@ -89,12 +90,32 @@ async def test_connect_locale_resolve(loop): conn.close() -async def test_proxy_connect_fail(loop): +@pytest.mark.parametrize('remote_resolve', [True, False]) +async def test_resolve_host_fail(loop, remote_resolve): + tr, proto = mock.Mock(name='transport'), mock.Mock(name='protocol') + + with mock.patch('aiosocks.connector.create_connection', + make_mocked_coro((tr, proto))): + req = ProxyClientRequest( + 'GET', URL('http://python.org'), loop=loop, + proxy=URL('socks5://proxy.example')) + connector = ProxyConnector(loop=loop, remote_resolve=remote_resolve) + connector._resolve_host = make_mocked_coro(raise_exception=OSError()) + + with pytest.raises(aiohttp.ClientConnectorError): + await connector.connect(req) + + +@pytest.mark.parametrize('exc', [ + (ssl.CertificateError, aiohttp.ClientConnectorCertificateError), + (ssl.SSLError, aiohttp.ClientConnectorSSLError), + (aiosocks.SocksConnectionError, aiohttp.ClientProxyConnectionError)]) +async def test_proxy_connect_fail(loop, exc): loop_mock = mock.Mock() loop_mock.getaddrinfo = make_mocked_coro( [[0, 0, 0, 0, ['127.0.0.1', 1080]]]) cc_coro = make_mocked_coro( - raise_exception=aiosocks.SocksConnectionError()) + raise_exception=exc[0]()) with mock.patch('aiosocks.connector.create_connection', cc_coro): req = ProxyClientRequest( @@ -102,7 +123,7 @@ async def test_proxy_connect_fail(loop): proxy=URL('socks5://127.0.0.1')) connector = ProxyConnector(loop=loop_mock) - with pytest.raises(aiohttp.ClientConnectionError): + with pytest.raises(exc[1]): await connector.connect(req) @@ -177,38 +198,3 @@ def test_proxy_client_request_invalid(loop): proxy=URL('socks5://proxy.org'), proxy_auth=Socks4Auth('l')) assert 'proxy_auth must be None or Socks5Auth() ' \ 'tuple for socks5 proxy' in str(cm) - - -def test_proxy_from_env_http(loop): - proxies = {'http': 'http://proxy.org'} - - with mock.patch('aiosocks.connector.getproxies', return_value=proxies): - req = ProxyClientRequest('GET', URL('http://python.org'), loop=loop) - req.update_proxy(None, None, True) - assert req.proxy == URL('http://proxy.org') - - req.original_url = URL('https://python.org') - req.update_proxy(None, None, True) - assert req.proxy is None - - proxies.update({'https': 'http://proxy.org', - 'socks4': 'socks4://127.0.0.1:33', - 'socks5': 'socks5://localhost:44'}) - req.update_proxy(None, None, True) - assert req.proxy == URL('http://proxy.org') - - -def test_proxy_from_env_socks(loop): - proxies = {'socks4': 'socks4://127.0.0.1:33', - 'socks5': 'socks5://localhost:44'} - - with mock.patch('aiosocks.connector.getproxies', return_value=proxies): - req = ProxyClientRequest('GET', URL('http://python.org'), loop=loop) - - req.update_proxy(None, None, True) - assert req.proxy == URL('socks4://127.0.0.1:33') - - del proxies['socks4'] - - req.update_proxy(None, None, True) - assert req.proxy == URL('socks5://localhost:44')