From e4eaecb1cdf492cc59612c78305f9b4f6414fef1 Mon Sep 17 00:00:00 2001 From: Serhii Dylda Date: Sun, 8 Nov 2020 05:58:10 +0100 Subject: [PATCH] Replace PySocks with aiosocks due to bugs (see #1192) related to blocking behaviour of PySocks on socket connection. Also small fixes and additional checks on local_addr parameter. Updated documentation. --- optional-requirements.txt | 1 - readthedocs/basic/signing-in.rst | 31 +++++- requirements.txt | 1 + telethon/client/telegrambaseclient.py | 12 +-- telethon/network/connection/connection.py | 119 ++++++++++++++-------- telethon_examples/print_messages.py | 2 +- telethon_examples/print_updates.py | 2 +- 7 files changed, 110 insertions(+), 58 deletions(-) diff --git a/optional-requirements.txt b/optional-requirements.txt index 00cd3324..8dbca111 100644 --- a/optional-requirements.txt +++ b/optional-requirements.txt @@ -1,4 +1,3 @@ cryptg -pysocks hachoir pillow diff --git a/readthedocs/basic/signing-in.rst b/readthedocs/basic/signing-in.rst index 562d6b14..3fae72de 100644 --- a/readthedocs/basic/signing-in.rst +++ b/readthedocs/basic/signing-in.rst @@ -117,7 +117,7 @@ Signing In behind a Proxy ========================= If you need to use a proxy to access Telegram, -you will need to `install PySocks`__ and then change: +you will need to `install aiosocks`__ and then change: .. code-block:: python @@ -127,15 +127,36 @@ with .. code-block:: python - TelegramClient('anon', api_id, api_hash, proxy=(socks.SOCKS5, '127.0.0.1', 4444)) + TelegramClient('anon', api_id, api_hash, proxy=("socks5", '127.0.0.1', 4444)) (of course, replacing the IP and port with the IP and port of the proxy). The ``proxy=`` argument should be a tuple, a list or a dict, -consisting of parameters described `in PySocks usage`__. +consisting of parameters described `in aiosocks usage`__. -.. __: https://github.com/Anorov/PySocks#installation -.. __: https://github.com/Anorov/PySocks#usage-1 +Example: + +* .. code-block:: python + + proxy = ('socks5', '1.1.1.1', 5555, 'foo', 'bar', True) + +* .. code-block:: python + + proxy = ['socks5', '1.1.1.1', 5555, 'foo', 'bar', True] + +* .. code-block:: python + + proxy = { + 'protocol': 'socks5', # (mandatory) protocol to use, default socks5, allowed values: 'socks5' (or 2), 'socks4' (or 1) + 'host': '1.1.1.1', # (mandatory) proxy IP address + 'port': 5555, # (mandatory) proxy port number + 'username': 'foo', # (optional) username if the proxy requires auth + 'password': 'bar', # (optional) password if the proxy requires auth + 'remote_resolve': True # (optional) whether to use remote or local resolve, default remote + } + +.. __: https://github.com/nibrag/aiosocks#installation +.. __: https://github.com/nibrag/aiosocks#usage Using MTProto Proxies diff --git a/requirements.txt b/requirements.txt index 2b650ec4..0fa60ff0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ +aiosocks pyaes rsa diff --git a/telethon/client/telegrambaseclient.py b/telethon/client/telegrambaseclient.py index 43aae812..57f4cd3b 100644 --- a/telethon/client/telegrambaseclient.py +++ b/telethon/client/telegrambaseclient.py @@ -107,11 +107,11 @@ class TelegramBaseClient(abc.ABC): An iterable consisting of the proxy info. If `connection` is one of `MTProxy`, then it should contain MTProxy credentials: ``('hostname', port, 'secret')``. Otherwise, it's meant to store - function parameters for PySocks, like ``(type, 'hostname', port)``. - See https://github.com/Anorov/PySocks#usage-1 for more. + function parameters for AioSocks, like ``(type, 'hostname', port)``. + See https://github.com/nibrag/aiosocks for more. - local_addr (`str`, optional): - Local host address used to bind the socket to locally. + local_addr (`str` | `tuple`, optional): + Local host address (and port, optionally) used to bind the socket to locally. You only need to use this if you have multiple network cards and want to use a specific one. @@ -220,10 +220,10 @@ class TelegramBaseClient(abc.ABC): connection: 'typing.Type[Connection]' = ConnectionTcpFull, use_ipv6: bool = False, proxy: typing.Union[tuple, dict] = None, - local_addr=None, + local_addr: typing.Union[tuple, str] = None, timeout: int = 10, request_retries: int = 5, - connection_retries: int =5, + connection_retries: int = 5, retry_delay: int = 1, auto_reconnect: bool = True, sequential_updates: bool = False, diff --git a/telethon/network/connection/connection.py b/telethon/network/connection/connection.py index e315a939..12d3836a 100644 --- a/telethon/network/connection/connection.py +++ b/telethon/network/connection/connection.py @@ -1,6 +1,5 @@ import abc import asyncio -import socket import sys try: @@ -11,6 +10,20 @@ except ImportError: from ...errors import InvalidChecksumError from ... import helpers +import aiosocks + +# For some reason, `aiosocks` internal errors are not inherited from +# builtin IOError (just from Exception). Instead of adding those +# in exceptions clauses everywhere through the code, we +# rather monkey-patch them in place. + +aiosocks.errors.SocksError = ConnectionError +aiosocks.errors.NoAcceptableAuthMethods = ConnectionError +aiosocks.errors.LoginAuthenticationFailed = ConnectionError +aiosocks.errors.InvalidServerVersion = ConnectionError +aiosocks.errors.InvalidServerReply = ConnectionError +aiosocks.errors.SocksConnectionError = ConnectionError + class Connection(abc.ABC): """ @@ -46,58 +59,76 @@ class Connection(abc.ABC): self._recv_queue = asyncio.Queue(1) async def _connect(self, timeout=None, ssl=None): - if not self._proxy: - if self._local_addr is not None: - local_addr = (self._local_addr, None) - else: - local_addr = None - self._reader, self._writer = await asyncio.wait_for( - asyncio.open_connection(self._ip, self._port, ssl=ssl, local_addr=local_addr), - timeout=timeout - ) + if self._local_addr is not None: + + # NOTE: If port is not specified, we use 0 port + # to notify the OS that port should be chosen randomly + # from the available ones. + + if isinstance(self._local_addr, tuple) and len(self._local_addr) == 2: + local_addr = self._local_addr + elif isinstance(self._local_addr, str): + local_addr = (self._local_addr, 0) + else: + raise ValueError("Unknown local address format: {}".format(self._local_addr)) else: - import socks - if ':' in self._ip: - mode, address = socket.AF_INET6, (self._ip, self._port, 0, 0) + local_addr = None + + if not self._proxy: + connect_coroutine = asyncio.open_connection( + host=self._ip, + port=self._port, + ssl=ssl, + local_addr=local_addr) + else: + + if isinstance(self._proxy, (tuple, list)): + proxy, auth, remote_resolve = self._parse_proxy(*self._proxy) + elif isinstance(self._proxy, dict): + proxy, auth, remote_resolve = self._parse_proxy(**self._proxy) else: - mode, address = socket.AF_INET, (self._ip, self._port) + raise ValueError("Unknown proxy format: {}".format(self._proxy.__class__.__name__)) - s = socks.socksocket(mode, socket.SOCK_STREAM) - if isinstance(self._proxy, dict): - s.set_proxy(**self._proxy) - else: - s.set_proxy(*self._proxy) - - s.settimeout(timeout) - if self._local_addr is not None: - s.bind((self._local_addr, None)) - await asyncio.wait_for( - asyncio.get_event_loop().sock_connect(s, address), - timeout=timeout - ) - if ssl: - if ssl_mod is None: - raise RuntimeError( - 'Cannot use proxy that requires SSL' - 'without the SSL module being available' - ) - - s = ssl_mod.wrap_socket( - s, - do_handshake_on_connect=True, - ssl_version=ssl_mod.PROTOCOL_SSLv23, - ciphers='ADH-AES256-SHA' - ) - - s.setblocking(False) - - self._reader, self._writer = await asyncio.open_connection(sock=s) + connect_coroutine = aiosocks.open_connection( + proxy=proxy, + proxy_auth=auth, + dst=(self._ip, self._port), + remote_resolve=remote_resolve, + ssl=ssl, + local_addr=local_addr) + self._reader, self._writer = await asyncio.wait_for(connect_coroutine, timeout=timeout) self._codec = self.packet_codec(self) self._init_conn() await self._writer.drain() + @staticmethod + def _parse_proxy(protocol, host, port, username=None, password=None, remote_resolve=True): + + proxy, auth = None, None + + if isinstance(protocol, str): + protocol = protocol.lower() + + # We do the check for numerical values here + # to be backwards compatible with PySocks proxy format, + # (since socks.SOCKS5 = 2 and socks.SOCKS4 = 1) + + if protocol == 'socks5' or protocol == 2: + proxy = aiosocks.Socks5Addr(host, port) + if username and password: + auth = aiosocks.Socks5Auth(username, password) + + elif protocol == 'socks4' or protocol == 1: + proxy = aiosocks.Socks4Addr(host, port) + if username: + auth = aiosocks.Socks4Auth(username) + else: + raise ValueError('Unsupported proxy protocol {}'.format(protocol)) + + return proxy, auth, remote_resolve + async def connect(self, timeout=None, ssl=None): """ Establishes a connection with the server. diff --git a/telethon_examples/print_messages.py b/telethon_examples/print_messages.py index 21aafc59..068a7203 100644 --- a/telethon_examples/print_messages.py +++ b/telethon_examples/print_messages.py @@ -22,7 +22,7 @@ def get_env(name, message, cast=str): session = os.environ.get('TG_SESSION', 'printer') api_id = get_env('TG_API_ID', 'Enter your API ID: ', int) api_hash = get_env('TG_API_HASH', 'Enter your API hash: ') -proxy = None # https://github.com/Anorov/PySocks +proxy = None # https://docs.telethon.dev/en/latest/basic/signing-in.html#signing-in-behind-a-proxy # Create and start the client so we can make requests (we don't here) client = TelegramClient(session, api_id, api_hash, proxy=proxy).start() diff --git a/telethon_examples/print_updates.py b/telethon_examples/print_updates.py index 48ade9d4..6a43e5ae 100755 --- a/telethon_examples/print_updates.py +++ b/telethon_examples/print_updates.py @@ -27,7 +27,7 @@ def get_env(name, message, cast=str): session = os.environ.get('TG_SESSION', 'printer') api_id = get_env('TG_API_ID', 'Enter your API ID: ', int) api_hash = get_env('TG_API_HASH', 'Enter your API hash: ') -proxy = None # https://github.com/Anorov/PySocks +proxy = None # https://docs.telethon.dev/en/latest/basic/signing-in.html#signing-in-behind-a-proxy # This is our update handler. It is called when a new update arrives.