├── tests ├── __init__.py ├── utils.py ├── http_app2.py ├── http_server.py ├── mocks.py ├── config.py ├── proxy_server.py ├── test_transport_trio.py ├── test_transport_sync.py ├── conftest.py └── test_transport_asyncio.py ├── .flake8 ├── MANIFEST.in ├── .coveragerc ├── requirements-dev.txt ├── .gitignore ├── httpx_socks ├── __init__.py ├── _sync_stream.py ├── _sync_transport.py ├── _async_transport.py ├── _sync_proxy.py └── _async_proxy.py ├── .github └── workflows │ └── ci.yml ├── pyproject.toml ├── README.md └── LICENSE.txt /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = N805,W503 3 | max-line-length = 99 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Include the license file 2 | include LICENSE.txt 3 | recursive-include tests * 4 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | # */_proxy_chain_*.py 4 | httpx_socks/core_socks/_basic_auth.py 5 | [report] 6 | # Regexes for lines to exclude from consideration 7 | exclude_lines = 8 | pragma: no cover 9 | pragma: nocover 10 | def __repr__ 11 | if self.debug: 12 | raise NotImplementedError 13 | raise ValueError 14 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | httpx>=0.28.0,<0.29.0 2 | httpcore>=1.0,<2.0 3 | python-socks>=2.4.3,<3.0.0 4 | sniffio>=1.1.0 5 | 6 | async-timeout>=4.0; python_version < "3.11" 7 | trio>=0.24 8 | anyio>=3.3.4,<5.0.0 9 | 10 | flake8>=3.9.1 11 | pytest>=8.3.4 12 | pytest-cov>=5.0.0 13 | pytest-asyncio>=0.24.0 14 | pytest-trio>=0.8.0 15 | trustme>=0.9.0 16 | yarl>=1.6.3 17 | hypercorn>=0.14.3 18 | starlette>=0.25.0 19 | tiny-proxy>=0.1.1 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bak 2 | *.egg 3 | *.egg-info 4 | *.eggs 5 | *.pyc 6 | *.pyd 7 | *.pyo 8 | *.so 9 | *.tar.gz 10 | *~ 11 | .DS_Store 12 | .Python 13 | .cache 14 | .coverage 15 | .coverage.* 16 | .idea 17 | .installed.cfg 18 | .noseids 19 | .tox 20 | .vimrc 21 | # bin 22 | build 23 | cover 24 | coverage 25 | develop-eggs 26 | dist 27 | docs/_build/ 28 | eggs 29 | include/ 30 | lib/ 31 | man/ 32 | nosetests.xml 33 | parts 34 | pyvenv 35 | sources 36 | var/* 37 | venv 38 | virtualenv.py 39 | .install-deps 40 | .develop 41 | .idea/ 42 | .vscode/ 43 | usage*.py -------------------------------------------------------------------------------- /httpx_socks/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = 'httpx-socks' 2 | __version__ = '0.11.0' 3 | 4 | from python_socks import ( 5 | ProxyError, 6 | ProxyTimeoutError, 7 | ProxyConnectionError, 8 | ProxyType 9 | ) 10 | 11 | from ._sync_transport import SyncProxyTransport 12 | from ._async_transport import AsyncProxyTransport 13 | 14 | __all__ = ( 15 | '__title__', 16 | '__version__', 17 | 'SyncProxyTransport', 18 | 'AsyncProxyTransport', 19 | 'ProxyError', 20 | 'ProxyTimeoutError', 21 | 'ProxyConnectionError', 22 | 'ProxyType', 23 | ) 24 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import time 3 | 4 | 5 | def is_connectable(host, port): 6 | try: 7 | sock = socket.create_connection((host, port), 1) 8 | except socket.error: 9 | return False 10 | else: 11 | sock.close() 12 | return True 13 | 14 | 15 | def wait_until_connectable(host, port, timeout=10): 16 | count = 0 17 | while not is_connectable(host=host, port=port): 18 | if count >= timeout: 19 | raise Exception( 20 | f'The proxy server has not available by ({host}, {port}) in {timeout:d} seconds' 21 | ) 22 | count += 1 23 | time.sleep(1) 24 | return True 25 | -------------------------------------------------------------------------------- /tests/http_app2.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from hypercorn.asyncio import serve 4 | from hypercorn.config import Config 5 | from starlette.applications import Starlette 6 | from starlette.requests import Request 7 | from starlette.responses import PlainTextResponse 8 | from starlette.routing import Route 9 | 10 | 11 | async def ip(request: Request): 12 | return PlainTextResponse(content=request.client.host) 13 | 14 | 15 | async def delay(request: Request): 16 | seconds = request.path_params['seconds'] 17 | await asyncio.sleep(seconds) 18 | return PlainTextResponse(content='ok') 19 | 20 | 21 | app = Starlette( 22 | debug=True, 23 | routes=[ 24 | Route('/ip', ip), 25 | Route('/delay/{seconds:int}', delay), 26 | ], 27 | ) 28 | 29 | 30 | def run_app(host: str, port: int, certfile: str = None, keyfile: str = None): 31 | config = Config() 32 | config.bind = ['{}:{}'.format(host, port)] 33 | config.certfile = certfile 34 | config.keyfile = keyfile 35 | asyncio.run(serve(app, config)) # type: ignore 36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | pull_request: 7 | branches: ["master"] 8 | 9 | jobs: 10 | build: 11 | name: "Python ${{ matrix.python-version }} ${{ matrix.os }}" 12 | runs-on: "${{ matrix.os }}" 13 | strategy: 14 | matrix: 15 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] 16 | os: [ubuntu-latest] 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | - name: Setup Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip setuptools 27 | pip install -r requirements-dev.txt 28 | - name: Lint with flake8 29 | run: | 30 | python -m flake8 httpx_socks tests 31 | continue-on-error: true 32 | - name: Run tests 33 | # run: python -m pytest tests --cov=./httpx_socks --cov-report term-missing -s 34 | run: python -m pytest tests --cov=./httpx_socks --cov-report xml 35 | - name: Upload coverage 36 | uses: codecov/codecov-action@v5 37 | with: 38 | token: ${{ secrets.CODECOV_TOKEN }} 39 | slug: romis2012/httpx-socks 40 | file: ./coverage.xml 41 | flags: unit 42 | fail_ci_if_error: false -------------------------------------------------------------------------------- /tests/http_server.py: -------------------------------------------------------------------------------- 1 | import typing 2 | import time 3 | from multiprocessing import Process 4 | 5 | from tests.utils import is_connectable 6 | from tests.http_app2 import run_app 7 | 8 | 9 | class HttpServerConfig(typing.NamedTuple): 10 | host: str 11 | port: int 12 | certfile: str = None 13 | keyfile: str = None 14 | 15 | def to_dict(self): 16 | d = {} 17 | for key, val in self._asdict().items(): 18 | if val is not None: 19 | d[key] = val 20 | return d 21 | 22 | 23 | class HttpServer: 24 | def __init__(self, config: typing.Iterable[HttpServerConfig]): 25 | self.config = config 26 | self.workers = [] 27 | 28 | def start(self): 29 | for cfg in self.config: 30 | p = Process(target=run_app, kwargs=cfg.to_dict()) 31 | self.workers.append(p) 32 | 33 | for p in self.workers: 34 | p.start() 35 | 36 | def terminate(self): 37 | for p in self.workers: 38 | p.terminate() 39 | 40 | def wait_until_connectable(self, host, port, timeout=10): 41 | count = 0 42 | while not is_connectable(host=host, port=port): 43 | if count >= timeout: 44 | self.terminate() 45 | raise Exception( 46 | 'The http server has not available ' 47 | 'by (%s, %s) in %d seconds' 48 | % (host, port, timeout)) 49 | count += 1 50 | time.sleep(1) 51 | return True 52 | -------------------------------------------------------------------------------- /httpx_socks/_sync_stream.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | import typing 3 | 4 | from httpcore._backends.sync import SyncStream as CoreSyncStream 5 | from httpcore._utils import is_socket_readable 6 | from python_socks.sync.v2._ssl_transport import SSLTransport 7 | 8 | 9 | class SyncStream(CoreSyncStream): 10 | def get_extra_info(self, info: str) -> typing.Any: 11 | if info == "ssl_object": 12 | if isinstance(self._sock, ssl.SSLSocket): 13 | # noinspection PyProtectedMember 14 | return self._sock._sslobj # type: ignore 15 | if isinstance(self._sock, SSLTransport): 16 | return self._sock.sslobj # type: ignore 17 | return None 18 | 19 | if info == "client_addr": # pragma: nocover 20 | if isinstance(self._sock, SSLTransport): 21 | return self._sock.socket.getsockname() 22 | else: 23 | return self._sock.getsockname() 24 | 25 | if info == "server_addr": # pragma: nocover 26 | if isinstance(self._sock, SSLTransport): 27 | return self._sock.socket.getpeername() 28 | else: 29 | return self._sock.getpeername() 30 | 31 | if info == "socket": # pragma: nocover 32 | return self._sock # ??? 33 | 34 | if info == "is_readable": 35 | if isinstance(self._sock, SSLTransport): 36 | return is_socket_readable(self._sock.socket) 37 | else: 38 | return is_socket_readable(self._sock) 39 | 40 | return None # pragma: nocover 41 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ['setuptools'] 3 | build-backend = 'setuptools.build_meta' 4 | 5 | [project] 6 | name = 'httpx-socks' 7 | license = { text = 'Apache-2.0' } 8 | description = 'Proxy (HTTP, SOCKS) transports for httpx' 9 | readme = 'README.md' 10 | authors = [{ name = 'Roman Snegirev', email = 'snegiryev@gmail.com' }] 11 | keywords = [ 12 | 'httpx', 13 | 'socks', 14 | 'socks5', 15 | 'socks4', 16 | 'http', 17 | 'proxy', 18 | 'asyncio', 19 | 'trio', 20 | 'anyio', 21 | ] 22 | requires-python = ">=3.8" 23 | dynamic = ['version'] 24 | classifiers = [ 25 | "Development Status :: 5 - Production/Stable", 26 | "Programming Language :: Python", 27 | "Programming Language :: Python :: 3", 28 | "Programming Language :: Python :: 3 :: Only", 29 | "Programming Language :: Python :: 3.8", 30 | "Programming Language :: Python :: 3.9", 31 | "Programming Language :: Python :: 3.10", 32 | "Programming Language :: Python :: 3.11", 33 | "Programming Language :: Python :: 3.12", 34 | "Programming Language :: Python :: 3.13", 35 | "Programming Language :: Python :: 3.14", 36 | "Operating System :: MacOS", 37 | "Operating System :: Microsoft", 38 | "Operating System :: POSIX :: Linux", 39 | "Topic :: Internet :: WWW/HTTP", 40 | "Intended Audience :: Developers", 41 | "Framework :: AsyncIO", 42 | "Framework :: Trio", 43 | "License :: OSI Approved :: Apache Software License", 44 | ] 45 | dependencies = [ 46 | "httpx>=0.28.0,<0.29.0", 47 | "httpcore>=1.0,<2.0", 48 | "python-socks>=2.4.3,<3.0.0", 49 | "sniffio>=1.1.0", 50 | ] 51 | 52 | [project.optional-dependencies] 53 | asyncio = ['async-timeout>=4.0; python_version < "3.11"'] 54 | trio = ['trio>=0.24'] 55 | anyio = ['anyio>=3.3.4,<5.0.0'] 56 | 57 | [project.urls] 58 | homepage = 'https://github.com/romis2012/httpx-socks' 59 | repository = 'https://github.com/romis2012/httpx-socks' 60 | 61 | [tool.setuptools.dynamic] 62 | version = { attr = 'httpx_socks.__version__' } 63 | 64 | [tool.setuptools.packages.find] 65 | include = ['httpx_socks*'] 66 | 67 | [tool.black] 68 | line-length = 99 69 | target-version = ['py38', 'py39', 'py310', 'py311', 'py312'] 70 | skip-string-normalization = true 71 | preview = true 72 | verbose = true 73 | 74 | [tool.pytest.ini_options] 75 | asyncio_mode = 'strict' 76 | -------------------------------------------------------------------------------- /tests/mocks.py: -------------------------------------------------------------------------------- 1 | import socket 2 | 3 | from tests.config import ( 4 | TEST_HOST_NAME_IPV4, 5 | PROXY_HOST_NAME_IPV4, 6 | TEST_HOST_NAME_IPV6, 7 | PROXY_HOST_NAME_IPV6, 8 | ) 9 | 10 | 11 | def getaddrinfo_sync_mock(): 12 | _orig_getaddrinfo = socket.getaddrinfo 13 | 14 | def getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): 15 | if host in (TEST_HOST_NAME_IPV4, PROXY_HOST_NAME_IPV4): 16 | return [(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('127.0.0.1', port))] 17 | 18 | if host in (TEST_HOST_NAME_IPV6, PROXY_HOST_NAME_IPV6): 19 | return [(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('::1', port, 0, 0))] 20 | 21 | return _orig_getaddrinfo(host, port, family, type, proto, flags) 22 | 23 | return getaddrinfo 24 | 25 | 26 | def getaddrinfo_async_mock(origin_getaddrinfo): 27 | async def getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): 28 | if host in (TEST_HOST_NAME_IPV4, PROXY_HOST_NAME_IPV4): 29 | return [(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('127.0.0.1', port))] 30 | 31 | if host in (TEST_HOST_NAME_IPV6, PROXY_HOST_NAME_IPV6): 32 | return [(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('::1', port, 0, 0))] 33 | 34 | return await origin_getaddrinfo( 35 | host, 36 | port, 37 | family=family, 38 | type=type, 39 | proto=proto, 40 | flags=flags, 41 | ) 42 | 43 | return getaddrinfo 44 | 45 | 46 | def _resolve_local(host): 47 | if host in (TEST_HOST_NAME_IPV4, PROXY_HOST_NAME_IPV4): 48 | return socket.AF_INET, '127.0.0.1' 49 | 50 | if host in (TEST_HOST_NAME_IPV6, PROXY_HOST_NAME_IPV6): 51 | return socket.AF_INET6, '::1' 52 | 53 | return None 54 | 55 | 56 | def sync_resolve_factory(cls): 57 | original_resolve = cls.resolve 58 | 59 | def new_resolve(self, host, port=0, family=socket.AF_UNSPEC): 60 | res = _resolve_local(host) 61 | 62 | if res is not None: 63 | return res 64 | 65 | return original_resolve(self, host=host, port=port, family=family) 66 | 67 | return new_resolve 68 | 69 | 70 | def async_resolve_factory(cls): 71 | original_resolve = cls.resolve 72 | 73 | async def new_resolve(self, host, port=0, family=socket.AF_UNSPEC): 74 | res = _resolve_local(host) 75 | 76 | if res is not None: 77 | return res 78 | 79 | return await original_resolve(self, host=host, port=port, family=family) 80 | 81 | return new_resolve 82 | -------------------------------------------------------------------------------- /tests/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | LOGIN = 'admin' 4 | PASSWORD = 'admin' 5 | 6 | PROXY_HOST_IPV4 = '127.0.0.1' 7 | PROXY_HOST_IPV6 = '::1' 8 | 9 | PROXY_HOST_NAME_IPV4 = 'ip4.proxy.example.com' 10 | PROXY_HOST_NAME_IPV6 = 'ip6.proxy.example.com' 11 | 12 | SOCKS5_PROXY_PORT = 7780 13 | SOCKS5_PROXY_PORT_NO_AUTH = 7781 14 | 15 | SOCKS4_PROXY_PORT = 7782 16 | SOCKS4_PORT_NO_AUTH = 7783 17 | 18 | HTTP_PROXY_PORT = 7784 19 | HTTPS_PROXY_PORT = 7785 20 | 21 | SKIP_IPV6_TESTS = 'SKIP_IPV6_TESTS' in os.environ 22 | 23 | SOCKS5_IPV4_URL = 'socks5://{login}:{password}@{host}:{port}'.format( 24 | host=PROXY_HOST_IPV4, 25 | port=SOCKS5_PROXY_PORT, 26 | login=LOGIN, 27 | password=PASSWORD, 28 | ) 29 | 30 | SOCKS5_IPV6_URL = 'socks5://{login}:{password}@{host}:{port}'.format( 31 | host='[%s]' % PROXY_HOST_IPV6, 32 | port=SOCKS5_PROXY_PORT, 33 | login=LOGIN, 34 | password=PASSWORD, 35 | ) 36 | 37 | SOCKS5_IPV4_HOSTNAME_URL = 'socks5://{login}:{password}@{host}:{port}'.format( 38 | host=PROXY_HOST_NAME_IPV4, 39 | port=SOCKS5_PROXY_PORT, 40 | login=LOGIN, 41 | password=PASSWORD, 42 | ) 43 | 44 | SOCKS5_IPV4_URL_WO_AUTH = 'socks5://{host}:{port}'.format( 45 | host=PROXY_HOST_IPV4, port=SOCKS5_PROXY_PORT_NO_AUTH 46 | ) 47 | 48 | SOCKS4_URL = 'socks4://{login}:{password}@{host}:{port}'.format( 49 | host=PROXY_HOST_IPV4, 50 | port=SOCKS4_PROXY_PORT, 51 | login=LOGIN, 52 | password='', 53 | ) 54 | 55 | HTTP_PROXY_URL = 'http://{login}:{password}@{host}:{port}'.format( 56 | host=PROXY_HOST_IPV4, 57 | port=HTTP_PROXY_PORT, 58 | login=LOGIN, 59 | password=PASSWORD, 60 | ) 61 | 62 | HTTPS_PROXY_URL = 'http://{login}:{password}@{host}:{port}'.format( 63 | host=PROXY_HOST_NAME_IPV4, 64 | port=HTTPS_PROXY_PORT, 65 | login=LOGIN, 66 | password=PASSWORD, 67 | ) 68 | 69 | TEST_HOST_IPV4 = '127.0.0.1' 70 | TEST_HOST_IPV6 = '::1' 71 | 72 | TEST_HOST_NAME_IPV4 = 'ip4.target.example.com' 73 | TEST_HOST_NAME_IPV6 = 'ip6.target.example.com' 74 | 75 | TEST_PORT_IPV4 = 8889 76 | TEST_PORT_IPV6 = 8889 77 | 78 | TEST_PORT_IPV4_HTTPS = 8890 79 | 80 | TEST_URL_IPV4 = 'http://{host}:{port}/ip'.format(host=TEST_HOST_NAME_IPV4, port=TEST_PORT_IPV4) 81 | 82 | TEST_URL_IPv6 = 'http://{host}:{port}/ip'.format(host=TEST_HOST_NAME_IPV6, port=TEST_PORT_IPV6) 83 | 84 | TEST_URL_IPV4_DELAY = 'http://{host}:{port}/delay/3'.format( 85 | host=TEST_HOST_NAME_IPV4, port=TEST_PORT_IPV4 86 | ) 87 | 88 | TEST_URL_IPV4_HTTPS = 'https://{host}:{port}/ip'.format( 89 | host=TEST_HOST_NAME_IPV4, port=TEST_PORT_IPV4_HTTPS 90 | ) 91 | 92 | 93 | def resolve_path(path): 94 | return os.path.normpath(os.path.join(os.path.dirname(os.path.realpath(__file__)), path)) 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # httpx-socks 2 | 3 | [![CI](https://github.com/romis2012/httpx-socks/actions/workflows/ci.yml/badge.svg)](https://github.com/romis2012/httpx-socks/actions/workflows/ci.yml) 4 | [![Coverage Status](https://codecov.io/gh/romis2012/httpx-socks/branch/master/graph/badge.svg)](https://codecov.io/gh/romis2012/httpx-socks) 5 | [![PyPI version](https://badge.fury.io/py/httpx-socks.svg)](https://pypi.python.org/pypi/httpx-socks) 6 | [![versions](https://img.shields.io/pypi/pyversions/httpx-socks.svg)](https://github.com/romis2012/httpx-socks) 7 | 10 | 11 | The `httpx-socks` package provides proxy transports for [httpx](https://github.com/encode/httpx) client. 12 | SOCKS4(a), SOCKS5(h), HTTP CONNECT proxy supported. 13 | It uses [python-socks](https://github.com/romis2012/python-socks) for core proxy functionality. 14 | 15 | 16 | ## Requirements 17 | - Python >= 3.8 18 | - httpx>=0.28.0,<0.29.0 19 | - python-socks>=2.4.3,<3.0.0 20 | - trio>=0.24 (optional) 21 | - anyio>=3.3.4,<5.0.0 (optional) 22 | 23 | 24 | ## Installation 25 | 26 | only sync proxy support: 27 | ``` 28 | pip install httpx-socks 29 | ``` 30 | 31 | to include optional asyncio support (it requires async-timeout): 32 | ``` 33 | pip install httpx-socks[asyncio] 34 | ``` 35 | 36 | to include optional trio support: 37 | ``` 38 | pip install httpx-socks[trio] 39 | ``` 40 | 41 | ## Usage 42 | 43 | #### sync transport 44 | ```python 45 | import httpx 46 | from httpx_socks import SyncProxyTransport 47 | 48 | def fetch(url): 49 | transport = SyncProxyTransport.from_url('socks5://user:password@127.0.0.1:1080') 50 | with httpx.Client(transport=transport) as client: 51 | res = client.get(url) 52 | return res.text 53 | ``` 54 | 55 | #### async transport (asyncio, trio) 56 | ```python 57 | import httpx 58 | from httpx_socks import AsyncProxyTransport 59 | 60 | async def fetch(url): 61 | transport = AsyncProxyTransport.from_url('socks5://user:password@127.0.0.1:1080') 62 | async with httpx.AsyncClient(transport=transport) as client: 63 | res = await client.get(url) 64 | return res.text 65 | ``` 66 | 67 | #### secure proxy connections (aka "HTTPS proxies", experimental feature, both sync and async support) 68 | ```python 69 | import ssl 70 | import httpx 71 | from httpx_socks import AsyncProxyTransport 72 | 73 | async def fetch(url): 74 | proxy_ssl = ssl.SSLContext(ssl.PROTOCOL_TLS) 75 | proxy_ssl.verify_mode = ssl.CERT_REQUIRED 76 | proxy_ssl.load_verify_locations(...) 77 | 78 | transport = AsyncProxyTransport.from_url('http://user:password@127.0.0.1:8080', proxy_ssl=proxy_ssl) 79 | async with httpx.AsyncClient(transport=transport) as client: 80 | res = await client.get(url) 81 | return res.text 82 | ``` 83 | -------------------------------------------------------------------------------- /httpx_socks/_sync_transport.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | import typing 3 | 4 | import httpcore 5 | 6 | from httpx import BaseTransport, Request, Response, SyncByteStream, Limits 7 | 8 | # noinspection PyProtectedMember 9 | from httpx._config import DEFAULT_LIMITS, create_ssl_context 10 | # noinspection PyProtectedMember 11 | from httpx._transports.default import ResponseStream, map_httpcore_exceptions 12 | 13 | from ._sync_proxy import SyncProxy 14 | from python_socks import ProxyType, parse_proxy_url 15 | 16 | 17 | class SyncProxyTransport(BaseTransport): 18 | def __init__( 19 | self, 20 | *, 21 | proxy_type: ProxyType, 22 | proxy_host: str, 23 | proxy_port: int, 24 | username=None, 25 | password=None, 26 | rdns=None, 27 | proxy_ssl: ssl.SSLContext = None, 28 | verify=True, 29 | cert=None, 30 | trust_env: bool = True, 31 | limits: Limits = DEFAULT_LIMITS, 32 | **kwargs, 33 | ): 34 | ssl_context = create_ssl_context( 35 | verify=verify, 36 | cert=cert, 37 | trust_env=trust_env, 38 | ) 39 | 40 | self._pool = SyncProxy( 41 | proxy_type=proxy_type, 42 | proxy_host=proxy_host, 43 | proxy_port=proxy_port, 44 | username=username, 45 | password=password, 46 | rdns=rdns, 47 | proxy_ssl=proxy_ssl, 48 | ssl_context=ssl_context, 49 | max_connections=limits.max_connections, 50 | max_keepalive_connections=limits.max_keepalive_connections, 51 | keepalive_expiry=limits.keepalive_expiry, 52 | **kwargs, 53 | ) 54 | 55 | def handle_request(self, request: Request) -> Response: 56 | assert isinstance(request.stream, SyncByteStream) 57 | 58 | req = httpcore.Request( 59 | method=request.method, 60 | url=httpcore.URL( 61 | scheme=request.url.raw_scheme, 62 | host=request.url.raw_host, 63 | port=request.url.port, 64 | target=request.url.raw_path, 65 | ), 66 | headers=request.headers.raw, 67 | content=request.stream, 68 | extensions=request.extensions, 69 | ) 70 | 71 | with map_httpcore_exceptions(): 72 | resp = self._pool.handle_request(req) 73 | 74 | assert isinstance(resp.stream, typing.Iterable) 75 | 76 | return Response( 77 | status_code=resp.status, 78 | headers=resp.headers, 79 | stream=ResponseStream(resp.stream), 80 | extensions=resp.extensions, 81 | ) 82 | 83 | @classmethod 84 | def from_url(cls, url, **kwargs): 85 | proxy_type, host, port, username, password = parse_proxy_url(url) 86 | return cls( 87 | proxy_type=proxy_type, 88 | proxy_host=host, 89 | proxy_port=port, 90 | username=username, 91 | password=password, 92 | **kwargs, 93 | ) 94 | 95 | def close(self) -> None: 96 | self._pool.close() # pragma: no cover 97 | 98 | def __enter__(self): 99 | self._pool.__enter__() 100 | return self 101 | 102 | def __exit__(self, exc_type=None, exc_value=None, traceback=None): 103 | with map_httpcore_exceptions(): 104 | self._pool.__exit__(exc_type, exc_value, traceback) 105 | -------------------------------------------------------------------------------- /httpx_socks/_async_transport.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | import typing 3 | 4 | import httpcore 5 | from httpx import AsyncBaseTransport, Request, Response, AsyncByteStream, Limits 6 | 7 | # noinspection PyProtectedMember 8 | from httpx._config import DEFAULT_LIMITS, create_ssl_context 9 | # noinspection PyProtectedMember 10 | from httpx._transports.default import AsyncResponseStream, map_httpcore_exceptions 11 | from python_socks import ProxyType, parse_proxy_url 12 | 13 | from ._async_proxy import AsyncProxy 14 | 15 | 16 | class AsyncProxyTransport(AsyncBaseTransport): 17 | def __init__( 18 | self, 19 | *, 20 | proxy_type: ProxyType, 21 | proxy_host: str, 22 | proxy_port: int, 23 | username=None, 24 | password=None, 25 | rdns=None, 26 | proxy_ssl: ssl.SSLContext = None, 27 | verify=True, 28 | cert=None, 29 | trust_env: bool = True, 30 | limits: Limits = DEFAULT_LIMITS, 31 | **kwargs, 32 | ): 33 | ssl_context = create_ssl_context( 34 | verify=verify, 35 | cert=cert, 36 | trust_env=trust_env, 37 | ) 38 | 39 | self._pool = AsyncProxy( 40 | proxy_type=proxy_type, 41 | proxy_host=proxy_host, 42 | proxy_port=proxy_port, 43 | username=username, 44 | password=password, 45 | rdns=rdns, 46 | proxy_ssl=proxy_ssl, 47 | ssl_context=ssl_context, 48 | max_connections=limits.max_connections, 49 | max_keepalive_connections=limits.max_keepalive_connections, 50 | keepalive_expiry=limits.keepalive_expiry, 51 | **kwargs, 52 | ) 53 | 54 | async def handle_async_request(self, request: Request) -> Response: 55 | assert isinstance(request.stream, AsyncByteStream) 56 | 57 | req = httpcore.Request( 58 | method=request.method, 59 | url=httpcore.URL( 60 | scheme=request.url.raw_scheme, 61 | host=request.url.raw_host, 62 | port=request.url.port, 63 | target=request.url.raw_path, 64 | ), 65 | headers=request.headers.raw, 66 | content=request.stream, 67 | extensions=request.extensions, 68 | ) 69 | 70 | with map_httpcore_exceptions(): 71 | resp = await self._pool.handle_async_request(req) 72 | 73 | assert isinstance(resp.stream, typing.AsyncIterable) 74 | 75 | return Response( 76 | status_code=resp.status, 77 | headers=resp.headers, 78 | stream=AsyncResponseStream(resp.stream), 79 | extensions=resp.extensions, 80 | ) 81 | 82 | @classmethod 83 | def from_url(cls, url, **kwargs): 84 | proxy_type, host, port, username, password = parse_proxy_url(url) 85 | return cls( 86 | proxy_type=proxy_type, 87 | proxy_host=host, 88 | proxy_port=port, 89 | username=username, 90 | password=password, 91 | **kwargs, 92 | ) 93 | 94 | async def aclose(self) -> None: 95 | await self._pool.aclose() # pragma: no cover 96 | 97 | async def __aenter__(self): 98 | await self._pool.__aenter__() 99 | return self 100 | 101 | async def __aexit__(self, exc_type=None, exc_value=None, traceback=None): 102 | with map_httpcore_exceptions(): 103 | await self._pool.__aexit__(exc_type, exc_value, traceback) 104 | -------------------------------------------------------------------------------- /tests/proxy_server.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | import typing 3 | from multiprocessing import Process 4 | from unittest import mock 5 | 6 | import anyio 7 | from anyio import create_tcp_listener 8 | from anyio.streams.tls import TLSListener 9 | from tiny_proxy import ( 10 | HttpProxyHandler, 11 | Socks5ProxyHandler, 12 | Socks4ProxyHandler, 13 | HttpProxy, 14 | Socks4Proxy, 15 | Socks5Proxy, 16 | AbstractProxy, 17 | ) 18 | 19 | from tests.mocks import getaddrinfo_async_mock 20 | 21 | 22 | class ProxyConfig(typing.NamedTuple): 23 | proxy_type: str 24 | host: str 25 | port: int 26 | username: typing.Optional[str] = None 27 | password: typing.Optional[str] = None 28 | ssl_certfile: typing.Optional[str] = None 29 | ssl_keyfile: typing.Optional[str] = None 30 | 31 | def to_dict(self): 32 | d = {} 33 | for key, val in self._asdict().items(): 34 | if val is not None: 35 | d[key] = val 36 | return d 37 | 38 | 39 | cls_map = { 40 | 'http': HttpProxyHandler, 41 | 'socks4': Socks4ProxyHandler, 42 | 'socks5': Socks5ProxyHandler, 43 | } 44 | 45 | 46 | def connect_to_remote_factory(cls: typing.Type[AbstractProxy]): 47 | """ 48 | simulate target host connection timeout 49 | """ 50 | origin_connect_to_remote = cls.connect_to_remote 51 | 52 | async def new_connect_to_remote(self): 53 | await anyio.sleep(0.01) 54 | return await origin_connect_to_remote(self) 55 | 56 | return new_connect_to_remote 57 | 58 | 59 | @mock.patch.object( 60 | HttpProxy, 61 | attribute='connect_to_remote', 62 | new=connect_to_remote_factory(HttpProxy), 63 | ) 64 | @mock.patch.object( 65 | Socks4Proxy, 66 | attribute='connect_to_remote', 67 | new=connect_to_remote_factory(Socks4Proxy), 68 | ) 69 | @mock.patch.object( 70 | Socks5Proxy, 71 | attribute='connect_to_remote', 72 | new=connect_to_remote_factory(Socks5Proxy), 73 | ) 74 | @mock.patch('anyio._core._sockets.getaddrinfo', new=getaddrinfo_async_mock(anyio.getaddrinfo)) 75 | def start( 76 | proxy_type, 77 | host, 78 | port, 79 | ssl_certfile=None, 80 | ssl_keyfile=None, 81 | **kwargs, 82 | ): 83 | handler_cls = cls_map.get(proxy_type) 84 | if not handler_cls: 85 | raise RuntimeError(f'Unsupported type: {proxy_type}') 86 | 87 | if ssl_certfile and ssl_keyfile: 88 | ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) 89 | ssl_context.load_cert_chain(ssl_certfile, ssl_keyfile) 90 | else: 91 | ssl_context = None 92 | 93 | print(f'Starting {proxy_type} proxy on {host}:{port}...') 94 | 95 | handler = handler_cls(**kwargs) 96 | 97 | async def serve(): 98 | listener = await create_tcp_listener(local_host=host, local_port=port) 99 | if ssl_context is not None: 100 | listener = TLSListener(listener=listener, ssl_context=ssl_context) 101 | 102 | async with listener: 103 | await listener.serve(handler.handle) 104 | 105 | anyio.run(serve) 106 | 107 | 108 | class ProxyServer: 109 | workers: typing.List[Process] 110 | 111 | def __init__(self, config: typing.Iterable[ProxyConfig]): 112 | self.config = config 113 | self.workers = [] 114 | 115 | def start(self): 116 | for cfg in self.config: 117 | print( 118 | 'Starting {} proxy on {}:{}; certfile={}, keyfile={}...'.format( 119 | cfg.proxy_type, 120 | cfg.host, 121 | cfg.port, 122 | cfg.ssl_certfile, 123 | cfg.ssl_keyfile, 124 | ) 125 | ) 126 | 127 | p = Process(target=start, kwargs=cfg.to_dict(), daemon=True) 128 | self.workers.append(p) 129 | 130 | for p in self.workers: 131 | p.start() 132 | 133 | def terminate(self): 134 | for p in self.workers: 135 | p.terminate() 136 | -------------------------------------------------------------------------------- /tests/test_transport_trio.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | 3 | 4 | import httpx 5 | import pytest 6 | from yarl import URL 7 | 8 | from httpx_socks import ( 9 | ProxyType, 10 | AsyncProxyTransport, 11 | ProxyError, 12 | ProxyConnectionError, 13 | ProxyTimeoutError, 14 | ) 15 | from httpx_socks._async_proxy import AsyncProxy 16 | from tests.config import ( 17 | TEST_URL_IPV4, 18 | TEST_URL_IPV4_HTTPS, 19 | SOCKS5_IPV4_URL, 20 | LOGIN, 21 | PASSWORD, 22 | PROXY_HOST_IPV4, 23 | SOCKS5_PROXY_PORT, 24 | TEST_URL_IPV4_DELAY, 25 | SKIP_IPV6_TESTS, 26 | SOCKS5_IPV6_URL, 27 | SOCKS4_URL, 28 | HTTP_PROXY_URL, 29 | SOCKS5_IPV4_HOSTNAME_URL, 30 | HTTPS_PROXY_URL, 31 | ) 32 | 33 | 34 | def create_ssl_context(url, ca, http2=False): 35 | parsed_url = URL(url) 36 | if parsed_url.scheme == 'https': 37 | ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) 38 | ssl_context.verify_mode = ssl.CERT_REQUIRED 39 | alpn_protocols = ['http/1.1', 'h2'] if http2 else ['http/1.1'] 40 | ssl_context.set_alpn_protocols(alpn_protocols) 41 | ca.configure_trust(ssl_context) 42 | return ssl_context 43 | else: 44 | return None 45 | 46 | 47 | async def fetch( 48 | transport: AsyncProxyTransport, 49 | url: str, 50 | timeout: httpx.Timeout = None, 51 | ): 52 | async with httpx.AsyncClient(transport=transport) as client: 53 | res = await client.get(url=url, timeout=timeout) 54 | return res 55 | 56 | 57 | @pytest.mark.parametrize('proxy_url', (SOCKS5_IPV4_URL, SOCKS5_IPV4_HOSTNAME_URL, HTTP_PROXY_URL)) 58 | @pytest.mark.parametrize('target_url', (TEST_URL_IPV4, TEST_URL_IPV4_HTTPS)) 59 | @pytest.mark.trio 60 | async def test_proxy_direct(proxy_url, target_url, target_ssl_ca): 61 | ssl_context = create_ssl_context(target_url, ca=target_ssl_ca) 62 | async with AsyncProxy.from_url(proxy_url, ssl_context=ssl_context) as proxy: 63 | res = await proxy.request(method="GET", url=target_url) 64 | assert res.status == 200 65 | res = await proxy.request(method="GET", url=target_url) 66 | assert res.status == 200 67 | 68 | 69 | @pytest.mark.parametrize('url', (TEST_URL_IPV4, TEST_URL_IPV4_HTTPS)) 70 | @pytest.mark.parametrize('rdns', (True, False)) 71 | @pytest.mark.trio 72 | async def test_socks5_proxy_ipv4(url, rdns, target_ssl_ca): 73 | transport = AsyncProxyTransport.from_url( 74 | SOCKS5_IPV4_URL, rdns=rdns, verify=create_ssl_context(url, ca=target_ssl_ca) 75 | ) 76 | res = await fetch(transport=transport, url=url) 77 | assert res.status_code == 200 78 | 79 | 80 | @pytest.mark.trio 81 | async def test_socks5_proxy_with_invalid_credentials(target_ssl_ca, url=TEST_URL_IPV4): 82 | transport = AsyncProxyTransport( 83 | proxy_type=ProxyType.SOCKS5, 84 | proxy_host=PROXY_HOST_IPV4, 85 | proxy_port=SOCKS5_PROXY_PORT, 86 | username=LOGIN, 87 | password=PASSWORD + 'aaa', 88 | verify=create_ssl_context(url, ca=target_ssl_ca), 89 | ) 90 | with pytest.raises(ProxyError): 91 | await fetch(transport=transport, url=url) 92 | 93 | 94 | @pytest.mark.trio 95 | async def test_socks5_proxy_with_read_timeout(target_ssl_ca, url=TEST_URL_IPV4_DELAY): 96 | transport = AsyncProxyTransport( 97 | proxy_type=ProxyType.SOCKS5, 98 | proxy_host=PROXY_HOST_IPV4, 99 | proxy_port=SOCKS5_PROXY_PORT, 100 | username=LOGIN, 101 | password=PASSWORD, 102 | verify=create_ssl_context(url, ca=target_ssl_ca), 103 | ) 104 | timeout = httpx.Timeout(2, connect=32) 105 | # with pytest.raises(httpcore.ReadTimeout): 106 | with pytest.raises(httpx.ReadTimeout): 107 | await fetch(transport=transport, url=url, timeout=timeout) 108 | 109 | 110 | @pytest.mark.trio 111 | async def test_socks5_proxy_with_connect_timeout(target_ssl_ca, url=TEST_URL_IPV4): 112 | transport = AsyncProxyTransport( 113 | proxy_type=ProxyType.SOCKS5, 114 | proxy_host=PROXY_HOST_IPV4, 115 | proxy_port=SOCKS5_PROXY_PORT, 116 | username=LOGIN, 117 | password=PASSWORD, 118 | verify=create_ssl_context(url, ca=target_ssl_ca), 119 | ) 120 | timeout = httpx.Timeout(32, connect=0.001) 121 | with pytest.raises(ProxyTimeoutError): 122 | await fetch(transport=transport, url=url, timeout=timeout) 123 | 124 | 125 | @pytest.mark.trio 126 | async def test_socks5_proxy_with_invalid_proxy_port( 127 | unused_tcp_port, target_ssl_ca, url=TEST_URL_IPV4 128 | ): 129 | transport = AsyncProxyTransport( 130 | proxy_type=ProxyType.SOCKS5, 131 | proxy_host=PROXY_HOST_IPV4, 132 | proxy_port=unused_tcp_port, 133 | username=LOGIN, 134 | password=PASSWORD, 135 | verify=create_ssl_context(url, ca=target_ssl_ca), 136 | ) 137 | with pytest.raises(ProxyConnectionError): 138 | await fetch(transport=transport, url=url) 139 | 140 | 141 | @pytest.mark.parametrize('url', (TEST_URL_IPV4, TEST_URL_IPV4_HTTPS)) 142 | @pytest.mark.skipif(SKIP_IPV6_TESTS, reason="TravisCI doesn't support ipv6") 143 | @pytest.mark.trio 144 | async def test_socks5_proxy_ipv6(url, target_ssl_ca): 145 | transport = AsyncProxyTransport.from_url( 146 | SOCKS5_IPV6_URL, verify=create_ssl_context(url, ca=target_ssl_ca) 147 | ) 148 | res = await fetch(transport=transport, url=url) 149 | assert res.status_code == 200 150 | 151 | 152 | @pytest.mark.parametrize('url', (TEST_URL_IPV4, TEST_URL_IPV4_HTTPS)) 153 | @pytest.mark.parametrize('rdns', (True, False)) 154 | @pytest.mark.trio 155 | async def test_socks4_proxy(url, rdns, target_ssl_ca): 156 | transport = AsyncProxyTransport.from_url( 157 | SOCKS4_URL, rdns=rdns, verify=create_ssl_context(url, ca=target_ssl_ca) 158 | ) 159 | res = await fetch(transport=transport, url=url) 160 | assert res.status_code == 200 161 | 162 | 163 | @pytest.mark.parametrize('url', (TEST_URL_IPV4, TEST_URL_IPV4_HTTPS)) 164 | @pytest.mark.trio 165 | async def test_http_proxy(url, target_ssl_ca): 166 | transport = AsyncProxyTransport.from_url( 167 | HTTP_PROXY_URL, verify=create_ssl_context(url, ca=target_ssl_ca) 168 | ) 169 | res = await fetch(transport=transport, url=url) 170 | assert res.status_code == 200 171 | 172 | 173 | @pytest.mark.parametrize('url', (TEST_URL_IPV4, TEST_URL_IPV4_HTTPS)) 174 | @pytest.mark.parametrize('http2', (False, True)) 175 | @pytest.mark.trio 176 | async def test_secure_proxy(url, http2, target_ssl_ca, proxy_ssl_context): 177 | transport = AsyncProxyTransport.from_url( 178 | HTTPS_PROXY_URL, 179 | proxy_ssl=proxy_ssl_context, 180 | http2=http2, 181 | verify=create_ssl_context(url, ca=target_ssl_ca, http2=http2), 182 | ) 183 | res = await fetch(transport=transport, url=url) 184 | assert res.status_code == 200 185 | 186 | 187 | @pytest.mark.trio 188 | async def test_proxy_http2(target_ssl_ca): 189 | url = TEST_URL_IPV4_HTTPS 190 | proxy_url = HTTP_PROXY_URL 191 | ssl_context = create_ssl_context(url, ca=target_ssl_ca, http2=True) 192 | 193 | transport = AsyncProxyTransport.from_url(proxy_url, verify=ssl_context, http2=True) 194 | res = await fetch(transport=transport, url=url) 195 | assert res.status_code == 200 196 | assert res.http_version == 'HTTP/2' 197 | -------------------------------------------------------------------------------- /tests/test_transport_sync.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | from unittest import mock 3 | 4 | import httpx 5 | import pytest 6 | from yarl import URL 7 | 8 | from httpx_socks import ( 9 | ProxyType, 10 | SyncProxyTransport, 11 | ProxyError, 12 | ProxyConnectionError, 13 | ProxyTimeoutError, 14 | ) 15 | from httpx_socks._sync_proxy import SyncProxy 16 | from tests.config import ( 17 | TEST_URL_IPV4, 18 | TEST_URL_IPV4_HTTPS, 19 | SOCKS5_IPV4_URL, 20 | LOGIN, 21 | PASSWORD, 22 | PROXY_HOST_IPV4, 23 | SOCKS5_PROXY_PORT, 24 | TEST_URL_IPV4_DELAY, 25 | SKIP_IPV6_TESTS, 26 | SOCKS5_IPV6_URL, 27 | SOCKS4_URL, 28 | HTTP_PROXY_URL, 29 | SOCKS5_IPV4_HOSTNAME_URL, 30 | HTTPS_PROXY_URL, 31 | ) 32 | 33 | 34 | def create_ssl_context(url, ca, http2=False): 35 | parsed_url = URL(url) 36 | if parsed_url.scheme == 'https': 37 | ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) 38 | ssl_context.verify_mode = ssl.CERT_REQUIRED 39 | alpn_protocols = ['http/1.1', 'h2'] if http2 else ['http/1.1'] 40 | ssl_context.set_alpn_protocols(alpn_protocols) 41 | ca.configure_trust(ssl_context) 42 | return ssl_context 43 | else: 44 | return None 45 | 46 | 47 | def fetch(transport: SyncProxyTransport, url: str, timeout: httpx.Timeout = None): 48 | with httpx.Client(transport=transport) as client: # type: ignore 49 | res = client.get(url=url, timeout=timeout) 50 | return res 51 | 52 | 53 | @pytest.mark.parametrize('proxy_url', (SOCKS5_IPV4_URL, SOCKS5_IPV4_HOSTNAME_URL, HTTP_PROXY_URL)) 54 | @pytest.mark.parametrize('target_url', (TEST_URL_IPV4, TEST_URL_IPV4_HTTPS)) 55 | def test_proxy_direct(proxy_url, target_url, target_ssl_ca): 56 | ssl_context = create_ssl_context(target_url, ca=target_ssl_ca) 57 | with SyncProxy.from_url(proxy_url, ssl_context=ssl_context) as proxy: 58 | res = proxy.request(method="GET", url=target_url) 59 | assert res.status == 200 60 | res = proxy.request(method="GET", url=target_url) 61 | assert res.status == 200 62 | 63 | 64 | @pytest.mark.parametrize('url', (TEST_URL_IPV4, TEST_URL_IPV4_HTTPS)) 65 | @pytest.mark.parametrize('rdns', (True, False)) 66 | def test_socks5_proxy_ipv4(url, rdns, target_ssl_ca): 67 | transport = SyncProxyTransport.from_url( 68 | SOCKS5_IPV4_URL, rdns=rdns, verify=create_ssl_context(url, ca=target_ssl_ca) 69 | ) 70 | res = fetch(transport=transport, url=url) 71 | assert res.status_code == 200 72 | 73 | 74 | def test_socks5_proxy_with_invalid_credentials(target_ssl_ca, url=TEST_URL_IPV4): 75 | transport = SyncProxyTransport( 76 | proxy_type=ProxyType.SOCKS5, 77 | proxy_host=PROXY_HOST_IPV4, 78 | proxy_port=SOCKS5_PROXY_PORT, 79 | username=LOGIN, 80 | password=PASSWORD + 'aaa', 81 | verify=create_ssl_context(url, ca=target_ssl_ca), 82 | ) 83 | with pytest.raises(ProxyError): 84 | fetch(transport=transport, url=url) 85 | 86 | 87 | def test_socks5_proxy_with_read_timeout(target_ssl_ca, url=TEST_URL_IPV4_DELAY): 88 | transport = SyncProxyTransport( 89 | proxy_type=ProxyType.SOCKS5, 90 | proxy_host=PROXY_HOST_IPV4, 91 | proxy_port=SOCKS5_PROXY_PORT, 92 | username=LOGIN, 93 | password=PASSWORD, 94 | verify=create_ssl_context(url, ca=target_ssl_ca), 95 | ) 96 | timeout = httpx.Timeout(2, connect=32) 97 | # with pytest.raises(httpcore.ReadTimeout): 98 | with pytest.raises(httpx.ReadTimeout): 99 | fetch(transport=transport, url=url, timeout=timeout) 100 | 101 | 102 | def test_socks5_proxy_with_connect_timeout(target_ssl_ca, url=TEST_URL_IPV4): 103 | transport = SyncProxyTransport( 104 | proxy_type=ProxyType.SOCKS5, 105 | proxy_host=PROXY_HOST_IPV4, 106 | proxy_port=SOCKS5_PROXY_PORT, 107 | username=LOGIN, 108 | password=PASSWORD, 109 | verify=create_ssl_context(url, ca=target_ssl_ca), 110 | ) 111 | timeout = httpx.Timeout(32, connect=0.001) 112 | with pytest.raises(ProxyTimeoutError): 113 | fetch(transport=transport, url=url, timeout=timeout) 114 | 115 | 116 | def test_socks5_proxy_with_invalid_proxy_port(unused_tcp_port, target_ssl_ca, url=TEST_URL_IPV4): 117 | transport = SyncProxyTransport( 118 | proxy_type=ProxyType.SOCKS5, 119 | proxy_host=PROXY_HOST_IPV4, 120 | proxy_port=unused_tcp_port, 121 | username=LOGIN, 122 | password=PASSWORD, 123 | verify=create_ssl_context(url, target_ssl_ca), 124 | ) 125 | with pytest.raises(ProxyConnectionError): 126 | fetch(transport=transport, url=url) 127 | 128 | 129 | @pytest.mark.parametrize('url', (TEST_URL_IPV4, TEST_URL_IPV4_HTTPS)) 130 | @pytest.mark.skipif(SKIP_IPV6_TESTS, reason="TravisCI doesn't support ipv6") 131 | def test_socks5_proxy_ipv6(url, target_ssl_ca): 132 | transport = SyncProxyTransport.from_url( 133 | SOCKS5_IPV6_URL, verify=create_ssl_context(url, ca=target_ssl_ca) 134 | ) 135 | res = fetch(transport=transport, url=url) 136 | assert res.status_code == 200 137 | 138 | 139 | @pytest.mark.parametrize('url', (TEST_URL_IPV4, TEST_URL_IPV4_HTTPS)) 140 | @pytest.mark.parametrize('rdns', (True, False)) 141 | def test_socks4_proxy(url, rdns, target_ssl_ca): 142 | transport = SyncProxyTransport.from_url( 143 | SOCKS4_URL, rdns=rdns, verify=create_ssl_context(url, ca=target_ssl_ca) 144 | ) 145 | res = fetch(transport=transport, url=url) 146 | assert res.status_code == 200 147 | 148 | 149 | @pytest.mark.parametrize('url', (TEST_URL_IPV4, TEST_URL_IPV4_HTTPS)) 150 | def test_http_proxy(url, target_ssl_ca): 151 | transport = SyncProxyTransport.from_url( 152 | HTTP_PROXY_URL, verify=create_ssl_context(url, ca=target_ssl_ca) 153 | ) 154 | res = fetch(transport=transport, url=url) 155 | assert res.status_code == 200 156 | 157 | 158 | @pytest.mark.parametrize('url', (TEST_URL_IPV4, TEST_URL_IPV4_HTTPS)) 159 | @pytest.mark.parametrize('http2', (False, True)) 160 | def test_secure_proxy(url, target_ssl_ca, proxy_ssl_context, http2): 161 | transport = SyncProxyTransport.from_url( 162 | HTTPS_PROXY_URL, 163 | proxy_ssl=proxy_ssl_context, 164 | http2=http2, 165 | verify=create_ssl_context(url, ca=target_ssl_ca, http2=http2), 166 | ) 167 | res = fetch(transport=transport, url=url) 168 | assert res.status_code == 200 169 | 170 | 171 | def test_proxy_http2(target_ssl_ca): 172 | url = TEST_URL_IPV4_HTTPS 173 | proxy_url = HTTP_PROXY_URL 174 | ssl_context = create_ssl_context(url, ca=target_ssl_ca, http2=True) 175 | 176 | transport = SyncProxyTransport.from_url(proxy_url, verify=ssl_context, http2=True) 177 | res = fetch(transport=transport, url=url) 178 | assert res.status_code == 200 179 | assert res.http_version == 'HTTP/2' 180 | 181 | 182 | def test_failed_proxy_connection(): 183 | url = TEST_URL_IPV4_HTTPS 184 | proxy_url = HTTP_PROXY_URL 185 | 186 | transport = SyncProxyTransport.from_url(proxy_url) 187 | with httpx.Client(transport=transport) as client: 188 | with mock.patch( 189 | 'httpx_socks._sync_proxy.SyncProxyConnection._connect_via_proxy', 190 | side_effect=Exception, 191 | ): 192 | with pytest.raises(Exception): 193 | client.get(url=url) 194 | 195 | assert len(client._transport._pool._connections) == 0 196 | -------------------------------------------------------------------------------- /httpx_socks/_sync_proxy.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | 3 | from httpcore import ( 4 | ConnectionPool, 5 | Origin, 6 | ConnectionInterface, 7 | Request, 8 | Response, 9 | default_ssl_context, 10 | HTTP11Connection, 11 | ConnectionNotAvailable, 12 | ) 13 | 14 | # from httpcore.backends.sync import SyncStream 15 | from ._sync_stream import SyncStream 16 | from httpcore._synchronization import Lock 17 | 18 | from python_socks import ProxyType, parse_proxy_url 19 | from python_socks.sync.v2 import Proxy 20 | 21 | 22 | class SyncProxy(ConnectionPool): 23 | def __init__( 24 | self, 25 | *, 26 | proxy_type: ProxyType, 27 | proxy_host: str, 28 | proxy_port: int, 29 | username=None, 30 | password=None, 31 | rdns=None, 32 | proxy_ssl: ssl.SSLContext = None, 33 | **kwargs, 34 | ): 35 | self._proxy_type = proxy_type 36 | self._proxy_host = proxy_host 37 | self._proxy_port = proxy_port 38 | self._username = username 39 | self._password = password 40 | self._rdns = rdns 41 | self._proxy_ssl = proxy_ssl 42 | 43 | super().__init__(**kwargs) 44 | 45 | def create_connection(self, origin: Origin) -> ConnectionInterface: 46 | return SyncProxyConnection( 47 | proxy_type=self._proxy_type, 48 | proxy_host=self._proxy_host, 49 | proxy_port=self._proxy_port, 50 | username=self._username, 51 | password=self._password, 52 | rdns=self._rdns, 53 | proxy_ssl=self._proxy_ssl, 54 | remote_origin=origin, 55 | ssl_context=self._ssl_context, 56 | keepalive_expiry=self._keepalive_expiry, 57 | http1=self._http1, 58 | http2=self._http2, 59 | ) 60 | 61 | @classmethod 62 | def from_url(cls, url, **kwargs): 63 | proxy_type, host, port, username, password = parse_proxy_url(url) 64 | return cls( 65 | proxy_type=proxy_type, 66 | proxy_host=host, 67 | proxy_port=port, 68 | username=username, 69 | password=password, 70 | **kwargs, 71 | ) 72 | 73 | 74 | class SyncProxyConnection(ConnectionInterface): 75 | def __init__( 76 | self, 77 | *, 78 | proxy_type: ProxyType, 79 | proxy_host: str, 80 | proxy_port: int, 81 | username=None, 82 | password=None, 83 | rdns=None, 84 | proxy_ssl: ssl.SSLContext = None, 85 | remote_origin: Origin, 86 | ssl_context: ssl.SSLContext, 87 | keepalive_expiry: float = None, 88 | http1: bool = True, 89 | http2: bool = False, 90 | ) -> None: 91 | 92 | if ssl_context is None: # pragma: no cover 93 | ssl_context = default_ssl_context() 94 | 95 | self._proxy_type = proxy_type 96 | self._proxy_host = proxy_host 97 | self._proxy_port = proxy_port 98 | self._username = username 99 | self._password = password 100 | self._rdns = rdns 101 | self._proxy_ssl = proxy_ssl 102 | 103 | self._remote_origin = remote_origin 104 | self._ssl_context = ssl_context 105 | self._keepalive_expiry = keepalive_expiry 106 | self._http1 = http1 107 | self._http2 = http2 108 | 109 | self._connect_lock = Lock() 110 | self._connection = None 111 | self._connect_failed: bool = False 112 | 113 | def handle_request(self, request: Request) -> Response: 114 | timeouts = request.extensions.get('timeout', {}) 115 | timeout = timeouts.get('connect', None) 116 | 117 | try: 118 | with self._connect_lock: 119 | if self._connection is None: 120 | 121 | if self._ssl_context is not None: 122 | alpn_protocols = ["http/1.1", "h2"] if self._http2 else ["http/1.1"] 123 | self._ssl_context.set_alpn_protocols(alpn_protocols) 124 | 125 | stream = self._connect_via_proxy( 126 | origin=self._remote_origin, 127 | connect_timeout=timeout, 128 | ) 129 | 130 | ssl_object = stream.get_extra_info('ssl_object') 131 | http2_negotiated = ( 132 | ssl_object is not None and ssl_object.selected_alpn_protocol() == "h2" 133 | ) 134 | if http2_negotiated or (self._http2 and not self._http1): 135 | from httpcore import HTTP2Connection 136 | 137 | self._connection = HTTP2Connection( 138 | origin=self._remote_origin, 139 | stream=stream, 140 | keepalive_expiry=self._keepalive_expiry, 141 | ) 142 | else: 143 | self._connection = HTTP11Connection( 144 | origin=self._remote_origin, 145 | stream=stream, 146 | keepalive_expiry=self._keepalive_expiry, 147 | ) 148 | elif not self._connection.is_available(): # pragma: no cover 149 | raise ConnectionNotAvailable() 150 | except BaseException as exc: 151 | self._connect_failed = True 152 | raise exc 153 | 154 | return self._connection.handle_request(request) 155 | 156 | def _connect_via_proxy(self, origin: Origin, connect_timeout: int): 157 | scheme, hostname, port = origin.scheme, origin.host, origin.port 158 | 159 | ssl_context = self._ssl_context if scheme == b'https' else None 160 | host = hostname.decode('ascii') 161 | 162 | proxy = Proxy.create( 163 | proxy_type=self._proxy_type, 164 | host=self._proxy_host, 165 | port=self._proxy_port, 166 | username=self._username, 167 | password=self._password, 168 | rdns=self._rdns, 169 | proxy_ssl=self._proxy_ssl, 170 | ) 171 | 172 | proxy_stream = proxy.connect( 173 | host, 174 | port, 175 | dest_ssl=ssl_context, 176 | timeout=connect_timeout, 177 | ) 178 | 179 | return SyncStream(sock=proxy_stream.socket) 180 | 181 | def close(self) -> None: 182 | if self._connection is not None: 183 | self._connection.close() 184 | 185 | def can_handle_request(self, origin: Origin) -> bool: 186 | return origin == self._remote_origin 187 | 188 | def is_available(self) -> bool: 189 | if self._connection is None: # pragma: no cover 190 | # return self._http2 and (self._remote_origin.scheme == b"https" or not self._http1) 191 | return False 192 | return self._connection.is_available() 193 | 194 | def has_expired(self) -> bool: 195 | if self._connection is None: 196 | return self._connect_failed 197 | return self._connection.has_expired() 198 | 199 | def is_idle(self) -> bool: 200 | if self._connection is None: 201 | return self._connect_failed 202 | return self._connection.is_idle() 203 | 204 | def is_closed(self) -> bool: 205 | if self._connection is None: 206 | return self._connect_failed 207 | return self._connection.is_closed() 208 | 209 | def info(self) -> str: # pragma: no cover 210 | if self._connection is None: 211 | return "CONNECTION FAILED" if self._connect_failed else "CONNECTING" 212 | return self._connection.info() 213 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | from unittest import mock 3 | 4 | import pytest 5 | import trustme 6 | from python_socks.async_.anyio._resolver import Resolver as AnyioResolver 7 | from python_socks.async_.asyncio._resolver import Resolver as AsyncioResolver 8 | from python_socks.async_.trio._resolver import Resolver as TrioResolver 9 | from python_socks.sync._resolver import SyncResolver 10 | 11 | from tests.config import ( 12 | PROXY_HOST_IPV4, 13 | PROXY_HOST_IPV6, 14 | SOCKS5_PROXY_PORT, 15 | LOGIN, 16 | PASSWORD, 17 | SKIP_IPV6_TESTS, 18 | HTTP_PROXY_PORT, 19 | SOCKS4_PORT_NO_AUTH, 20 | SOCKS4_PROXY_PORT, 21 | SOCKS5_PROXY_PORT_NO_AUTH, 22 | TEST_PORT_IPV4, 23 | TEST_PORT_IPV6, 24 | TEST_HOST_IPV4, 25 | TEST_HOST_IPV6, 26 | TEST_PORT_IPV4_HTTPS, 27 | HTTPS_PROXY_PORT, 28 | TEST_HOST_NAME_IPV4, 29 | TEST_HOST_NAME_IPV6, 30 | PROXY_HOST_NAME_IPV4, 31 | PROXY_HOST_NAME_IPV6, 32 | ) 33 | from tests.http_server import HttpServer, HttpServerConfig 34 | from tests.mocks import ( 35 | sync_resolve_factory, 36 | async_resolve_factory, 37 | getaddrinfo_sync_mock, 38 | getaddrinfo_async_mock, 39 | ) 40 | from tests.proxy_server import ProxyConfig, ProxyServer 41 | from tests.utils import wait_until_connectable 42 | 43 | 44 | @pytest.fixture(scope='session') 45 | def target_ssl_ca() -> trustme.CA: 46 | return trustme.CA() 47 | 48 | 49 | @pytest.fixture(scope='session') 50 | def target_ssl_cert(target_ssl_ca) -> trustme.LeafCert: 51 | return target_ssl_ca.issue_cert( 52 | 'localhost', 53 | TEST_HOST_IPV4, 54 | TEST_HOST_IPV6, 55 | TEST_HOST_NAME_IPV4, 56 | TEST_HOST_NAME_IPV6, 57 | ) 58 | 59 | 60 | @pytest.fixture(scope='session') 61 | def target_ssl_certfile(target_ssl_cert): 62 | with target_ssl_cert.cert_chain_pems[0].tempfile() as cert_path: 63 | yield cert_path 64 | 65 | 66 | @pytest.fixture(scope='session') 67 | def target_ssl_keyfile(target_ssl_cert): 68 | with target_ssl_cert.private_key_pem.tempfile() as private_key_path: 69 | yield private_key_path 70 | 71 | 72 | @pytest.fixture(scope='session') 73 | def target_ssl_context(target_ssl_ca) -> ssl.SSLContext: 74 | ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) 75 | ssl_ctx.verify_mode = ssl.CERT_REQUIRED 76 | ssl_ctx.check_hostname = True 77 | target_ssl_ca.configure_trust(ssl_ctx) 78 | return ssl_ctx 79 | 80 | 81 | @pytest.fixture(scope='session') 82 | def proxy_ssl_ca() -> trustme.CA: 83 | return trustme.CA() 84 | 85 | 86 | @pytest.fixture(scope='session') 87 | def proxy_ssl_cert(proxy_ssl_ca) -> trustme.LeafCert: 88 | return proxy_ssl_ca.issue_cert( 89 | 'localhost', 90 | PROXY_HOST_IPV4, 91 | PROXY_HOST_IPV6, 92 | PROXY_HOST_NAME_IPV4, 93 | PROXY_HOST_NAME_IPV6, 94 | ) 95 | 96 | 97 | @pytest.fixture(scope='session') 98 | def proxy_ssl_context(proxy_ssl_ca) -> ssl.SSLContext: 99 | ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) 100 | ssl_ctx.verify_mode = ssl.CERT_REQUIRED 101 | ssl_ctx.check_hostname = True 102 | proxy_ssl_ca.configure_trust(ssl_ctx) 103 | return ssl_ctx 104 | 105 | 106 | @pytest.fixture(scope='session') 107 | def proxy_ssl_certfile(proxy_ssl_cert): 108 | with proxy_ssl_cert.cert_chain_pems[0].tempfile() as cert_path: 109 | yield cert_path 110 | 111 | 112 | @pytest.fixture(scope='session') 113 | def proxy_ssl_keyfile(proxy_ssl_cert): 114 | with proxy_ssl_cert.private_key_pem.tempfile() as private_key_path: 115 | yield private_key_path 116 | 117 | 118 | @pytest.fixture(scope='session', autouse=True) 119 | def patch_socket_getaddrinfo(): 120 | with mock.patch('socket.getaddrinfo', new=getaddrinfo_sync_mock()): 121 | yield None 122 | 123 | 124 | @pytest.fixture(scope='session', autouse=True) 125 | def patch_anyio_getaddrinfo(): 126 | import anyio 127 | 128 | with mock.patch( 129 | 'anyio._core._sockets.getaddrinfo', 130 | new=getaddrinfo_async_mock(anyio.getaddrinfo), 131 | ): 132 | yield None 133 | 134 | 135 | @pytest.fixture(scope='session', autouse=True) 136 | def patch_resolvers(): 137 | p1 = mock.patch.object( 138 | SyncResolver, attribute='resolve', new=sync_resolve_factory(SyncResolver) 139 | ) 140 | 141 | p2 = mock.patch.object( 142 | AsyncioResolver, attribute='resolve', new=async_resolve_factory(AsyncioResolver) 143 | ) 144 | 145 | p3 = mock.patch.object( 146 | TrioResolver, attribute='resolve', new=async_resolve_factory(TrioResolver) 147 | ) 148 | 149 | p4 = mock.patch.object( 150 | AnyioResolver, attribute='resolve', new=async_resolve_factory(AnyioResolver) 151 | ) 152 | 153 | with p1, p2, p3, p4: 154 | yield None 155 | 156 | 157 | @pytest.fixture(scope='session', autouse=True) 158 | def proxy_server(proxy_ssl_certfile, proxy_ssl_keyfile): 159 | config = [ 160 | ProxyConfig( 161 | proxy_type='http', 162 | host=PROXY_HOST_IPV4, 163 | port=HTTP_PROXY_PORT, 164 | username=LOGIN, 165 | password=PASSWORD, 166 | ), 167 | ProxyConfig( 168 | proxy_type='socks4', 169 | host=PROXY_HOST_IPV4, 170 | port=SOCKS4_PROXY_PORT, 171 | username=LOGIN, 172 | password=None, 173 | ), 174 | ProxyConfig( 175 | proxy_type='socks4', 176 | host=PROXY_HOST_IPV4, 177 | port=SOCKS4_PORT_NO_AUTH, 178 | username=None, 179 | password=None, 180 | ), 181 | ProxyConfig( 182 | proxy_type='socks5', 183 | host=PROXY_HOST_IPV4, 184 | port=SOCKS5_PROXY_PORT, 185 | username=LOGIN, 186 | password=PASSWORD, 187 | ), 188 | ProxyConfig( 189 | proxy_type='socks5', 190 | host=PROXY_HOST_IPV4, 191 | port=SOCKS5_PROXY_PORT_NO_AUTH, 192 | username=None, 193 | password=None, 194 | ), 195 | ProxyConfig( 196 | proxy_type='http', 197 | host=PROXY_HOST_IPV4, 198 | port=HTTPS_PROXY_PORT, 199 | username=LOGIN, 200 | password=PASSWORD, 201 | ssl_certfile=proxy_ssl_certfile, 202 | ssl_keyfile=proxy_ssl_keyfile, 203 | ), 204 | ] 205 | 206 | if not SKIP_IPV6_TESTS: 207 | config.append( 208 | ProxyConfig( 209 | proxy_type='socks5', 210 | host=PROXY_HOST_IPV6, 211 | port=SOCKS5_PROXY_PORT, 212 | username=LOGIN, 213 | password=PASSWORD, 214 | ), 215 | ) 216 | 217 | server = ProxyServer(config=config) 218 | server.start() 219 | for cfg in config: 220 | wait_until_connectable(host=cfg.host, port=cfg.port, timeout=10) 221 | 222 | yield None 223 | 224 | server.terminate() 225 | 226 | 227 | @pytest.fixture(scope='session', autouse=True) 228 | def web_server(target_ssl_certfile, target_ssl_keyfile): 229 | config = [ 230 | HttpServerConfig( 231 | host=TEST_HOST_IPV4, 232 | port=TEST_PORT_IPV4, 233 | ), 234 | HttpServerConfig( 235 | host=TEST_HOST_IPV4, 236 | port=TEST_PORT_IPV4_HTTPS, 237 | certfile=target_ssl_certfile, 238 | keyfile=target_ssl_keyfile, 239 | ), 240 | ] 241 | 242 | if not SKIP_IPV6_TESTS: 243 | config.append(HttpServerConfig(host=TEST_HOST_IPV6, port=TEST_PORT_IPV6)) 244 | 245 | server = HttpServer(config=config) 246 | server.start() 247 | for cfg in config: 248 | server.wait_until_connectable(host=cfg.host, port=cfg.port) 249 | 250 | yield None 251 | 252 | server.terminate() 253 | -------------------------------------------------------------------------------- /tests/test_transport_asyncio.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | from unittest import mock 3 | 4 | import httpx 5 | import pytest 6 | from yarl import URL 7 | 8 | from httpx_socks import ( 9 | ProxyType, 10 | AsyncProxyTransport, 11 | ProxyError, 12 | ProxyConnectionError, 13 | ProxyTimeoutError, 14 | ) 15 | from httpx_socks._async_proxy import AsyncProxy 16 | from tests.config import ( 17 | TEST_URL_IPV4, 18 | TEST_URL_IPV4_HTTPS, 19 | SOCKS5_IPV4_URL, 20 | LOGIN, 21 | PASSWORD, 22 | PROXY_HOST_IPV4, 23 | SOCKS5_PROXY_PORT, 24 | TEST_URL_IPV4_DELAY, 25 | SKIP_IPV6_TESTS, 26 | SOCKS5_IPV6_URL, 27 | SOCKS4_URL, 28 | HTTP_PROXY_URL, 29 | SOCKS5_IPV4_HOSTNAME_URL, 30 | HTTPS_PROXY_URL, 31 | ) 32 | 33 | 34 | def create_ssl_context(url, ca, http2=False): 35 | parsed_url = URL(url) 36 | if parsed_url.scheme == 'https': 37 | ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) 38 | ssl_context.verify_mode = ssl.CERT_REQUIRED 39 | # ssl_context.load_verify_locations(TEST_HOST_PEM_FILE) 40 | alpn_protocols = ['http/1.1', 'h2'] if http2 else ['http/1.1'] 41 | ssl_context.set_alpn_protocols(alpn_protocols) 42 | ca.configure_trust(ssl_context) 43 | return ssl_context 44 | else: 45 | return None 46 | 47 | 48 | async def fetch( 49 | transport: AsyncProxyTransport, 50 | url: str, 51 | timeout: httpx.Timeout = None, 52 | ): 53 | async with httpx.AsyncClient(transport=transport) as client: 54 | res = await client.get(url=url, timeout=timeout) 55 | return res 56 | 57 | 58 | @pytest.mark.parametrize('proxy_url', (SOCKS5_IPV4_URL, SOCKS5_IPV4_HOSTNAME_URL, HTTP_PROXY_URL)) 59 | @pytest.mark.parametrize('target_url', (TEST_URL_IPV4, TEST_URL_IPV4_HTTPS)) 60 | @pytest.mark.asyncio 61 | async def test_proxy_direct(proxy_url, target_url, target_ssl_ca): 62 | ssl_context = create_ssl_context(url=target_url, ca=target_ssl_ca) 63 | async with AsyncProxy.from_url(proxy_url, ssl_context=ssl_context) as proxy: 64 | res = await proxy.request(method="GET", url=target_url) 65 | assert res.status == 200 66 | res = await proxy.request(method="GET", url=target_url) 67 | assert res.status == 200 68 | 69 | 70 | @pytest.mark.parametrize('url', (TEST_URL_IPV4, TEST_URL_IPV4_HTTPS)) 71 | @pytest.mark.parametrize('rdns', (True, False)) 72 | @pytest.mark.asyncio 73 | async def test_socks5_proxy_ipv4(url, rdns, target_ssl_ca): 74 | transport = AsyncProxyTransport.from_url( 75 | SOCKS5_IPV4_URL, rdns=rdns, verify=create_ssl_context(url, ca=target_ssl_ca) 76 | ) 77 | res = await fetch(transport=transport, url=url) 78 | assert res.status_code == 200 79 | 80 | 81 | @pytest.mark.asyncio 82 | async def test_socks5_proxy_with_invalid_credentials(target_ssl_ca, url=TEST_URL_IPV4): 83 | transport = AsyncProxyTransport( 84 | proxy_type=ProxyType.SOCKS5, 85 | proxy_host=PROXY_HOST_IPV4, 86 | proxy_port=SOCKS5_PROXY_PORT, 87 | username=LOGIN, 88 | password=PASSWORD + 'aaa', 89 | verify=create_ssl_context(url, ca=target_ssl_ca), 90 | ) 91 | with pytest.raises(ProxyError): 92 | await fetch(transport=transport, url=url) 93 | 94 | 95 | @pytest.mark.asyncio 96 | async def test_socks5_proxy_with_read_timeout(target_ssl_ca, url=TEST_URL_IPV4_DELAY): 97 | transport = AsyncProxyTransport( 98 | proxy_type=ProxyType.SOCKS5, 99 | proxy_host=PROXY_HOST_IPV4, 100 | proxy_port=SOCKS5_PROXY_PORT, 101 | username=LOGIN, 102 | password=PASSWORD, 103 | verify=create_ssl_context(url, ca=target_ssl_ca), 104 | ) 105 | timeout = httpx.Timeout(2, connect=32) 106 | # with pytest.raises(httpcore.ReadTimeout): 107 | with pytest.raises(httpx.ReadTimeout): 108 | await fetch(transport=transport, url=url, timeout=timeout) 109 | 110 | 111 | @pytest.mark.asyncio 112 | async def test_socks5_proxy_with_connect_timeout(target_ssl_ca, url=TEST_URL_IPV4): 113 | transport = AsyncProxyTransport( 114 | proxy_type=ProxyType.SOCKS5, 115 | proxy_host=PROXY_HOST_IPV4, 116 | proxy_port=SOCKS5_PROXY_PORT, 117 | username=LOGIN, 118 | password=PASSWORD, 119 | verify=create_ssl_context(url, ca=target_ssl_ca), 120 | ) 121 | timeout = httpx.Timeout(32, connect=0.001) 122 | with pytest.raises(ProxyTimeoutError): 123 | await fetch(transport=transport, url=url, timeout=timeout) 124 | 125 | 126 | @pytest.mark.asyncio 127 | async def test_socks5_proxy_with_invalid_proxy_port( 128 | unused_tcp_port, 129 | target_ssl_ca, 130 | url=TEST_URL_IPV4, 131 | ): 132 | transport = AsyncProxyTransport( 133 | proxy_type=ProxyType.SOCKS5, 134 | proxy_host=PROXY_HOST_IPV4, 135 | proxy_port=unused_tcp_port, 136 | username=LOGIN, 137 | password=PASSWORD, 138 | verify=create_ssl_context(url, ca=target_ssl_ca), 139 | ) 140 | with pytest.raises(ProxyConnectionError): 141 | await fetch(transport=transport, url=url) 142 | 143 | 144 | @pytest.mark.parametrize('url', (TEST_URL_IPV4, TEST_URL_IPV4_HTTPS)) 145 | @pytest.mark.skipif(SKIP_IPV6_TESTS, reason="TravisCI doesn't support ipv6") 146 | @pytest.mark.asyncio 147 | async def test_socks5_proxy_ipv6(url, target_ssl_ca): 148 | transport = AsyncProxyTransport.from_url( 149 | SOCKS5_IPV6_URL, verify=create_ssl_context(url, ca=target_ssl_ca) 150 | ) 151 | res = await fetch(transport=transport, url=url) 152 | assert res.status_code == 200 153 | 154 | 155 | @pytest.mark.parametrize('url', (TEST_URL_IPV4, TEST_URL_IPV4_HTTPS)) 156 | @pytest.mark.parametrize('rdns', (True, False)) 157 | @pytest.mark.asyncio 158 | async def test_socks4_proxy(url, rdns, target_ssl_ca): 159 | transport = AsyncProxyTransport.from_url( 160 | SOCKS4_URL, rdns=rdns, verify=create_ssl_context(url, ca=target_ssl_ca) 161 | ) 162 | res = await fetch(transport=transport, url=url) 163 | assert res.status_code == 200 164 | 165 | 166 | @pytest.mark.parametrize('url', (TEST_URL_IPV4, TEST_URL_IPV4_HTTPS)) 167 | @pytest.mark.asyncio 168 | async def test_http_proxy(url, target_ssl_ca): 169 | transport = AsyncProxyTransport.from_url( 170 | HTTP_PROXY_URL, verify=create_ssl_context(url, ca=target_ssl_ca) 171 | ) 172 | res = await fetch(transport=transport, url=url) 173 | assert res.status_code == 200 174 | 175 | 176 | @pytest.mark.parametrize('url', (TEST_URL_IPV4, TEST_URL_IPV4_HTTPS)) 177 | @pytest.mark.parametrize('http2', (False, True)) 178 | @pytest.mark.asyncio 179 | async def test_secure_proxy(url, http2, proxy_ssl_context, target_ssl_ca): 180 | transport = AsyncProxyTransport.from_url( 181 | HTTPS_PROXY_URL, 182 | proxy_ssl=proxy_ssl_context, 183 | http2=http2, 184 | verify=create_ssl_context(url, ca=target_ssl_ca, http2=http2), 185 | ) 186 | res = await fetch(transport=transport, url=url) 187 | assert res.status_code == 200 188 | 189 | 190 | @pytest.mark.asyncio 191 | async def test_proxy_http2(target_ssl_ca): 192 | url = TEST_URL_IPV4_HTTPS 193 | proxy_url = HTTP_PROXY_URL 194 | ssl_context = create_ssl_context(url, ca=target_ssl_ca, http2=True) 195 | 196 | transport = AsyncProxyTransport.from_url(proxy_url, verify=ssl_context, http2=True) 197 | res = await fetch(transport=transport, url=url) 198 | assert res.status_code == 200 199 | assert res.http_version == 'HTTP/2' 200 | 201 | 202 | @pytest.mark.asyncio 203 | async def test_failed_proxy_connection(): 204 | url = TEST_URL_IPV4_HTTPS 205 | proxy_url = HTTP_PROXY_URL 206 | 207 | transport = AsyncProxyTransport.from_url(proxy_url) 208 | async with httpx.AsyncClient(transport=transport) as client: 209 | with mock.patch( 210 | 'httpx_socks._async_proxy.AsyncProxyConnection._connect_via_proxy', 211 | new=mock.AsyncMock(side_effect=Exception), 212 | ): 213 | with pytest.raises(Exception): 214 | await client.get(url=url) 215 | 216 | assert len(client._transport._pool._connections) == 0 217 | -------------------------------------------------------------------------------- /httpx_socks/_async_proxy.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | 3 | import sniffio 4 | from httpcore import ( 5 | AsyncConnectionPool, 6 | Origin, 7 | AsyncConnectionInterface, 8 | Request, 9 | Response, 10 | default_ssl_context, 11 | AsyncHTTP11Connection, 12 | ConnectionNotAvailable, 13 | ) 14 | from httpcore import AsyncNetworkStream 15 | from httpcore._synchronization import AsyncLock 16 | from python_socks import ProxyType, parse_proxy_url 17 | 18 | 19 | class AsyncProxy(AsyncConnectionPool): 20 | def __init__( 21 | self, 22 | *, 23 | proxy_type: ProxyType, 24 | proxy_host: str, 25 | proxy_port: int, 26 | username=None, 27 | password=None, 28 | rdns=None, 29 | proxy_ssl: ssl.SSLContext = None, 30 | loop=None, 31 | **kwargs, 32 | ): 33 | self._proxy_type = proxy_type 34 | self._proxy_host = proxy_host 35 | self._proxy_port = proxy_port 36 | self._username = username 37 | self._password = password 38 | self._rdns = rdns 39 | self._proxy_ssl = proxy_ssl 40 | self._loop = loop 41 | 42 | super().__init__(**kwargs) 43 | 44 | def create_connection(self, origin: Origin) -> AsyncConnectionInterface: 45 | return AsyncProxyConnection( 46 | proxy_type=self._proxy_type, 47 | proxy_host=self._proxy_host, 48 | proxy_port=self._proxy_port, 49 | username=self._username, 50 | password=self._password, 51 | rdns=self._rdns, 52 | proxy_ssl=self._proxy_ssl, 53 | loop=self._loop, 54 | remote_origin=origin, 55 | ssl_context=self._ssl_context, 56 | keepalive_expiry=self._keepalive_expiry, 57 | http1=self._http1, 58 | http2=self._http2, 59 | ) 60 | 61 | @classmethod 62 | def from_url(cls, url, **kwargs): 63 | proxy_type, host, port, username, password = parse_proxy_url(url) 64 | return cls( 65 | proxy_type=proxy_type, 66 | proxy_host=host, 67 | proxy_port=port, 68 | username=username, 69 | password=password, 70 | **kwargs, 71 | ) 72 | 73 | 74 | class AsyncProxyConnection(AsyncConnectionInterface): 75 | def __init__( 76 | self, 77 | *, 78 | proxy_type: ProxyType, 79 | proxy_host: str, 80 | proxy_port: int, 81 | username=None, 82 | password=None, 83 | rdns=None, 84 | proxy_ssl: ssl.SSLContext = None, 85 | loop=None, 86 | remote_origin: Origin, 87 | ssl_context: ssl.SSLContext, 88 | keepalive_expiry: float = None, 89 | http1: bool = True, 90 | http2: bool = False, 91 | ) -> None: 92 | 93 | if ssl_context is None: # pragma: no cover 94 | ssl_context = default_ssl_context() 95 | 96 | self._proxy_type = proxy_type 97 | self._proxy_host = proxy_host 98 | self._proxy_port = proxy_port 99 | self._username = username 100 | self._password = password 101 | self._rdns = rdns 102 | self._proxy_ssl = proxy_ssl 103 | self._loop = loop 104 | 105 | self._remote_origin = remote_origin 106 | self._ssl_context = ssl_context 107 | self._keepalive_expiry = keepalive_expiry 108 | self._http1 = http1 109 | self._http2 = http2 110 | 111 | self._connect_lock = AsyncLock() 112 | self._connection = None 113 | self._connect_failed: bool = False 114 | 115 | async def handle_async_request(self, request: Request) -> Response: 116 | timeouts = request.extensions.get('timeout', {}) 117 | timeout = timeouts.get('connect', None) 118 | 119 | try: 120 | async with self._connect_lock: 121 | if self._connection is None: 122 | 123 | if self._ssl_context is not None: 124 | alpn_protocols = ["http/1.1", "h2"] if self._http2 else ["http/1.1"] 125 | self._ssl_context.set_alpn_protocols(alpn_protocols) 126 | 127 | stream = await self._connect_via_proxy( 128 | origin=self._remote_origin, 129 | connect_timeout=timeout, 130 | ) 131 | 132 | ssl_object = stream.get_extra_info("ssl_object") 133 | http2_negotiated = ( 134 | ssl_object is not None and ssl_object.selected_alpn_protocol() == "h2" 135 | ) 136 | if http2_negotiated or (self._http2 and not self._http1): 137 | from httpcore import AsyncHTTP2Connection 138 | 139 | self._connection = AsyncHTTP2Connection( 140 | origin=self._remote_origin, 141 | stream=stream, 142 | keepalive_expiry=self._keepalive_expiry, 143 | ) 144 | else: 145 | self._connection = AsyncHTTP11Connection( 146 | origin=self._remote_origin, 147 | stream=stream, 148 | keepalive_expiry=self._keepalive_expiry, 149 | ) 150 | elif not self._connection.is_available(): # pragma: no cover 151 | raise ConnectionNotAvailable() 152 | except BaseException as exc: 153 | self._connect_failed = True 154 | raise exc 155 | 156 | return await self._connection.handle_async_request(request) 157 | 158 | async def _connect_via_proxy(self, origin, connect_timeout) -> AsyncNetworkStream: 159 | scheme, hostname, port = origin.scheme, origin.host, origin.port 160 | 161 | ssl_context = self._ssl_context if scheme == b'https' else None 162 | host = hostname.decode('ascii') # ? 163 | 164 | return await self._open_stream( 165 | host=host, 166 | port=port, 167 | connect_timeout=connect_timeout, 168 | ssl_context=ssl_context, 169 | ) 170 | 171 | async def _open_stream(self, host, port, connect_timeout, ssl_context): 172 | backend = sniffio.current_async_library() 173 | 174 | if backend == 'asyncio': 175 | return await self._open_aio_stream(host, port, connect_timeout, ssl_context) 176 | 177 | if backend == 'trio': 178 | return await self._open_trio_stream(host, port, connect_timeout, ssl_context) 179 | 180 | # Curio support has been dropped in httpcore 0.14.0 181 | # if backend == 'curio': 182 | # return await self._open_curio_stream(host, port, connect_timeout, ssl_context) 183 | 184 | raise RuntimeError(f'Unsupported concurrency backend {backend!r}') # pragma: no cover 185 | 186 | async def _open_aio_stream(self, host, port, connect_timeout, ssl_context): 187 | from httpcore._backends.anyio import AnyIOStream 188 | from python_socks.async_.anyio import Proxy 189 | 190 | proxy = Proxy.create( 191 | proxy_type=self._proxy_type, 192 | host=self._proxy_host, 193 | port=self._proxy_port, 194 | username=self._username, 195 | password=self._password, 196 | rdns=self._rdns, 197 | proxy_ssl=self._proxy_ssl, 198 | ) 199 | 200 | proxy_stream = await proxy.connect( 201 | host, 202 | port, 203 | dest_ssl=ssl_context, 204 | timeout=connect_timeout, 205 | ) 206 | 207 | return AnyIOStream(proxy_stream.anyio_stream) 208 | 209 | async def _open_trio_stream(self, host, port, connect_timeout, ssl_context): 210 | from httpcore._backends.trio import TrioStream 211 | from python_socks.async_.trio.v2 import Proxy 212 | 213 | proxy = Proxy.create( 214 | proxy_type=self._proxy_type, 215 | host=self._proxy_host, 216 | port=self._proxy_port, 217 | username=self._username, 218 | password=self._password, 219 | rdns=self._rdns, 220 | proxy_ssl=self._proxy_ssl, 221 | ) 222 | 223 | proxy_stream = await proxy.connect( 224 | host, 225 | port, 226 | dest_ssl=ssl_context, 227 | timeout=connect_timeout, 228 | ) 229 | 230 | return TrioStream(proxy_stream.trio_stream) 231 | 232 | async def aclose(self) -> None: 233 | if self._connection is not None: 234 | await self._connection.aclose() 235 | 236 | def can_handle_request(self, origin: Origin) -> bool: 237 | return origin == self._remote_origin 238 | 239 | def is_available(self) -> bool: 240 | if self._connection is None: # pragma: no cover 241 | # return self._http2 and (self._remote_origin.scheme == b"https" or not self._http1) 242 | return False 243 | return self._connection.is_available() 244 | 245 | def has_expired(self) -> bool: 246 | if self._connection is None: 247 | return self._connect_failed 248 | return self._connection.has_expired() 249 | 250 | def is_idle(self) -> bool: 251 | if self._connection is None: 252 | return self._connect_failed 253 | return self._connection.is_idle() 254 | 255 | def is_closed(self) -> bool: 256 | if self._connection is None: 257 | return self._connect_failed 258 | return self._connection.is_closed() 259 | 260 | def info(self) -> str: # pragma: no cover 261 | if self._connection is None: 262 | return "CONNECTION FAILED" if self._connect_failed else "CONNECTING" 263 | return self._connection.info() 264 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------