pax_global_header00006660000000000000000000000064145217205640014520gustar00rootroot0000000000000052 comment=296712db1275ec1aa150298ca9a1e292ae0fe1e6 tiny-proxy-0.2.1/000077500000000000000000000000001452172056400136625ustar00rootroot00000000000000tiny-proxy-0.2.1/.flake8000066400000000000000000000000611452172056400150320ustar00rootroot00000000000000[flake8] ignore = N805,W503 max-line-length = 99 tiny-proxy-0.2.1/.github/000077500000000000000000000000001452172056400152225ustar00rootroot00000000000000tiny-proxy-0.2.1/.github/workflows/000077500000000000000000000000001452172056400172575ustar00rootroot00000000000000tiny-proxy-0.2.1/.github/workflows/ci.yml000066400000000000000000000022211452172056400203720ustar00rootroot00000000000000name: CI on: push: branches: ["master"] pull_request: branches: ["master"] jobs: build: name: "Python ${{ matrix.python-version }} ${{ matrix.os }}" runs-on: "${{ matrix.os }}" strategy: matrix: python-version: ["3.7", "3.8", "3.9", "3.10"] os: [ubuntu-latest] steps: - name: Checkout uses: actions/checkout@v3 - name: Setup Python ${{ matrix.python-version }} uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip setuptools pip install -r requirements-dev.txt - name: Lint with flake8 run: | python -m flake8 tiny_proxy tests continue-on-error: true - name: Run tests # run: python -m pytest tests --cov=./tiny_proxy --cov-report term-missing -s run: python -m pytest tests --cov=./tiny_proxy --cov-report xml - name: Upload coverage uses: codecov/codecov-action@v1 with: file: ./coverage.xml flags: unit fail_ci_if_error: falsetiny-proxy-0.2.1/.gitignore000066400000000000000000000005421452172056400156530ustar00rootroot00000000000000*.bak *.egg *.egg-info *.eggs *.pyc *.pyd *.pyo *.so *.tar.gz *~ .DS_Store .Python .cache .coverage .coverage.* .idea .installed.cfg .noseids .tox .vimrc # bin build cover coverage develop-eggs dist docs/_build/ eggs include/ lib/ man/ nosetests.xml parts pyvenv sources var/* venv virtualenv.py .install-deps .develop .idea/ usage*.py logs coverage.xml tiny-proxy-0.2.1/LICENSE.txt000066400000000000000000000261351452172056400155140ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright {yyyy} {name of copyright owner} Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. tiny-proxy-0.2.1/MANIFEST.in000066400000000000000000000000571452172056400154220ustar00rootroot00000000000000# Include the license file include LICENSE.txt tiny-proxy-0.2.1/README.md000066400000000000000000000022301452172056400151360ustar00rootroot00000000000000## tiny-proxy [![CI](https://github.com/romis2012/tiny-proxy/actions/workflows/ci.yml/badge.svg)](https://github.com/romis2012/tiny-proxy/actions/workflows/ci.yml) [![Coverage Status](https://codecov.io/gh/romis2012/tiny-proxy/branch/master/graph/badge.svg)](https://codecov.io/gh/romis2012/tiny-proxy) [![PyPI version](https://badge.fury.io/py/tiny-proxy.svg)](https://pypi.python.org/pypi/tiny-proxy) Simple proxy (SOCKS4(a), SOCKS5(h), HTTP tunnel) server built with [anyio](https://github.com/agronholm/anyio). It is used for testing [python-socks](https://github.com/romis2012/python-socks), [aiohttp-socks](https://github.com/romis2012/aiohttp-socks) and [httpx-socks](https://github.com/romis2012/httpx-socks) packages. ## Requirements - Python >= 3.7 - anyio>=3.6.1 ## Installation ``` pip install tiny-proxy ``` ## Usage ```python import anyio from tiny_proxy import Socks5ProxyHandler async def main(): handler = Socks5ProxyHandler(username='user', password='password') listener = await anyio.create_tcp_listener(local_host='127.0.0.1', local_port=1080) await listener.serve(handler.handle) if __name__ == '__main__': anyio.run(main) ``` tiny-proxy-0.2.1/examples/000077500000000000000000000000001452172056400155005ustar00rootroot00000000000000tiny-proxy-0.2.1/examples/cert/000077500000000000000000000000001452172056400164355ustar00rootroot00000000000000tiny-proxy-0.2.1/examples/cert/ca.pem000066400000000000000000000012441452172056400175240ustar00rootroot00000000000000-----BEGIN CERTIFICATE----- MIIBxjCCAW2gAwIBAgIUbx4y7aqQjspnr35pfoaRNPypy2EwCgYIKoZIzj0EAwIw QDEXMBUGA1UECgwOdHJ1c3RtZSB2MS4wLjAxJTAjBgNVBAsMHFRlc3RpbmcgQ0Eg IzBUVG54eUpjQi1QdTZEQjQwHhcNMDAwMTAxMDAwMDAwWhcNMzgwMTAxMDAwMDAw WjBAMRcwFQYDVQQKDA50cnVzdG1lIHYxLjAuMDElMCMGA1UECwwcVGVzdGluZyBD QSAjMFRUbnh5SmNCLVB1NkRCNDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABPUa 3gNA8n5Xj3Ngctyf3CWcfgrWBwu4C4XZ8krpsQ2KpqDFLV6vOokrUB4q+Os6iWNj wDG1lJU8rOftfy+dX12jRTBDMB0GA1UdDgQWBBSvskpKJCYkw0Ncv83NykwhFqbF 6jASBgNVHRMBAf8ECDAGAQH/AgEJMA4GA1UdDwEB/wQEAwIBhjAKBggqhkjOPQQD AgNHADBEAiA/rY4Ez6UORYn7tiL/U/+m+PWiVgrr9/2rmJqODMfu6gIgP5pb4myx JI9KS9hoI7KmRlSGsxwuJIq/8m7CkP4vJzg= -----END CERTIFICATE----- tiny-proxy-0.2.1/examples/cert/server.key000066400000000000000000000003431452172056400204550ustar00rootroot00000000000000-----BEGIN EC PRIVATE KEY----- MHcCAQEEIJ/vfg6ib1g5als2ExElQOVhOAeC2MN4SLpVHpDWw6KZoAoGCCqGSM49 AwEHoUQDQgAEEQUN4N9ABP3h6FhGU38HePpTQSadsQHAF3jySsq7Gg9HHgsbZW6S fzoX7Exb3k2E+MCzKVpXUdJRJ2Rm/qbGxg== -----END EC PRIVATE KEY----- tiny-proxy-0.2.1/examples/cert/server.pem000066400000000000000000000015371452172056400204540ustar00rootroot00000000000000-----BEGIN CERTIFICATE----- MIICUTCCAfegAwIBAgIUJbm60pTlySmHOC8ipmyPdsdiUOMwCgYIKoZIzj0EAwIw QDEXMBUGA1UECgwOdHJ1c3RtZSB2MS4wLjAxJTAjBgNVBAsMHFRlc3RpbmcgQ0Eg IzBUVG54eUpjQi1QdTZEQjQwHhcNMDAwMTAxMDAwMDAwWhcNMzgwMTAxMDAwMDAw WjBCMRcwFQYDVQQKDA50cnVzdG1lIHYxLjAuMDEnMCUGA1UECwweVGVzdGluZyBj ZXJ0ICNPeEZSODNTcFRzYUpIbTNWMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE EQUN4N9ABP3h6FhGU38HePpTQSadsQHAF3jySsq7Gg9HHgsbZW6SfzoX7Exb3k2E +MCzKVpXUdJRJ2Rm/qbGxqOBzDCByTAdBgNVHQ4EFgQUx0HpXkHZdr7FT35TgyqM 5toVtMUwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBSvskpKJCYkw0Ncv83Nykwh FqbF6jA9BgNVHREBAf8EMzAxggx0ZXN0LnB3YnQucHeCCWxvY2FsaG9zdIcEfwAA AYcQAAAAAAAAAAAAAAAAAAAAATAOBgNVHQ8BAf8EBAMCBaAwKgYDVR0lAQH/BCAw HgYIKwYBBQUHAwIGCCsGAQUFBwMBBggrBgEFBQcDAzAKBggqhkjOPQQDAgNIADBF AiEAxPJOFqu419a366Pz8ZxrA9X4fuRWBp6ksnaClFQtEdoCIFvO5VwONePJ23tN a8InYQ42IFvnuj6QdTysnA+klzhL -----END CERTIFICATE----- tiny-proxy-0.2.1/examples/requirements.txt000066400000000000000000000000361452172056400207630ustar00rootroot00000000000000tiny-proxy>=0.2.0 PyYAML>=3.12tiny-proxy-0.2.1/examples/server.py000066400000000000000000000043721452172056400173660ustar00rootroot00000000000000import functools import logging import ssl import sys import time from typing import Tuple, Optional import anyio import yaml from anyio import create_tcp_listener, get_cancelled_exc_class, create_task_group from anyio.streams.tls import TLSListener from tiny_proxy import HttpProxyHandler, Socks4ProxyHandler, Socks5ProxyHandler CLS_MAP = { 'http': HttpProxyHandler, 'socks4': Socks4ProxyHandler, 'socks5': Socks5ProxyHandler, } logger = logging.getLogger(__name__) def configure_logging(): root_logger = logging.getLogger() root_logger.setLevel('INFO') fmt = '%(asctime)s [%(name)s:%(lineno)d] %(levelname)s : %(message)s' formatter = logging.Formatter(fmt) formatter.converter = time.gmtime stdout_handler = logging.StreamHandler(sys.stdout) stdout_handler.setFormatter(formatter) root_logger.addHandler(stdout_handler) def load_settings(file_name='./settings.yml') -> dict: with open(file_name, 'r') as f: return yaml.safe_load(f) async def serve( proxy_type: str, host: str, port: int, ssl_cert: Optional[Tuple[str, str]] = None, **kwargs, ): handler_cls = CLS_MAP.get(proxy_type) if not handler_cls: raise RuntimeError(f'Unsupported proxy type: {proxy_type}') if ssl_cert is not None: ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ssl_context.load_cert_chain(*ssl_cert) else: ssl_context = None logger.info(f'Starting {proxy_type} proxy on {host}:{port}...') handler = handler_cls(**kwargs) try: listener = await create_tcp_listener(local_host=host, local_port=port) if ssl_context is not None: listener = TLSListener(listener=listener, ssl_context=ssl_context) async with listener: await listener.serve(handler.handle) except get_cancelled_exc_class(): # noqa pass async def start_server(configs: list[dict]): async with create_task_group() as tg: for cfg in configs: tg.start_soon(functools.partial(serve, **cfg)) def main(): configure_logging() settings = load_settings() try: anyio.run(start_server, settings['proxies']) except (KeyboardInterrupt, SystemExit): pass if __name__ == '__main__': main() tiny-proxy-0.2.1/examples/settings.yml000066400000000000000000000006331452172056400200650ustar00rootroot00000000000000proxies: - proxy_type: socks5 host: 0.0.0.0 port: 7781 username: user password: password - proxy_type: socks4 host: 0.0.0.0 port: 7772 - proxy_type: http host: 0.0.0.0 port: 7770 username: user password: password - proxy_type: http host: 0.0.0.0 port: 7774 username: user password: password ssl_cert: ['./cert/server.pem', './cert/server.key']tiny-proxy-0.2.1/pyproject.toml000066400000000000000000000003411452172056400165740ustar00rootroot00000000000000[tool.black] line-length = 99 target-version = ['py37', 'py38', 'py39'] skip-string-normalization = true # experimental-string-processing = true preview = true verbose = true [tool.pytest.ini_options] asyncio_mode = 'strict'tiny-proxy-0.2.1/requirements-dev.txt000066400000000000000000000002661452172056400177260ustar00rootroot00000000000000# -r requirements.txt -e . aiohttp==3.8.1 # aiohttp-socks==0.7.1 httpx==0.24.1 httpx-socks==0.7.8 trustme==0.9.0 flake8==3.9.1 pytest==7.0.1 pytest-cov==3.0.0 pytest-asyncio==0.18.3 tiny-proxy-0.2.1/setup.py000066400000000000000000000022331452172056400153740ustar00rootroot00000000000000#!/usr/bin/env python import os import re import sys from setuptools import setup def get_version(): here = os.path.dirname(os.path.abspath(__file__)) filename = os.path.join(here, 'tiny_proxy', '__init__.py') contents = open(filename).read() pattern = r"^__version__ = '(.*?)'$" return re.search(pattern, contents, re.MULTILINE).group(1) def get_long_description(): with open('README.md', mode='r', encoding='utf8') as f: return f.read() if sys.version_info < (3, 7): raise RuntimeError('tiny-proxy requires Python 3.7+') setup( name='tiny_proxy', author='Roman Snegirev', author_email='snegiryev@gmail.com', version=get_version(), license='Apache 2', url='https://github.com/romis2012/tiny-proxy', description='Simple proxy server (SOCKS4(a), SOCKS5(h), HTTP tunnel)', long_description=get_long_description(), long_description_content_type='text/markdown', packages=[ 'tiny_proxy', 'tiny_proxy._proxy', 'tiny_proxy._handlers', ], keywords='socks socks5 socks4 http proxy server asyncio trio anyio', install_requires=[ 'anyio>=3.6.1,<5.0', ], ) tiny-proxy-0.2.1/tests/000077500000000000000000000000001452172056400150245ustar00rootroot00000000000000tiny-proxy-0.2.1/tests/__init__.py000066400000000000000000000000001452172056400171230ustar00rootroot00000000000000tiny-proxy-0.2.1/tests/config.py000066400000000000000000000030351452172056400166440ustar00rootroot00000000000000TEST_HTTP_HOST_IPV4 = '127.0.0.1' TEST_HTTP_PORT_IPV4 = 8881 TEST_HTTP_URL_IPV4 = f'http://{TEST_HTTP_HOST_IPV4}:{TEST_HTTP_PORT_IPV4}/' TEST_HTTPS_HOST_IPV4 = '127.0.0.1' TEST_HTTPS_PORT_IPV4 = 8882 TEST_HTTPS_URL_IPV4 = f'https://{TEST_HTTPS_HOST_IPV4}:{TEST_HTTPS_PORT_IPV4}/' TEST_HTTPS_HOST_IPV6 = '::1' TEST_HTTPS_PORT_IPV6 = 8883 TEST_HTTPS_URL_IPV6 = f'https://[{TEST_HTTPS_HOST_IPV6}]:{TEST_HTTPS_PORT_IPV6}/' PROXY_USERNAME = 'username' PROXY_PASSWORD = 'password' PROXY_HOST = '127.0.0.1' SOCKS5_PROXY_PORT = 7780 SOCKS5_PROXY_PORT_NO_AUTH = 7781 SOCKS4_PROXY_PORT = 7782 SOCKS4_PROXY_PORT_NO_AUTH = 7783 HTTP_PROXY_PORT = 7784 HTTP_PROXY_PORT_NO_AUTH = 7785 SOCKS5_PROXY_URL = 'socks5://{username}:{password}@{host}:{port}'.format( host=PROXY_HOST, port=SOCKS5_PROXY_PORT, username=PROXY_USERNAME, password=PROXY_PASSWORD, ) SOCKS5_PROXY_URL_NO_AUTH = 'socks5://{host}:{port}'.format( host=PROXY_HOST, port=SOCKS5_PROXY_PORT_NO_AUTH, ) SOCKS4_PROXY_URL = 'socks4://{username}:{password}@{host}:{port}'.format( host=PROXY_HOST, port=SOCKS4_PROXY_PORT, username=PROXY_USERNAME, password='', ) SOCKS4_PROXY_URL_NO_AUTH = 'socks4://{host}:{port}'.format( host=PROXY_HOST, port=SOCKS4_PROXY_PORT_NO_AUTH, ) HTTP_PROXY_URL = 'http://{username}:{password}@{host}:{port}'.format( host=PROXY_HOST, port=HTTP_PROXY_PORT, username=PROXY_USERNAME, password=PROXY_PASSWORD, ) HTTP_PROXY_URL_NO_AUTH = 'http://{host}:{port}'.format( host=PROXY_HOST, port=HTTP_PROXY_PORT_NO_AUTH, ) tiny-proxy-0.2.1/tests/conftest.py000066400000000000000000000101561452172056400172260ustar00rootroot00000000000000import ssl import pytest import trustme from tests.config import ( TEST_HTTP_HOST_IPV4, TEST_HTTP_PORT_IPV4, PROXY_HOST, SOCKS5_PROXY_PORT, PROXY_USERNAME, PROXY_PASSWORD, SOCKS5_PROXY_PORT_NO_AUTH, SOCKS4_PROXY_PORT, SOCKS4_PROXY_PORT_NO_AUTH, HTTP_PROXY_PORT, HTTP_PROXY_PORT_NO_AUTH, TEST_HTTPS_HOST_IPV4, TEST_HTTPS_PORT_IPV4, TEST_HTTPS_HOST_IPV6, TEST_HTTPS_PORT_IPV6, ) from tests.http_server import HttpServerConfig, HttpServer from tests.proxy_server import ProxyConfig, ProxyServerRunner from tests.utils import wait_until_connectable @pytest.fixture(scope='session') def ssl_ca() -> trustme.CA: return trustme.CA() @pytest.fixture(scope='session') def ssl_cert(ssl_ca: trustme.CA) -> trustme.LeafCert: return ssl_ca.issue_cert( "localhost", "127.0.0.1", "::1", ) @pytest.fixture(scope='session') def ssl_certfile(ssl_cert: trustme.LeafCert): with ssl_cert.cert_chain_pems[0].tempfile() as cert_path: yield cert_path @pytest.fixture(scope='session') def ssl_keyfile(ssl_cert: trustme.LeafCert): with ssl_cert.private_key_pem.tempfile() as private_key_path: yield private_key_path @pytest.fixture(scope='session') def ssl_key_and_cert_chain_file(ssl_cert: trustme.LeafCert): with ssl_cert.private_key_and_cert_chain_pem.tempfile() as path: yield path @pytest.fixture(scope='session') def ssl_ca_cert_file(ssl_ca: trustme.CA): with ssl_ca.cert_pem.tempfile() as ca_cert_pem: yield ca_cert_pem @pytest.fixture(scope='session') def server_ssl_context(ssl_cert: trustme.LeafCert) -> ssl.SSLContext: ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ssl_cert.configure_cert(ssl_ctx) return ssl_ctx @pytest.fixture(scope='session') def client_ssl_context(ssl_ca: trustme.CA, ssl_ca_cert_file) -> ssl.SSLContext: ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) ssl_ctx.verify_mode = ssl.CERT_REQUIRED ssl_ctx.check_hostname = True # ssl_ctx.load_verify_locations(ssl_ca_cert_file) ssl_ca.configure_trust(ssl_ctx) return ssl_ctx @pytest.fixture(scope='session', autouse=True) def web_server(ssl_certfile, ssl_keyfile): config = [ HttpServerConfig( host=TEST_HTTP_HOST_IPV4, port=TEST_HTTP_PORT_IPV4, ), HttpServerConfig( host=TEST_HTTPS_HOST_IPV4, port=TEST_HTTPS_PORT_IPV4, ssl_certfile=ssl_certfile, ssl_keyfile=ssl_keyfile, ), HttpServerConfig( host=TEST_HTTPS_HOST_IPV6, port=TEST_HTTPS_PORT_IPV6, ssl_certfile=ssl_certfile, ssl_keyfile=ssl_keyfile, ), ] server = HttpServer(config=config) server.run() for cfg in config: wait_until_connectable(host=cfg.host, port=cfg.port) yield None server.shutdown() @pytest.fixture(scope='session', autouse=True) def proxy_server(): config = [ ProxyConfig( proxy_type='socks5', host=PROXY_HOST, port=SOCKS5_PROXY_PORT, username=PROXY_USERNAME, password=PROXY_PASSWORD, ), ProxyConfig( proxy_type='socks5', host=PROXY_HOST, port=SOCKS5_PROXY_PORT_NO_AUTH, ), ProxyConfig( proxy_type='socks4', host=PROXY_HOST, port=SOCKS4_PROXY_PORT, username=PROXY_USERNAME, password=None, ), ProxyConfig( proxy_type='socks4', host=PROXY_HOST, port=SOCKS4_PROXY_PORT_NO_AUTH, ), ProxyConfig( proxy_type='http', host=PROXY_HOST, port=HTTP_PROXY_PORT, username=PROXY_USERNAME, password=PROXY_PASSWORD, ), ProxyConfig( proxy_type='http', host=PROXY_HOST, port=HTTP_PROXY_PORT_NO_AUTH, ), ] server = ProxyServerRunner(config=config) server.run() for cfg in config: wait_until_connectable(host=cfg.host, port=cfg.port) yield None server.shutdown() tiny-proxy-0.2.1/tests/http_app.py000066400000000000000000000011341452172056400172140ustar00rootroot00000000000000import ssl from aiohttp import web async def index(_): return web.Response(body='Index') app = web.Application() app.router.add_get('/', index) def run_app( host: str, port: int, ssl_certfile: str = None, ssl_keyfile: str = None, ): if ssl_certfile and ssl_keyfile: ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ssl_context.load_cert_chain(ssl_certfile, ssl_keyfile) else: ssl_context = None web.run_app( app, host=host, port=port, ssl_context=ssl_context, print=False, # type: ignore ) tiny-proxy-0.2.1/tests/http_server.py000066400000000000000000000015561452172056400177520ustar00rootroot00000000000000import typing from multiprocessing import Process from tests.http_app import run_app class HttpServerConfig(typing.NamedTuple): host: str port: int ssl_certfile: str = None ssl_keyfile: str = None def to_dict(self): d = {} for key, val in self._asdict().items(): if val is not None: d[key] = val return d class HttpServer: def __init__(self, config: typing.Iterable[HttpServerConfig]): self.config = config self.workers = [] def run(self): for cfg in self.config: print(f'Starting web server on {cfg.host}:{cfg.port}...') p = Process(target=run_app, kwargs=cfg.to_dict()) self.workers.append(p) for p in self.workers: p.start() def shutdown(self): for p in self.workers: p.terminate() tiny-proxy-0.2.1/tests/proxy_server.py000066400000000000000000000112161452172056400201460ustar00rootroot00000000000000import asyncio import logging import ssl import typing from contextlib import contextmanager from multiprocessing import Process from anyio import create_tcp_listener from anyio.streams.tls import TLSListener from tests.utils import cancel_all_tasks, cancel_tasks, wait_until_connectable from tiny_proxy import HttpProxyHandler, Socks5ProxyHandler, Socks4ProxyHandler class ProxyConfig(typing.NamedTuple): proxy_type: str host: str port: int username: typing.Optional[str] = None password: typing.Optional[str] = None ssl_certfile: typing.Optional[str] = None ssl_keyfile: typing.Optional[str] = None def to_dict(self): d = {} for key, val in self._asdict().items(): if val is not None: d[key] = val return d class ProxyServer: cls_map = { 'http': HttpProxyHandler, 'socks4': Socks4ProxyHandler, 'socks5': Socks5ProxyHandler, } def __init__(self, config: typing.Iterable[ProxyConfig], loop: asyncio.AbstractEventLoop): self.loop = loop self.config = config self.logger = logging.getLogger(__name__) self.server_tasks = [] def run(self): proxies = self.config for proxy in proxies: server_task = self.loop.create_task(self._listen(**proxy.to_dict())) self.server_tasks.append(server_task) def run_forever(self): self.run() self.loop.run_forever() def shutdown(self): print('Shutting down...') cancel_tasks(self.server_tasks, self.loop) cancel_all_tasks(self.loop) self.loop.run_until_complete(self.loop.shutdown_asyncgens()) try: self.loop.run_until_complete(self.loop.shutdown_default_executor()) except AttributeError: # pragma: no cover pass # shutdown_default_executor is new to Python 3.9 self.loop.close() def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): return self.shutdown() async def _listen( self, proxy_type, host, port, ssl_certfile=None, ssl_keyfile=None, **kwargs, ): handler_cls = self.cls_map.get(proxy_type) if not handler_cls: raise RuntimeError(f'Unsupported type: {proxy_type}') if ssl_certfile and ssl_keyfile: ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ssl_context.load_cert_chain(ssl_certfile, ssl_keyfile) else: ssl_context = None print(f'Starting {proxy_type} proxy on {host}:{port}...') handler = handler_cls(**kwargs) listener = await create_tcp_listener(local_host=host, local_port=port) if ssl_context is not None: listener = TLSListener(listener=listener, ssl_context=ssl_context) async with listener: await listener.serve(handler.handle) def _start_proxy_server(config: typing.Iterable[ProxyConfig]): import asyncio loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) server = ProxyServer(config=config, loop=loop) try: server.run_forever() except (KeyboardInterrupt, SystemExit): pass finally: server.shutdown() class ProxyServerRunner: def __init__(self, config: typing.Iterable[ProxyConfig]): self.config = config self.process = None def run(self): """ https://pytest-cov.readthedocs.io/en/latest/subprocess-support.html#if-you-use-multiprocessing-process or use Thread """ try: from pytest_cov.embed import cleanup_on_sigterm # noqa except ImportError: pass else: cleanup_on_sigterm() self.process = Process(target=_start_proxy_server, kwargs=dict(config=self.config)) self.process.daemon = True self.process.start() def shutdown(self): self.process.terminate() @contextmanager def start_proxy_server(config: ProxyConfig): """ https://pytest-cov.readthedocs.io/en/latest/subprocess-support.html#if-you-use-multiprocessing-process or use Thread """ try: from pytest_cov.embed import cleanup_on_sigterm # noqa except ImportError: pass else: cleanup_on_sigterm() process = Process(target=_start_proxy_server, kwargs=dict(config=[config])) # process = Thread(target=_start_proxy_server, kwargs=dict(config=[config])) process.daemon = True process.start() wait_until_connectable(host=config.host, port=config.port) try: yield None finally: process.terminate() process.join() pass tiny-proxy-0.2.1/tests/test_proxy.py000066400000000000000000000065431452172056400176260ustar00rootroot00000000000000""" python -m pytest tests --cov=./tiny_proxy --cov-report term-missing -s """ import ssl import httpx import pytest from httpx import Response from httpx_socks import AsyncProxyTransport from tests.config import ( SOCKS5_PROXY_URL, TEST_HTTPS_URL_IPV4, TEST_HTTPS_URL_IPV6, TEST_HTTP_URL_IPV4, SOCKS5_PROXY_URL_NO_AUTH, SOCKS4_PROXY_URL, SOCKS4_PROXY_URL_NO_AUTH, HTTP_PROXY_URL, HTTP_PROXY_URL_NO_AUTH, ) async def fetch( proxy_url: str, target_url: str, proxy_ssl: ssl.SSLContext = None, target_ssl: ssl.SSLContext = None, timeout: httpx.Timeout = None, **kwargs, ) -> Response: transport = AsyncProxyTransport.from_url( proxy_url, proxy_ssl=proxy_ssl, verify=target_ssl, **kwargs, ) async with httpx.AsyncClient(transport=transport) as client: res = await client.get(target_url, timeout=timeout) return res @pytest.mark.parametrize('url', (TEST_HTTP_URL_IPV4,)) @pytest.mark.parametrize('rdns', (True, False)) @pytest.mark.asyncio async def test_socks5_proxy_http(url, rdns): res = await fetch( proxy_url=SOCKS5_PROXY_URL, target_url=url, rdns=rdns, ) assert res.status_code == 200 @pytest.mark.parametrize('url', (TEST_HTTPS_URL_IPV4, TEST_HTTPS_URL_IPV6)) @pytest.mark.parametrize('rdns', (True, False)) @pytest.mark.asyncio async def test_socks5_proxy_https(client_ssl_context, url, rdns): res = await fetch( proxy_url=SOCKS5_PROXY_URL, target_url=url, target_ssl=client_ssl_context, rdns=rdns, ) assert res.status_code == 200 @pytest.mark.parametrize('url', (TEST_HTTPS_URL_IPV4, TEST_HTTPS_URL_IPV6)) @pytest.mark.parametrize('rdns', (True, False)) @pytest.mark.asyncio async def test_socks5_proxy_https_no_auth(client_ssl_context, url, rdns): res = await fetch( proxy_url=SOCKS5_PROXY_URL_NO_AUTH, target_url=url, target_ssl=client_ssl_context, rdns=rdns, ) assert res.status_code == 200 @pytest.mark.parametrize('url', (TEST_HTTPS_URL_IPV4,)) @pytest.mark.parametrize('rdns', (True, False)) @pytest.mark.asyncio async def test_socks4_proxy_https(client_ssl_context, url, rdns): res = await fetch( proxy_url=SOCKS4_PROXY_URL, target_url=url, target_ssl=client_ssl_context, rdns=rdns, ) assert res.status_code == 200 @pytest.mark.parametrize('url', (TEST_HTTPS_URL_IPV4,)) @pytest.mark.parametrize('rdns', (True, False)) @pytest.mark.asyncio async def test_socks4_proxy_https_no_auth(client_ssl_context, url, rdns): res = await fetch( proxy_url=SOCKS4_PROXY_URL_NO_AUTH, target_url=url, target_ssl=client_ssl_context, rdns=rdns, ) assert res.status_code == 200 @pytest.mark.parametrize('url', (TEST_HTTPS_URL_IPV4,)) @pytest.mark.asyncio async def test_http_proxy_https(client_ssl_context, url): res = await fetch( proxy_url=HTTP_PROXY_URL, target_url=url, target_ssl=client_ssl_context, ) assert res.status_code == 200 @pytest.mark.parametrize('url', (TEST_HTTPS_URL_IPV4,)) @pytest.mark.asyncio async def test_http_proxy_https_no_auth(client_ssl_context, url): res = await fetch( proxy_url=HTTP_PROXY_URL_NO_AUTH, target_url=url, target_ssl=client_ssl_context, ) assert res.status_code == 200 tiny-proxy-0.2.1/tests/utils.py000066400000000000000000000026751452172056400165500ustar00rootroot00000000000000import asyncio import socket import time from typing import Iterable def is_connectable(host, port): sock = None try: sock = socket.create_connection((host, port), timeout=1) except socket.error: return False else: return True finally: if sock is not None: sock.close() def wait_until_connectable(host, port, timeout=10): count = 0 while not is_connectable(host=host, port=port): if count >= timeout: raise Exception( f'The proxy server has not available by ({host}, {port}) in {timeout:d} seconds' ) count += 1 time.sleep(1) return True def cancel_tasks(tasks: Iterable[asyncio.Task], loop: asyncio.AbstractEventLoop): if not tasks: return for task in tasks: task.cancel() loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True)) for task in tasks: if task.cancelled(): continue if task.exception() is not None: loop.call_exception_handler( { "message": "unhandled exception during asyncio.run() shutdown", "exception": task.exception(), "task": task, } ) def cancel_all_tasks(loop: asyncio.AbstractEventLoop): tasks = [task for task in asyncio.all_tasks(loop) if not task.done()] cancel_tasks(tasks=tasks, loop=loop) tiny-proxy-0.2.1/tiny_proxy/000077500000000000000000000000001452172056400161065ustar00rootroot00000000000000tiny-proxy-0.2.1/tiny_proxy/__init__.py000066400000000000000000000012061452172056400202160ustar00rootroot00000000000000from ._errors import ProxyError from ._stream import SocketStream from ._tunnel import create_tunnel from ._proxy.abc import AbstractProxy from ._proxy.socks5 import Socks5Proxy from ._proxy.socks4 import Socks4Proxy from ._proxy.http import HttpProxy from ._handlers.http import HttpProxyHandler from ._handlers.socks4 import Socks4ProxyHandler from ._handlers.socks5 import Socks5ProxyHandler __version__ = '0.2.1' __all__ = ( 'ProxyError', 'SocketStream', 'create_tunnel', 'AbstractProxy', 'Socks5Proxy', 'Socks4Proxy', 'HttpProxy', 'HttpProxyHandler', 'Socks4ProxyHandler', 'Socks5ProxyHandler', ) tiny-proxy-0.2.1/tiny_proxy/_errors.py000066400000000000000000000000461452172056400201330ustar00rootroot00000000000000class ProxyError(Exception): pass tiny-proxy-0.2.1/tiny_proxy/_handlers/000077500000000000000000000000001452172056400200455ustar00rootroot00000000000000tiny-proxy-0.2.1/tiny_proxy/_handlers/__init__.py000066400000000000000000000000001452172056400221440ustar00rootroot00000000000000tiny-proxy-0.2.1/tiny_proxy/_handlers/base.py000066400000000000000000000024271452172056400213360ustar00rootroot00000000000000import logging from typing import Union import anyio import anyio.abc from anyio.streams.tls import TLSStream from .._stream import SocketStream from .._proxy.abc import AbstractProxy from .._tunnel import create_tunnel AnyioSocketStream = Union[anyio.abc.SocketStream, TLSStream] class BaseProxyHandler: logger: logging.Logger async def handle(self, stream: AnyioSocketStream): client = SocketStream(stream) proxy = self.create_proxy(client) try: remote = await proxy.connect_to_remote() except anyio.get_cancelled_exc_class(): # noqa await client.aclose() except Exception as e: await client.aclose() self.logger.error(e) self.logger.debug(e, exc_info=True) else: try: await create_tunnel(client, remote) except anyio.get_cancelled_exc_class(): # noqa # pragma: nocover pass except Exception as e: # pragma: nocover self.logger.error(e) self.logger.debug(e, exc_info=True) finally: await remote.aclose() await client.aclose() def create_proxy(self, stream: SocketStream) -> AbstractProxy: raise NotImplementedError() tiny-proxy-0.2.1/tiny_proxy/_handlers/http.py000066400000000000000000000011571452172056400214020ustar00rootroot00000000000000import logging from .base import BaseProxyHandler from .._proxy.abc import AbstractProxy from .._proxy.http import HttpProxy from .._stream import SocketStream class HttpProxyHandler(BaseProxyHandler): def __init__( self, username: str = None, password: str = None, ): self.username = username self.password = password self.logger = logging.getLogger(__name__) def create_proxy(self, stream: SocketStream) -> AbstractProxy: return HttpProxy( stream=stream, username=self.username, password=self.password, ) tiny-proxy-0.2.1/tiny_proxy/_handlers/socks4.py000066400000000000000000000007321452172056400216270ustar00rootroot00000000000000import logging from .base import BaseProxyHandler from .._proxy.abc import AbstractProxy from .._proxy.socks4 import Socks4Proxy from .._stream import SocketStream class Socks4ProxyHandler(BaseProxyHandler): def __init__(self, username: str = None): self.username = username self.logger = logging.getLogger(__name__) def create_proxy(self, stream: SocketStream) -> AbstractProxy: return Socks4Proxy(stream=stream, username=self.username) tiny-proxy-0.2.1/tiny_proxy/_handlers/socks5.py000066400000000000000000000011671452172056400216330ustar00rootroot00000000000000import logging from .base import BaseProxyHandler from .._proxy.abc import AbstractProxy from .._proxy.socks5 import Socks5Proxy from .._stream import SocketStream class Socks5ProxyHandler(BaseProxyHandler): def __init__( self, username: str = None, password: str = None, ): self.username = username self.password = password self.logger = logging.getLogger(__name__) def create_proxy(self, stream: SocketStream) -> AbstractProxy: return Socks5Proxy( stream=stream, username=self.username, password=self.password, ) tiny-proxy-0.2.1/tiny_proxy/_proxy/000077500000000000000000000000001452172056400174265ustar00rootroot00000000000000tiny-proxy-0.2.1/tiny_proxy/_proxy/__init__.py000066400000000000000000000000001452172056400215250ustar00rootroot00000000000000tiny-proxy-0.2.1/tiny_proxy/_proxy/abc.py000066400000000000000000000002251452172056400205240ustar00rootroot00000000000000from .._stream import SocketStream class AbstractProxy: async def connect_to_remote(self) -> SocketStream: raise NotImplementedError() tiny-proxy-0.2.1/tiny_proxy/_proxy/http.py000066400000000000000000000127231452172056400207640ustar00rootroot00000000000000import base64 import binascii import logging from collections import namedtuple from http.server import BaseHTTPRequestHandler from io import BytesIO from typing import Tuple import anyio import anyio.abc from .abc import AbstractProxy from .._errors import ProxyError from .._stream import SocketStream class HTTPRequest(BaseHTTPRequestHandler): """ https://stackoverflow.com/questions/4685217/parse-raw-http-headers """ # noinspection PyMissingConstructor def __init__(self, data: bytes): self.rfile = BytesIO(data) self.raw_requestline = self.rfile.readline() self.error_code = self.error_message = None self.parse_request() def send_error(self, code, message=None, explain=None): self.error_code = code class BasicAuth(namedtuple('BasicAuth', ['login', 'password', 'encoding'])): """Http basic authentication helper.""" def __new__(cls, login: str, password: str = '', encoding: str = 'latin1') -> 'BasicAuth': if login is None: raise ValueError('None is not allowed as login value') if password is None: raise ValueError('None is not allowed as password value') if ':' in login: raise ValueError('A ":" is not allowed in login (RFC 1945#section-11.1)') # noinspection PyTypeChecker,PyArgumentList return super().__new__(cls, login, password, encoding) @classmethod def decode(cls, auth_header: str, encoding: str = 'latin1') -> 'BasicAuth': """Create a BasicAuth object from an Authorization HTTP header.""" try: auth_type, encoded_credentials = auth_header.split(' ', 1) except ValueError: raise ValueError('Could not parse authorization header.') if auth_type.lower() != 'basic': raise ValueError('Unknown authorization method %s' % auth_type) try: decoded = base64.b64decode(encoded_credentials.encode('ascii'), validate=True).decode( encoding ) except binascii.Error: raise ValueError('Invalid base64 encoding.') try: # RFC 2617 HTTP Authentication # https://www.ietf.org/rfc/rfc2617.txt # the colon must be present, but the username and password may be # otherwise blank. username, password = decoded.split(':', 1) except ValueError: raise ValueError('Invalid credentials.') # noinspection PyTypeChecker return cls(username, password, encoding=encoding) def encode(self) -> str: """Encode credentials.""" creds = ('%s:%s' % (self.login, self.password)).encode(self.encoding) return 'Basic %s' % base64.b64encode(creds).decode(self.encoding) class HttpProxy(AbstractProxy): def __init__( self, stream: SocketStream, username: str = None, password: str = None, ): self.stream = stream self.username = username self.password = password self.logger = logging.getLogger(__name__) async def connect_to_remote(self) -> SocketStream: try: remote_host, remote_port = await self.negotiate() except ( anyio.EndOfStream, anyio.IncompleteRead, anyio.ClosedResourceError, anyio.BrokenResourceError, ) as e: raise ConnectionResetError( f'Connection reset by peer {self.stream.getpeername()}' ) from e local_addr = self.stream.getsockname() remote_addr = (remote_host, remote_port) self.logger.info('CONNECT {} -> {}'.format(local_addr, remote_addr)) try: stream = await anyio.connect_tcp(remote_host=remote_host, remote_port=remote_port) remote = SocketStream(stream) except OSError as e: self.logger.error(e) await self.respond(502, 'Bad Gateway', raise_exc=False) raise ProxyError(f"Couldn't connect to host {remote_host}:{remote_port}") from e else: await self.respond(200, 'Connection established') return remote async def negotiate(self) -> Tuple[str, int]: data = await self.stream.receive_until(b'\r\n\r\n', 4096) req = HTTPRequest(data) if req.error_code: await self.respond(int(req.error_code), req.error_message) if req.command is None or req.command.lower() != 'connect': self.logger.debug(repr(data)) await self.respond(400, 'Bad Request') if self.username and self.password: auth_header = req.headers['proxy-authorization'] if not auth_header: await self.respond(401, 'Unauthorized') try: auth = BasicAuth.decode(auth_header) except ValueError: await self.respond(401, 'Unauthorized') else: if auth.login != self.username or auth.password != self.password: await self.respond(401, 'Unauthorized') try: host, port = req.path.split(":") port = int(port) except ValueError: await self.respond(400, 'Bad Request') raise return host, port async def respond(self, code: int, message: str, raise_exc=True): res = f'HTTP/1.1 {code} {message}\r\n\r\n' await self.stream.send(res.encode('ascii')) if code != 200 and raise_exc: raise ProxyError(f'{code} {message}') tiny-proxy-0.2.1/tiny_proxy/_proxy/socks4.py000066400000000000000000000073571452172056400212220ustar00rootroot00000000000000import enum import logging import ipaddress from typing import Tuple import anyio import anyio.abc from .._stream import SocketStream from .._errors import ProxyError from .abc import AbstractProxy RSV = NULL = 0x00 SOCKS_VER4 = 0x04 class Command(enum.IntEnum): CONNECT = 0x01 BIND = 0x02 class ReplyCode(enum.IntEnum): REQUEST_GRANTED = 0x5A REQUEST_REJECTED_OR_FAILED = 0x5B CONNECTION_FAILED = 0x5C AUTHENTICATION_FAILED = 0x5D ReplyMessages = { ReplyCode.REQUEST_GRANTED: "Request granted", ReplyCode.REQUEST_REJECTED_OR_FAILED: "Request rejected or failed", ReplyCode.CONNECTION_FAILED: ( "Request rejected because SOCKS server cannot connect to identd on the client" ), ReplyCode.AUTHENTICATION_FAILED: ( "Request rejected because the client program and identd report different user-ids" ), } class Socks4Proxy(AbstractProxy): def __init__(self, stream: SocketStream, username: str = None): self.stream = stream self.username = username self.logger = logging.getLogger(__name__) async def connect_to_remote(self) -> SocketStream: try: remote_host, remote_port = await self.negotiate() except ( anyio.EndOfStream, anyio.IncompleteRead, anyio.ClosedResourceError, anyio.BrokenResourceError, ) as e: raise ConnectionResetError( f'Connection reset by peer {self.stream.getpeername()}' ) from e local_addr = self.stream.getsockname() remote_addr = (remote_host, remote_port) self.logger.info('CONNECT {} -> {}'.format(local_addr, remote_addr)) try: stream = await anyio.connect_tcp(remote_host=remote_host, remote_port=remote_port) remote = SocketStream(stream) except OSError as e: await self.respond(ReplyCode.CONNECTION_FAILED) raise ProxyError(f"Couldn't connect to host {remote_host}:{remote_port}") from e else: await self.respond(ReplyCode.REQUEST_GRANTED) return remote async def negotiate(self) -> Tuple[str, int]: version, command = await self.stream.receive_exactly(2) if version != SOCKS_VER4: await self.respond(ReplyCode.REQUEST_REJECTED_OR_FAILED) raise ProxyError("Invalid socks version") if command != Command.CONNECT: await self.respond(ReplyCode.REQUEST_REJECTED_OR_FAILED) raise ProxyError("Unsupported command") port = int.from_bytes(await self.stream.receive_exactly(2), "big") host_bytes = await self.stream.receive_exactly(4) include_hostname = host_bytes[:3] == bytes([NULL, NULL, NULL]) user = (await self.read_until_null()).decode("ascii") if self.username and self.username != user: await self.respond(ReplyCode.AUTHENTICATION_FAILED) raise ProxyError("Authentication failed") if include_hostname: host = (await self.read_until_null()).decode("ascii") else: host = str(ipaddress.IPv4Address(host_bytes)) return host, port async def read_until_null(self) -> bytes: data = bytearray() while True: byte = ord(await self.stream.receive_exactly(1)) if byte == NULL: break data.append(byte) return data async def respond(self, code: ReplyCode): await self.stream.send( bytes( [ RSV, code, NULL, NULL, NULL, NULL, NULL, NULL, ] ) ) tiny-proxy-0.2.1/tiny_proxy/_proxy/socks5.py000066400000000000000000000154241452172056400212150ustar00rootroot00000000000000import enum import ipaddress import logging import anyio import anyio.abc from .._stream import SocketStream from .._errors import ProxyError from .abc import AbstractProxy RSV = NULL = 0x00 SOCKS_VER5 = 0x05 SOCKS5_GRANTED = 0x00 class AuthMethod(enum.IntEnum): ANONYMOUS = 0x00 GSSAPI = 0x01 USERNAME_PASSWORD = 0x02 NO_ACCEPTABLE = 0xFF class AddressType(enum.IntEnum): IPV4 = 0x01 DOMAIN = 0x03 IPV6 = 0x04 @classmethod def from_ip_ver(cls, ver: int): if ver == 4: return cls.IPV4 if ver == 6: return cls.IPV6 raise ValueError('Invalid IP version') class Command(enum.IntEnum): CONNECT = 0x01 BIND = 0x02 UDP_ASSOCIATE = 0x03 class ReplyCode(enum.IntEnum): SUCCEEDED = 0x00 GENERAL_FAILURE = 0x01 CONNECTION_NOT_ALLOWED = 0x02 NETWORK_UNREACHABLE = 0x03 HOST_UNREACHABLE = 0x04 CONNECTION_REFUSED = 0x05 TTL_EXPIRED = 0x06 COMMAND_NOT_SUPPORTED = 0x07 ADDRESS_TYPE_NOT_SUPPORTED = 0x08 ReplyMessages = { ReplyCode.SUCCEEDED: 'Request granted', ReplyCode.GENERAL_FAILURE: 'General SOCKS server failure', ReplyCode.CONNECTION_NOT_ALLOWED: 'Connection not allowed by ruleset', ReplyCode.NETWORK_UNREACHABLE: 'Network unreachable', ReplyCode.HOST_UNREACHABLE: 'Host unreachable', ReplyCode.CONNECTION_REFUSED: 'Connection refused by destination host', ReplyCode.TTL_EXPIRED: 'TTL expired', ReplyCode.COMMAND_NOT_SUPPORTED: 'Command not supported or protocol error', ReplyCode.ADDRESS_TYPE_NOT_SUPPORTED: 'Address type not supported', } class Socks5Proxy(AbstractProxy): def __init__(self, stream: SocketStream, username=None, password=None): self.stream = stream self.username = username self.password = password self.logger = logging.getLogger(__name__) async def connect_to_remote(self) -> SocketStream: try: remote_host, remote_port = await self.negotiate() except ( anyio.EndOfStream, anyio.IncompleteRead, anyio.ClosedResourceError, anyio.BrokenResourceError, ) as e: raise ConnectionResetError( f'Connection reset by peer {self.stream.getpeername()}' ) from e local_addr = self.stream.getsockname() remote_addr = (remote_host, remote_port) self.logger.info('CONNECT {} -> {}'.format(local_addr, remote_addr)) try: # todo: add timeout? stream = await anyio.connect_tcp(remote_host=remote_host, remote_port=remote_port) remote = SocketStream(stream) except OSError as e: reply = bytes([SOCKS_VER5, ReplyCode.CONNECTION_REFUSED, NULL, NULL, NULL, NULL]) await self.stream.send(reply) raise ProxyError(f"Couldn't connect to host {remote_host}:{remote_port}") from e else: bind_address = remote.getsockname() bind_ip = ipaddress.ip_address(bind_address[0]) bind_port = bind_address[1] reply = bytearray( [ SOCKS_VER5, ReplyCode.SUCCEEDED, RSV, AddressType.from_ip_ver(bind_ip.version), ] ) reply += bind_ip.packed reply += bind_port.to_bytes(2, 'big') await self.stream.send(reply) return remote async def negotiate(self): auth_required = self.username and self.password # ------------------------AUTH METHODS--------------------- version, num_methods = await self.stream.receive_exactly(2) if version != SOCKS_VER5: await self.stream.send(bytes([NULL, NULL])) raise ProxyError('Unsupported socks version') methods = [] for i in range(num_methods): methods.append(ord(await self.stream.receive_exactly(1))) if auth_required: auth_method = ( AuthMethod.USERNAME_PASSWORD if AuthMethod.USERNAME_PASSWORD in methods else AuthMethod.NO_ACCEPTABLE ) else: auth_method = ( AuthMethod.ANONYMOUS if AuthMethod.ANONYMOUS in methods else AuthMethod.NO_ACCEPTABLE ) await self.stream.send(bytes([SOCKS_VER5, auth_method])) if auth_method == AuthMethod.NO_ACCEPTABLE: raise ProxyError('Not acceptable auth method') # -----------------------AUTH REQUEST----------------------- if auth_method == AuthMethod.USERNAME_PASSWORD: version = ord(await self.stream.receive_exactly(1)) if version != 1: await self.stream.send(bytes([version, 0xFF])) raise ProxyError('Invalid auth request') username_len = ord(await self.stream.receive_exactly(1)) username = (await self.stream.receive_exactly(username_len)).decode('utf-8') password_len = ord(await self.stream.receive_exactly(1)) password = (await self.stream.receive_exactly(password_len)).decode('utf-8') if username == self.username and password == self.password: await self.stream.send(bytes([version, SOCKS5_GRANTED])) else: await self.stream.send(bytes([version, 0xFF])) raise ProxyError('Authentication failed') # --------------------------CONNECT----------------------------- version, cmd, _, address_type = await self.stream.receive_exactly(4) if version != SOCKS_VER5: await self.stream.send(bytes([SOCKS_VER5, ReplyCode.GENERAL_FAILURE, RSV])) raise ProxyError(ReplyMessages[ReplyCode.GENERAL_FAILURE]) if cmd != Command.CONNECT: await self.stream.send(bytes([SOCKS_VER5, ReplyCode.COMMAND_NOT_SUPPORTED, RSV])) raise ProxyError(ReplyMessages[ReplyCode.COMMAND_NOT_SUPPORTED]) if address_type == AddressType.IPV4: remote_host = str(ipaddress.IPv4Address(await self.stream.receive_exactly(4))) elif address_type == AddressType.IPV6: remote_host = str(ipaddress.IPv6Address(await self.stream.receive_exactly(16))) elif address_type == AddressType.DOMAIN: domain_length = ord(await self.stream.receive_exactly(1)) remote_host = (await self.stream.receive_exactly(domain_length)).decode('ascii') else: await self.stream.send( bytes([SOCKS_VER5, ReplyCode.ADDRESS_TYPE_NOT_SUPPORTED, NULL, NULL, NULL, NULL]) ) raise ProxyError(ReplyMessages[ReplyCode.ADDRESS_TYPE_NOT_SUPPORTED]) remote_port = int.from_bytes(await self.stream.receive_exactly(2), 'big') return remote_host, remote_port tiny-proxy-0.2.1/tiny_proxy/_stream.py000066400000000000000000000025651452172056400201220ustar00rootroot00000000000000import anyio import anyio.abc from anyio.streams.buffered import BufferedByteReceiveStream DEFAULT_RECEIVE_SIZE = 65536 class SocketStream: def __init__(self, stream: anyio.abc.SocketStream): self._stream = stream self._buffered = BufferedByteReceiveStream(stream) self._closing = False async def send(self, data: bytes) -> None: await self._stream.send(data) async def send_eof(self) -> None: await self._stream.send_eof() async def receive(self, max_bytes=DEFAULT_RECEIVE_SIZE) -> bytes: return await self._buffered.receive(max_bytes) async def receive_exactly(self, n) -> bytes: return await self._buffered.receive_exactly(n) async def receive_until(self, delimiter: bytes, max_bytes: int) -> bytes: return await self._buffered.receive_until(delimiter, max_bytes) async def aclose(self): if not self._closing: self._closing = True try: # underlying TLSStream.aclose() -> TLSStream.unwrap() await self._buffered.aclose() except (anyio.BrokenResourceError, anyio.BusyResourceError): pass def getpeername(self): return self._stream.extra(anyio.abc.SocketAttribute.remote_address, '') def getsockname(self): return self._stream.extra(anyio.abc.SocketAttribute.local_address, '') tiny-proxy-0.2.1/tiny_proxy/_tunnel.py000066400000000000000000000017141452172056400201270ustar00rootroot00000000000000import anyio from ._stream import SocketStream, DEFAULT_RECEIVE_SIZE async def create_tunnel(endpoint1: SocketStream, endpoint2: SocketStream): async def pipe(reader: SocketStream, writer: SocketStream): try: while True: try: data = await reader.receive(DEFAULT_RECEIVE_SIZE) except ( anyio.EndOfStream, anyio.ClosedResourceError, anyio.BrokenResourceError, ): break try: await writer.send(data) except ( anyio.ClosedResourceError, anyio.BrokenResourceError, ): break finally: await writer.aclose() async with anyio.create_task_group() as tg: tg.start_soon(pipe, endpoint1, endpoint2) tg.start_soon(pipe, endpoint2, endpoint1)