From 81510a0cec870c40a9d73adb2d453e6211ac39e9 Mon Sep 17 00:00:00 2001 From: dongfangtianyu <7629022+dongfangtianyu@users.noreply.github.com> Date: Fri, 12 Apr 2024 10:46:14 +0800 Subject: [PATCH 01/27] FIX proxy authentication bypass with HTTP/1.0 requests #1267 (#1342) * test: Add test case to reproduce bug #1267 * fix: Bypass proxy authentication with HTTP/1.0 requests #1267 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Abhinav Singh <126065+abhinavsingh@users.noreply.github.com> --- proxy/http/proxy/auth.py | 3 +- tests/plugin/test_http_proxy_plugins.py | 74 ++++++++++++++++++++++++- tests/plugin/utils.py | 3 + 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/proxy/http/proxy/auth.py b/proxy/http/proxy/auth.py index 0238d212d5..9bfcaf5bec 100644 --- a/proxy/http/proxy/auth.py +++ b/proxy/http/proxy/auth.py @@ -27,7 +27,8 @@ class AuthPlugin(HttpProxyBasePlugin): def before_upstream_connection( self, request: HttpParser, ) -> Optional[HttpParser]: - if self.flags.auth_code and request.headers: + if self.flags.auth_code: + request.headers = request.headers or {} if httpHeaders.PROXY_AUTHORIZATION not in request.headers: raise ProxyAuthenticationFailed() parts = request.headers[httpHeaders.PROXY_AUTHORIZATION][1].split() diff --git a/tests/plugin/test_http_proxy_plugins.py b/tests/plugin/test_http_proxy_plugins.py index 8726439c7c..f8da650157 100644 --- a/tests/plugin/test_http_proxy_plugins.py +++ b/tests/plugin/test_http_proxy_plugins.py @@ -10,6 +10,7 @@ """ import gzip import json +import base64 import selectors from typing import Any, cast from urllib import parse as urlparse @@ -29,7 +30,8 @@ from proxy.http.parser import HttpParser, httpParserTypes from proxy.common.utils import bytes_, build_http_request, build_http_response from proxy.http.responses import ( - NOT_FOUND_RESPONSE_PKT, PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT, + NOT_FOUND_RESPONSE_PKT, PROXY_AUTH_FAILED_RESPONSE_PKT, + PROXY_TUNNEL_ESTABLISHED_RESPONSE_PKT, ) from proxy.common.constants import DEFAULT_HTTP_PORT, PROXY_AGENT_HEADER_VALUE from .utils import get_plugin_by_test_name @@ -555,3 +557,73 @@ async def test_shortlink_plugin_external(self) -> None: ), ) self.assertFalse(self.protocol_handler.work.has_buffer()) + + @pytest.mark.asyncio # type: ignore[misc] + @pytest.mark.parametrize( + "_setUp", + ( + ('test_auth_plugin'), + ), + indirect=True, + ) # type: ignore[misc] + async def test_auth_plugin(self) -> None: + self.flags.auth_code = base64.b64encode(bytes_("admin:123456")) + + request = b'\r\n'.join([ + b'GET http://www.facebook.com/tr/ HTTP/1.1', + b'Host: www.facebook.com', + b'User-Agent: proxy.py v2.4.4rc5.dev3+g95b646a.d20230811', + b'', + b'', + ]) + + self._conn.recv.return_value = request + self.mock_selector.return_value.select.side_effect = [ + [( + selectors.SelectorKey( + fileobj=self._conn.fileno(), + fd=self._conn.fileno(), + events=selectors.EVENT_READ, + data=None, + ), + selectors.EVENT_READ, + )], + ] + await self.protocol_handler._run_once() + self.assertEqual( + self.protocol_handler.work.buffer[0], + PROXY_AUTH_FAILED_RESPONSE_PKT, + ) + + @pytest.mark.asyncio # type: ignore[misc] + @pytest.mark.parametrize( + "_setUp", + ( + ('test_auth_plugin'), + ), + indirect=True, + ) # type: ignore[misc] + async def test_auth_plugin_bypass(self) -> None: + self.flags.auth_code = base64.b64encode(bytes_("admin:123456")) + + # miss requests header when https and HTTP 1.0 + request = b'CONNECT www.facebook.com:443 HTTP/1.0\r\n\r\n' + + self._conn.recv.return_value = request + self.mock_selector.return_value.select.side_effect = [ + [( + selectors.SelectorKey( + fileobj=self._conn.fileno(), + fd=self._conn.fileno(), + events=selectors.EVENT_READ, + data=None, + ), + selectors.EVENT_READ, + )], + ] + await self.protocol_handler._run_once() + + self.assertEqual( + self.protocol_handler.work.buffer[0], + PROXY_AUTH_FAILED_RESPONSE_PKT, + ) diff --git a/tests/plugin/utils.py b/tests/plugin/utils.py index 400e874d53..4176467a85 100644 --- a/tests/plugin/utils.py +++ b/tests/plugin/utils.py @@ -16,6 +16,7 @@ FilterByUpstreamHostPlugin, RedirectToCustomServerPlugin, ) from proxy.http.proxy import HttpProxyBasePlugin +from proxy.http.proxy.auth import AuthPlugin def get_plugin_by_test_name(test_name: str) -> Type[HttpProxyBasePlugin]: @@ -36,4 +37,6 @@ def get_plugin_by_test_name(test_name: str) -> Type[HttpProxyBasePlugin]: plugin = FilterByURLRegexPlugin elif test_name == 'test_shortlink_plugin': plugin = ShortLinkPlugin + elif test_name == 'test_auth_plugin': + plugin = AuthPlugin return plugin From 2fa320d03f06995cc3a0822e8efccc5704e019d7 Mon Sep 17 00:00:00 2001 From: Abhinav Singh <126065+abhinavsingh@users.noreply.github.com> Date: Sat, 13 Apr 2024 13:22:25 +0530 Subject: [PATCH 02/27] Python 3.11 support (#1384) * Changes for Python 3.11 support * Updated README.md for versioning info * Update `httpx==0.27.0` to avoid `cgi` deprecation warning from pytest on Python 3.11 * Make tests work for 3.11 * Declare support for 3.11 * Use 3.11-alpine for Docker images * Preserve pylint version for `python_version <= 3.10` * Preserve httpx version for <= 3.10 * `httpx` usage fix in tests for <=3.10 * Adjust pylint and pytest for >= 3.11 * Use 3.11.8, bad-option-value and httpx proxies fix * tox for 3.11 * Fix for `TOXENV: py` * -vv for pytest * Downgrade to `pytest-asyncio==0.21.1` * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * remove asyncio_mode=strict * try with `pytest-cov==4.1.0` for 3.11 * bump coverage for 3.11 * Try `3.11` in GitHub workflow which installs >3.11.8 unavailable via pyenv yet * Revert back to `-v` --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .github/workflows/test-library.yml | 15 ++++++++++++--- .pylintrc | 12 ++++++++---- Dockerfile | 2 +- Makefile | 2 +- README.md | 8 ++++---- benchmark/requirements.txt | 2 +- docs/conf.py | 2 ++ examples/web_scraper.py | 7 +++++++ proxy/common/types.py | 8 ++++---- proxy/core/base/tcp_server.py | 5 +++++ proxy/core/work/fd/fd.py | 15 +++++++++++++++ proxy/core/work/local.py | 5 +++++ proxy/core/work/remote.py | 5 +++++ proxy/http/server/plugin.py | 2 +- proxy/plugin/proxy_pool.py | 1 + proxy/testing/test_case.py | 1 + requirements-testing.txt | 29 ++++++++++++++++++++--------- setup.cfg | 1 + tests/http/proxy/test_http2.py | 18 +++++++++++++++--- tox.ini | 1 - 20 files changed, 109 insertions(+), 32 deletions(-) diff --git a/.github/workflows/test-library.yml b/.github/workflows/test-library.yml index a9fcad5160..b01f93b0aa 100644 --- a/.github/workflows/test-library.yml +++ b/.github/workflows/test-library.yml @@ -446,8 +446,9 @@ jobs: # NOTE: The latest and the lowest supported Pythons are prioritized # NOTE: to improve the responsiveness. It's nice to see the most # NOTE: important results first. - - '3.10' + - '3.11' - 3.6 + - '3.10' - 3.9 - 3.8 - 3.7 @@ -463,7 +464,7 @@ jobs: env: PY_COLORS: 1 TOX_PARALLEL_NO_SPINNER: 1 - TOXENV: python + TOXENV: py steps: - name: Switch to using Python v${{ matrix.python }} @@ -500,7 +501,15 @@ jobs: steps.calc-cache-key-py.outputs.py-hash-key }}- ${{ runner.os }}-pip- - - name: Install tox + - name: Install tox for >= 3.11 + if: matrix.python == '3.11' + run: >- + python -m + pip install + --user + tox==4.14.2 + - name: Install tox for < 3.11 + if: matrix.python != '3.11' run: >- python -m pip install diff --git a/.pylintrc b/.pylintrc index 25434cea23..c705e36297 100644 --- a/.pylintrc +++ b/.pylintrc @@ -131,6 +131,10 @@ disable=raw-checker-failed, useless-return, useless-super-delegation, wrong-import-order, + # Required because to support 3.11 + # we added unnecessary-dunder-call which is not supported for <=3.11 + # see https://github.com/abhinavsingh/proxy.py/actions/runs/8671404475/job/23780537848?pr=1384 + bad-option-value # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option @@ -419,7 +423,7 @@ contextmanager-decorators=contextlib.contextmanager # List of members which are set dynamically and missed by pylint inference # system, and so shouldn't trigger E1101 when accessed. Python regular # expressions are accepted. -generated-members= +generated-members=os,io # Tells whether missing members accessed in mixin class should be ignored. A # mixin class is detected if its name ends with "mixin" (case insensitive). @@ -446,7 +450,7 @@ ignored-classes=optparse.Values,thread._local,_thread._local # (useful for modules/projects where namespaces are manipulated during runtime # and thus existing member attributes cannot be deduced by static analysis). It # supports qualified module names, as well as Unix pattern matching. -ignored-modules= +ignored-modules=abc # Show a hint with possible names when a member name was not found. The aspect # of finding the hint is based on edit distance. @@ -605,5 +609,5 @@ preferred-modules= # Exceptions that will emit a warning when being caught. Defaults to # "BaseException, Exception". -overgeneral-exceptions=BaseException, - Exception +overgeneral-exceptions=builtins.BaseException, + builtins.Exception diff --git a/Dockerfile b/Dockerfile index dcfb3611b1..5277069a5a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-alpine as base +FROM python:3.11-alpine as base LABEL com.abhinavsingh.name="abhinavsingh/proxy.py" \ com.abhinavsingh.description="⚡ Fast • 🪶 Lightweight • 0️⃣ Dependency • 🔌 Pluggable • \ diff --git a/Makefile b/Makefile index f111b2f9e6..e42551765b 100644 --- a/Makefile +++ b/Makefile @@ -120,7 +120,7 @@ lib-mypy: tox -e lint -- mypy --all-files lib-pytest: - $(PYTHON) -m tox -e python -- -v + $(PYTHON) -m tox -e py -- -v lib-test: lib-clean lib-check lib-lint lib-pytest diff --git a/README.md b/README.md index ab37c7e84e..0ea8544648 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ [![iOS, iOS Simulator](https://img.shields.io/static/v1?label=tested%20with&message=iOS%20%F0%9F%93%B1%20%7C%20iOS%20Simulator%20%F0%9F%93%B1&color=darkgreen&style=for-the-badge)](https://abhinavsingh.com/proxy-py-a-lightweight-single-file-http-proxy-server-in-python/) [![pypi version](https://img.shields.io/pypi/v/proxy.py?style=flat-square)](https://pypi.org/project/proxy.py/) -[![Python 3.x](https://img.shields.io/static/v1?label=Python&message=3.6%20%7C%203.7%20%7C%203.8%20%7C%203.9%20%7C%203.10&color=blue&style=flat-square)](https://www.python.org/) +[![Python 3.x](https://img.shields.io/static/v1?label=Python&message=3.6%20%7C%203.7%20%7C%203.8%20%7C%203.9%20%7C%203.10%20%7C%203.11&color=blue&style=flat-square)](https://www.python.org/) [![Checked with mypy](https://img.shields.io/static/v1?label=MyPy&message=checked&color=blue&style=flat-square)](http://mypy-lang.org/) [![doc](https://img.shields.io/readthedocs/proxypy/latest?style=flat-square&color=darkgreen)](https://proxypy.readthedocs.io/) @@ -2366,7 +2366,7 @@ usage: -m [-h] [--tunnel-hostname TUNNEL_HOSTNAME] [--tunnel-port TUNNEL_PORT] [--filtered-client-ips FILTERED_CLIENT_IPS] [--filtered-url-regex-config FILTERED_URL_REGEX_CONFIG] -proxy.py v2.4.4rc5.dev36+g6c9d0315.d20240411 +proxy.py v2.4.4rc6.dev11+gac1f05d7.d20240413 options: -h, --help show this help message and exit @@ -2489,8 +2489,8 @@ options: Default: None. Signing certificate to use for signing dynamically generated HTTPS certificates. If used, must also pass --ca-key-file and --ca-signing-key-file - --ca-file CA_FILE Default: /Users/abhinavsingh/Dev/proxy.py/.venv/lib/py - thon3.10/site-packages/certifi/cacert.pem. Provide + --ca-file CA_FILE Default: /Users/abhinavsingh/Dev/proxy.py/.venv3118/li + b/python3.11/site-packages/certifi/cacert.pem. Provide path to custom CA bundle for peer certificate verification --ca-signing-key-file CA_SIGNING_KEY_FILE diff --git a/benchmark/requirements.txt b/benchmark/requirements.txt index c9fbac1fc8..1b0d1fe2f0 100644 --- a/benchmark/requirements.txt +++ b/benchmark/requirements.txt @@ -1,4 +1,4 @@ -aiohttp==3.8.1 +aiohttp==3.8.2 # Blacksheep depends upon essentials_openapi which is pinned to pyyaml==5.4.1 # and pyyaml>5.3.1 is broken for cython 3 # See https://github.com/yaml/pyyaml/issues/724#issuecomment-1638587228 diff --git a/docs/conf.py b/docs/conf.py index 40573d5edc..0388bd313e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -321,6 +321,8 @@ (_py_class_role, 'HostPort'), (_py_class_role, 'TcpOrTlsSocket'), (_py_class_role, 're.Pattern'), + (_py_class_role, 'proxy.core.base.tcp_server.T'), + (_py_class_role, 'proxy.common.types.RePattern'), (_py_obj_role, 'proxy.core.work.threadless.T'), (_py_obj_role, 'proxy.core.work.work.T'), (_py_obj_role, 'proxy.core.base.tcp_server.T'), diff --git a/examples/web_scraper.py b/examples/web_scraper.py index daf31d612f..618b7bf709 100644 --- a/examples/web_scraper.py +++ b/examples/web_scraper.py @@ -9,6 +9,8 @@ :license: BSD, see LICENSE for more details. """ import time +from abc import abstractmethod +from typing import Any from proxy import Proxy from proxy.core.work import Work @@ -52,6 +54,11 @@ async def handle_events( Return True to shutdown work.""" return False + @staticmethod + @abstractmethod + def create(*args: Any) -> TcpClientConnection: + raise NotImplementedError() + if __name__ == '__main__': with Proxy( diff --git a/proxy/common/types.py b/proxy/common/types.py index 984cc3bdd3..78f3bd5040 100644 --- a/proxy/common/types.py +++ b/proxy/common/types.py @@ -14,7 +14,7 @@ import queue import socket import ipaddress -from typing import TYPE_CHECKING, Any, Dict, List, Tuple, Union +from typing import TYPE_CHECKING, Any, Dict, List, Tuple, Union, TypeVar if TYPE_CHECKING: # pragma: no cover @@ -34,8 +34,8 @@ HostPort = Tuple[str, int] if sys.version_info.minor == 6: - RePattern = Any + RePattern = TypeVar('RePattern', bound=Any) elif sys.version_info.minor in (7, 8): - RePattern = re.Pattern # type: ignore + RePattern = TypeVar('RePattern', bound=re.Pattern) # type: ignore else: - RePattern = re.Pattern[Any] # type: ignore + RePattern = TypeVar('RePattern', bound=re.Pattern[Any]) # type: ignore diff --git a/proxy/core/base/tcp_server.py b/proxy/core/base/tcp_server.py index 842e255a7c..4d32949d55 100644 --- a/proxy/core/base/tcp_server.py +++ b/proxy/core/base/tcp_server.py @@ -240,3 +240,8 @@ def _optionally_wrap_socket(self, conn: socket.socket) -> TcpOrTlsSocket: conn = wrap_socket(conn, self.flags.keyfile, self.flags.certfile) self.work._conn = conn return conn + + @staticmethod + @abstractmethod + def create(*args: Any) -> T: + raise NotImplementedError() diff --git a/proxy/core/work/fd/fd.py b/proxy/core/work/fd/fd.py index 730019123c..a13024769c 100644 --- a/proxy/core/work/fd/fd.py +++ b/proxy/core/work/fd/fd.py @@ -9,7 +9,9 @@ :license: BSD, see LICENSE for more details. """ import socket +import asyncio import logging +from abc import abstractmethod from typing import Any, TypeVar, Optional from ...event import eventNames @@ -47,3 +49,16 @@ def work(self, *args: Any) -> None: exc_info=e, ) self._cleanup(fileno) + + @property + @abstractmethod + def loop(self) -> Optional[asyncio.AbstractEventLoop]: + raise NotImplementedError() + + @abstractmethod + def receive_from_work_queue(self) -> bool: + raise NotImplementedError() + + @abstractmethod + def work_queue_fileno(self) -> Optional[int]: + raise NotImplementedError() diff --git a/proxy/core/work/local.py b/proxy/core/work/local.py index 0745e817a7..6b3ae9936d 100644 --- a/proxy/core/work/local.py +++ b/proxy/core/work/local.py @@ -11,6 +11,7 @@ import queue import asyncio import contextlib +from abc import abstractmethod from typing import Any, Optional from .threadless import Threadless @@ -40,3 +41,7 @@ def receive_from_work_queue(self) -> bool: return True self.work(work) return False + + @abstractmethod + def work(self, *args: Any) -> None: + raise NotImplementedError() diff --git a/proxy/core/work/remote.py b/proxy/core/work/remote.py index afac2ebef8..5734438591 100644 --- a/proxy/core/work/remote.py +++ b/proxy/core/work/remote.py @@ -9,6 +9,7 @@ :license: BSD, see LICENSE for more details. """ import asyncio +from abc import abstractmethod from typing import Any, Optional from multiprocessing import connection @@ -37,3 +38,7 @@ def close_work_queue(self) -> None: def receive_from_work_queue(self) -> bool: self.work(self.work_queue.recv()) return False + + @abstractmethod + def work(self, *args: Any) -> None: + raise NotImplementedError() diff --git a/proxy/http/server/plugin.py b/proxy/http/server/plugin.py index 434fba24b8..48cc5eb2a7 100644 --- a/proxy/http/server/plugin.py +++ b/proxy/http/server/plugin.py @@ -169,7 +169,7 @@ def before_routing(self, request: HttpParser) -> Optional[HttpParser]: def handle_route(self, request: HttpParser, pattern: RePattern) -> Url: """Implement this method if you have configured dynamic routes.""" - pass + raise NotImplementedError() def regexes(self) -> List[str]: """Helper method to return list of route regular expressions.""" diff --git a/proxy/plugin/proxy_pool.py b/proxy/plugin/proxy_pool.py index 6e04034721..c244adeda1 100644 --- a/proxy/plugin/proxy_pool.py +++ b/proxy/plugin/proxy_pool.py @@ -186,6 +186,7 @@ def handle_upstream_chunk(self, chunk: memoryview) -> Optional[memoryview]: """Will never be called since we didn't establish an upstream connection.""" if not self.upstream: return chunk + # pylint: disable=broad-exception-raised raise Exception("This should have never been called") def on_upstream_connection_close(self) -> None: diff --git a/proxy/testing/test_case.py b/proxy/testing/test_case.py index afd13fe1af..4c2740e0b6 100644 --- a/proxy/testing/test_case.py +++ b/proxy/testing/test_case.py @@ -42,6 +42,7 @@ def setUpClass(cls) -> None: cls.PROXY.flags.plugins[b'HttpProxyBasePlugin'].append( CacheResponsesPlugin, ) + # pylint: disable=unnecessary-dunder-call cls.PROXY.__enter__() assert cls.PROXY.acceptors cls.wait_for_server(cls.PROXY.flags.port) diff --git a/requirements-testing.txt b/requirements-testing.txt index 13eba0f9bb..61153d0d62 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1,21 +1,32 @@ wheel==0.37.1 python-coveralls==2.9.3 -coverage==6.2 +coverage==6.2; python_version < '3.11' +coverage==7.4.4; python_version >= '3.11' flake8==4.0.1 -pytest==7.0.1 -pytest-cov==3.0.0 -pytest-xdist == 2.5.0 -pytest-mock==3.6.1 -pytest-asyncio==0.16.0 +# pytest for Python<3.11 +pytest==7.0.1; python_version < '3.11' +pytest-cov==3.0.0; python_version < '3.11' +pytest-xdist==2.5.0; python_version < '3.11' +pytest-mock==3.6.1; python_version < '3.11' +pytest-asyncio==0.16.0; python_version < '3.11' +# pytest for Python>=3.11 +pytest==8.1.1; python_version >= '3.11' +pytest-cov==5.0.0; python_version >= '3.11' +pytest-xdist==3.5.0; python_version >= '3.11' +pytest-mock==3.14.0; python_version >= '3.11' +pytest-asyncio==0.21.1; python_version >= '3.11' autopep8==1.6.0 mypy==0.971 py-spy==0.3.12 -tox==3.28.0 +tox==3.28.0; python_version < '3.11' +tox==4.14.2; python_version >= '3.11' mccabe==0.6.1 -pylint==2.13.7 +pylint==2.13.7; python_version < '3.11' +pylint==3.1.0; python_version >= '3.11' rope==1.1.1 # Required by test_http2.py -httpx==0.22.0 +httpx==0.22.0; python_version < '3.11' +httpx==0.27.0; python_version >= '3.11' h2==4.1.0 hpack==4.0.0 hyperframe==6.0.1 diff --git a/setup.cfg b/setup.cfg index 124681dd94..fb14a6515e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -65,6 +65,7 @@ classifiers = Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 Topic :: Internet Topic :: Internet :: Proxy Servers diff --git a/tests/http/proxy/test_http2.py b/tests/http/proxy/test_http2.py index a8f1eeed08..2ef95eaae6 100644 --- a/tests/http/proxy/test_http2.py +++ b/tests/http/proxy/test_http2.py @@ -8,6 +8,9 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ +import sys +from typing import Any, Dict + import httpx from proxy import TestCase @@ -17,14 +20,23 @@ class TestHttp2WithProxy(TestCase): def test_http2_via_proxy(self) -> None: assert self.PROXY + proxy_url = 'http://localhost:%d' % self.PROXY.flags.port + proxies: Dict[str, Any] = ( + { + 'proxies': { + 'all://': proxy_url, + }, + } + # For Python>=3.11, proxies keyword is deprecated by httpx + if sys.version_info < (3, 11, 0) + else {'proxy': proxy_url} + ) response = httpx.get( 'https://www.google.com', headers={'accept': 'application/json'}, verify=httpx.create_ssl_context(http2=True), timeout=httpx.Timeout(timeout=5.0), - proxies={ - 'all://': 'http://localhost:%d' % self.PROXY.flags.port, - }, + **proxies, ) self.assertEqual(response.status_code, 200) diff --git a/tox.ini b/tox.ini index 2a777f9cb5..5e7d706a4b 100644 --- a/tox.ini +++ b/tox.ini @@ -262,7 +262,6 @@ deps = pre-commit pylint >= 2.5.3 pylint-pytest < 1.1.0 - pytest-mock == 3.6.1 -r docs/requirements.in -r requirements-tunnel.txt -r requirements-testing.txt From 5ab40f20b87419ace9aa780e51184338888d0358 Mon Sep 17 00:00:00 2001 From: Abhinav Singh <126065+abhinavsingh@users.noreply.github.com> Date: Sat, 13 Apr 2024 14:47:35 +0530 Subject: [PATCH 03/27] Wait until buffer flush (#1385) * Wait until all data in buffer is flushed to client when upstream server finishes. (cherry picked from commit d7765067b0c7d4a8b0bf5548bcd3b9a77b73d0b1) * Wait until buffer flush * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Avoid shadowing * _teared not _teardown * Refactor logic * Do not try `read_from_descriptors` if reads have previously teared down * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: yk <1876421041@qq.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- helper/monitor_open_files.sh | 21 +++++++++++++-------- proxy/core/connection/connection.py | 6 +++++- proxy/http/handler.py | 29 +++++++++++++++++------------ proxy/http/proxy/server.py | 12 ++++++++---- 4 files changed, 43 insertions(+), 25 deletions(-) diff --git a/helper/monitor_open_files.sh b/helper/monitor_open_files.sh index f353e0db15..d4214e8bb6 100755 --- a/helper/monitor_open_files.sh +++ b/helper/monitor_open_files.sh @@ -20,15 +20,20 @@ if [[ -z "$PROXY_PY_PID" ]]; then exit 1 fi -OPEN_FILES_BY_MAIN=$(lsof -p "$PROXY_PY_PID" | wc -l) -echo "[$PROXY_PY_PID] Main process: $OPEN_FILES_BY_MAIN" +while true; +do + OPEN_FILES_BY_MAIN=$(lsof -p "$PROXY_PY_PID" | wc -l) + echo "[$PROXY_PY_PID] Main process: $OPEN_FILES_BY_MAIN" -pgrep -P "$PROXY_PY_PID" | while read -r acceptorPid; do - OPEN_FILES_BY_ACCEPTOR=$(lsof -p "$acceptorPid" | wc -l) - echo "[$acceptorPid] Acceptor process: $OPEN_FILES_BY_ACCEPTOR" + pgrep -P "$PROXY_PY_PID" | while read -r acceptorPid; do + OPEN_FILES_BY_ACCEPTOR=$(lsof -p "$acceptorPid" | wc -l) + echo "[$acceptorPid] Acceptor process: $OPEN_FILES_BY_ACCEPTOR" - pgrep -P "$acceptorPid" | while read -r childPid; do - OPEN_FILES_BY_CHILD_PROC=$(lsof -p "$childPid" | wc -l) - echo " [$childPid] child process: $OPEN_FILES_BY_CHILD_PROC" + pgrep -P "$acceptorPid" | while read -r childPid; do + OPEN_FILES_BY_CHILD_PROC=$(lsof -p "$childPid" | wc -l) + echo " [$childPid] child process: $OPEN_FILES_BY_CHILD_PROC" + done done + + sleep 1 done diff --git a/proxy/core/connection/connection.py b/proxy/core/connection/connection.py index d0bebe26db..63cb62e316 100644 --- a/proxy/core/connection/connection.py +++ b/proxy/core/connection/connection.py @@ -87,7 +87,11 @@ def flush(self, max_send_size: Optional[int] = None) -> int: # TODO: Assemble multiple packets if total # size remains below max send size. max_send_size = max_send_size or DEFAULT_MAX_SEND_SIZE - sent: int = self.send(mv[:max_send_size]) + try: + sent: int = self.send(mv[:max_send_size]) + except BlockingIOError: + logger.warning('BlockingIOError when trying send to {0}'.format(self.tag)) + return 0 if sent == len(mv): self.buffer.pop(0) self._num_buffer -= 1 diff --git a/proxy/http/handler.py b/proxy/http/handler.py index b8d207d561..581e911a33 100644 --- a/proxy/http/handler.py +++ b/proxy/http/handler.py @@ -49,6 +49,8 @@ def __init__(self, *args: Any, **kwargs: Any): if not self.flags.threadless: self.selector = selectors.DefaultSelector() self.plugin: Optional[HttpProtocolHandlerPlugin] = None + self.writes_teared: bool = False + self.reads_teared: bool = False ## # initialize, is_inactive, shutdown, get_events, handle_events @@ -137,23 +139,26 @@ async def handle_events( ) -> bool: """Returns True if proxy must tear down.""" # Flush buffer for ready to write sockets - teardown = await self.handle_writables(writables) - if teardown: + self.writes_teared = await self.handle_writables(writables) + if self.writes_teared: return True # Invoke plugin.write_to_descriptors if self.plugin: - teardown = await self.plugin.write_to_descriptors(writables) - if teardown: + self.writes_teared = await self.plugin.write_to_descriptors(writables) + if self.writes_teared: return True - # Read from ready to read sockets - teardown = await self.handle_readables(readables) - if teardown: + # Read from ready to read sockets if reads have not already teared down + if not self.reads_teared: + self.reads_teared = await self.handle_readables(readables) + if not self.reads_teared: + # Invoke plugin.read_from_descriptors + if self.plugin: + self.reads_teared = await self.plugin.read_from_descriptors( + readables, + ) + # Wait until client buffer has flushed when reads has teared down but we can still write + if self.reads_teared and not self.work.has_buffer(): return True - # Invoke plugin.read_from_descriptors - if self.plugin: - teardown = await self.plugin.read_from_descriptors(readables) - if teardown: - return True return False def handle_data(self, data: memoryview) -> Optional[bool]: diff --git a/proxy/http/proxy/server.py b/proxy/http/proxy/server.py index f18f45fc55..70d3369ec4 100644 --- a/proxy/http/proxy/server.py +++ b/proxy/http/proxy/server.py @@ -175,7 +175,11 @@ async def get_descriptors(self) -> Descriptors: return r, w async def write_to_descriptors(self, w: Writables) -> bool: - if (self.upstream and self.upstream.connection.fileno() not in w) or not self.upstream: + if ( + self.upstream + and not self.upstream.closed + and self.upstream.connection.fileno() not in w + ) or not self.upstream: # Currently, we just call write/read block of each plugins. It is # plugins responsibility to ignore this callback, if passed descriptors # doesn't contain the descriptor they registered. @@ -208,9 +212,9 @@ async def write_to_descriptors(self, w: Writables) -> bool: async def read_from_descriptors(self, r: Readables) -> bool: if ( - self.upstream and not - self.upstream.closed and - self.upstream.connection.fileno() not in r + self.upstream + and not self.upstream.closed + and self.upstream.connection.fileno() not in r ) or not self.upstream: # Currently, we just call write/read block of each plugins. It is # plugins responsibility to ignore this callback, if passed descriptors From 7026c139ba620623d0eb2bc3911a889e53fe7bb8 Mon Sep 17 00:00:00 2001 From: Abhinav Singh <126065+abhinavsingh@users.noreply.github.com> Date: Sat, 13 Apr 2024 15:19:07 +0530 Subject: [PATCH 04/27] Update benchmark results (#1386) * Update benchmark results for 2024 * Also capture oha version --- README.md | 86 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 49 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 0ea8544648..8c3e1978ce 100644 --- a/README.md +++ b/README.md @@ -147,50 +147,62 @@ - Made to handle `tens-of-thousands` connections / sec ```console - # On Macbook Pro 2019 / 2.4 GHz 8-Core Intel Core i9 / 32 GB RAM - ❯ ./helper/benchmark.sh - CONCURRENCY: 100 workers, TOTAL REQUESTS: 100000 req - + # On Macbook Pro M2 2022 + ❯ python --version + Python 3.11.8 + ❯ oha --version + oha 1.4.3 + ❯ ./benchmark/compare.sh + CONCURRENCY: 100 workers, DURATION: 1m, TIMEOUT: 1sec + ============================= + Benchmarking Proxy.Py + Server (pid:75969) running Summary: - Success rate: 1.0000 - Total: 2.5489 secs - Slowest: 0.0443 secs - Fastest: 0.0006 secs - Average: 0.0025 secs - Requests/sec: 39232.6572 + Success rate: 100.00% + Total: 60.0006 secs + Slowest: 0.2525 secs + Fastest: 0.0002 secs + Average: 0.0019 secs + Requests/sec: 51667.3774 - Total data: 1.81 MiB - Size/request: 19 B - Size/sec: 727.95 KiB + Total data: 56.17 MiB + Size/request: 19 B + Size/sec: 958.64 KiB Response time histogram: - 0.001 [5006] |■■■■■ - 0.001 [19740] |■■■■■■■■■■■■■■■■■■■■■ - 0.002 [29701] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ - 0.002 [21278] |■■■■■■■■■■■■■■■■■■■■■■ - 0.003 [15376] |■■■■■■■■■■■■■■■■ - 0.004 [6644] |■■■■■■■ - 0.004 [1609] |■ - 0.005 [434] | - 0.006 [83] | - 0.006 [29] | - 0.007 [100] | - - Latency distribution: - 10% in 0.0014 secs - 25% in 0.0018 secs - 50% in 0.0023 secs - 75% in 0.0030 secs - 90% in 0.0036 secs - 95% in 0.0040 secs - 99% in 0.0047 secs + 0.000 [1] | + 0.025 [3073746] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ + 0.051 [10559] | + 0.076 [4980] | + 0.101 [2029] | + 0.126 [5896] | + 0.152 [2466] | + 0.177 [116] | + 0.202 [40] | + 0.227 [52] | + 0.253 [87] | + + Response time distribution: + 10.00% in 0.0005 secs + 25.00% in 0.0007 secs + 50.00% in 0.0009 secs + 75.00% in 0.0014 secs + 90.00% in 0.0021 secs + 95.00% in 0.0035 secs + 99.00% in 0.0198 secs + 99.90% in 0.1262 secs + 99.99% in 0.1479 secs Details (average, fastest, slowest): - DNS+dialup: 0.0025 secs, 0.0015 secs, 0.0030 secs - DNS-lookup: 0.0000 secs, 0.0000 secs, 0.0001 secs + DNS+dialup: 0.0018 secs, 0.0004 secs, 0.0031 secs + DNS-lookup: 0.0000 secs, 0.0000 secs, 0.0002 secs Status code distribution: - [200] 100000 responses + [200] 3099972 responses + + Error distribution: + [100] aborted due to deadline + ============================= ``` Consult [Threads vs Threadless](#threads-vs-threadless) and [Threadless Remote vs Local Execution Mode](#threadless-remote-vs-local-execution-mode) to control number of CPU cores utilized. @@ -2322,7 +2334,7 @@ See [Benchmark](https://github.com/abhinavsingh/proxy.py/tree/develop/benchmark) To run standalone benchmark for `proxy.py`, use the following command from repo root: ```console -❯ ./helper/benchmark.sh +❯ ./benchmark/compare.sh ``` # Flags From c24862ba85c9fec926ae4e6e72ab66ff53c1180a Mon Sep 17 00:00:00 2001 From: Abhinav Singh <126065+abhinavsingh@users.noreply.github.com> Date: Thu, 18 Apr 2024 09:04:07 +0530 Subject: [PATCH 05/27] Ability to override `--data-dir` for scenarios when `proxy.py` is running as a user with no home directory (#1389) * Ability to override `--data-dir` for scenarios when `proxy.py` is running as a user with no home directory * Single quotes * Update expected tar.gz name and Default to `ms-python.black-formatter` in vscode settings * Fix tests for Python 3.6 and 3.7 * Updated README --- .github/workflows/test-library.yml | 2 +- .gitignore | 3 +++ .vscode/settings.json | 2 +- README.md | 14 ++++++++------ proxy/common/constants.py | 2 ++ proxy/common/flag.py | 8 +++++++- proxy/proxy.py | 7 +++++++ requirements-tunnel.txt | 6 ++++-- tests/test_main.py | 7 ++++--- 9 files changed, 37 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test-library.yml b/.github/workflows/test-library.yml index b01f93b0aa..d9f6931634 100644 --- a/.github/workflows/test-library.yml +++ b/.github/workflows/test-library.yml @@ -188,7 +188,7 @@ jobs: - name: Set the expected dist artifact names id: artifact-name run: | - print('::set-output name=sdist::proxy.py-${{ + print('::set-output name=sdist::proxy_py-${{ steps.request-check.outputs.release-requested == 'true' && github.event.inputs.release-version || steps.scm-version.outputs.dist-version diff --git a/.gitignore b/.gitignore index 3611a6c91c..088334e275 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ build pyreverse.png profile.svg + +*-pre-push +jaxl-api-credentials*.json diff --git a/.vscode/settings.json b/.vscode/settings.json index f43d334378..e2fed886b5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,7 +19,7 @@ "typescript.preferences.quoteStyle": "single", "[python]": { "editor.wordBasedSuggestions": "matchingDocuments", - "editor.defaultFormatter": null + "editor.defaultFormatter": "ms-python.black-formatter" }, "python.testing.unittestEnabled": false, "python.testing.autoTestDiscoverOnSaveEnabled": true, diff --git a/README.md b/README.md index 8c3e1978ce..b93c88af4b 100644 --- a/README.md +++ b/README.md @@ -2357,8 +2357,9 @@ usage: -m [-h] [--tunnel-hostname TUNNEL_HOSTNAME] [--tunnel-port TUNNEL_PORT] [--plugins PLUGINS [PLUGINS ...]] [--enable-dashboard] [--basic-auth BASIC_AUTH] [--enable-ssh-tunnel] [--work-klass WORK_KLASS] [--pid-file PID_FILE] [--openssl OPENSSL] - [--enable-proxy-protocol] [--enable-conn-pool] [--key-file KEY_FILE] - [--cert-file CERT_FILE] [--client-recvbuf-size CLIENT_RECVBUF_SIZE] + [--data-dir DATA_DIR] [--enable-proxy-protocol] [--enable-conn-pool] + [--key-file KEY_FILE] [--cert-file CERT_FILE] + [--client-recvbuf-size CLIENT_RECVBUF_SIZE] [--server-recvbuf-size SERVER_RECVBUF_SIZE] [--max-sendbuf-size MAX_SENDBUF_SIZE] [--timeout TIMEOUT] [--disable-http-proxy] [--disable-headers DISABLE_HEADERS] @@ -2378,7 +2379,7 @@ usage: -m [-h] [--tunnel-hostname TUNNEL_HOSTNAME] [--tunnel-port TUNNEL_PORT] [--filtered-client-ips FILTERED_CLIENT_IPS] [--filtered-url-regex-config FILTERED_URL_REGEX_CONFIG] -proxy.py v2.4.4rc6.dev11+gac1f05d7.d20240413 +proxy.py v2.4.4rc6.dev85+g9335918b options: -h, --help show this help message and exit @@ -2459,6 +2460,7 @@ options: --pid-file PID_FILE Default: None. Save "parent" process ID to a file. --openssl OPENSSL Default: openssl. Path to openssl binary. By default, assumption is that openssl is in your PATH. + --data-dir DATA_DIR Default: ~/.proxypy. Path to proxypy data directory. --enable-proxy-protocol Default: False. If used, will enable proxy protocol. Only version 1 is currently supported. @@ -2501,9 +2503,9 @@ options: Default: None. Signing certificate to use for signing dynamically generated HTTPS certificates. If used, must also pass --ca-key-file and --ca-signing-key-file - --ca-file CA_FILE Default: /Users/abhinavsingh/Dev/proxy.py/.venv3118/li - b/python3.11/site-packages/certifi/cacert.pem. Provide - path to custom CA bundle for peer certificate + --ca-file CA_FILE Default: /Users/abhinavsingh/Dev/proxy.py/.venv31010/l + ib/python3.10/site-packages/certifi/cacert.pem. + Provide path to custom CA bundle for peer certificate verification --ca-signing-key-file CA_SIGNING_KEY_FILE Default: None. CA signing key to use for dynamic diff --git a/proxy/common/constants.py b/proxy/common/constants.py index bd0a40e785..a0a2d9e5af 100644 --- a/proxy/common/constants.py +++ b/proxy/common/constants.py @@ -47,6 +47,8 @@ def _env_threadless_compliant() -> bool: DOT = b'.' SLASH = b'/' AT = b'@' +AND = b'&' +EQUAL = b'=' HTTP_PROTO = b'http' HTTPS_PROTO = HTTP_PROTO + b's' HTTP_1_0 = HTTP_PROTO.upper() + SLASH + b'1.0' diff --git a/proxy/common/flag.py b/proxy/common/flag.py index cffb97223e..8859c7d4e8 100644 --- a/proxy/common/flag.py +++ b/proxy/common/flag.py @@ -381,7 +381,13 @@ def initialize( ), ) - args.proxy_py_data_dir = DEFAULT_DATA_DIRECTORY_PATH + args.proxy_py_data_dir = cast( + str, + opts.get( + 'data_dir', + args.data_dir or DEFAULT_DATA_DIRECTORY_PATH, + ), + ) os.makedirs(args.proxy_py_data_dir, exist_ok=True) ca_cert_dir = opts.get('ca_cert_dir', args.ca_cert_dir) diff --git a/proxy/proxy.py b/proxy/proxy.py index 62ecc52e48..4279c611d5 100644 --- a/proxy/proxy.py +++ b/proxy/proxy.py @@ -145,6 +145,13 @@ 'By default, assumption is that openssl is in your PATH.', ) +flags.add_argument( + '--data-dir', + type=str, + default=None, + help='Default: ~/.proxypy. Path to proxypy data directory.', +) + class Proxy: """Proxy is a context manager to control proxy.py library core. diff --git a/requirements-tunnel.txt b/requirements-tunnel.txt index e28eb4ff04..a002227042 100644 --- a/requirements-tunnel.txt +++ b/requirements-tunnel.txt @@ -1,3 +1,5 @@ -paramiko==2.11.0 -types-paramiko==2.11.3 +paramiko==2.11.0; python_version < '3.11' +paramiko==3.4.0; python_version >= '3.11' +types-paramiko==2.11.3; python_version < '3.11' +types-paramiko==3.4.0.20240311; python_version >= '3.11' cryptography==36.0.2; python_version <= '3.6' diff --git a/tests/test_main.py b/tests/test_main.py index da0cbf143a..d939273cb4 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -33,9 +33,9 @@ DEFAULT_ENABLE_SSH_TUNNEL, DEFAULT_ENABLE_WEB_SERVER, DEFAULT_DISABLE_HTTP_PROXY, PLUGIN_WEBSOCKET_TRANSPORT, DEFAULT_CA_SIGNING_KEY_FILE, DEFAULT_CLIENT_RECVBUF_SIZE, - DEFAULT_SERVER_RECVBUF_SIZE, DEFAULT_CACHE_DIRECTORY_PATH, - DEFAULT_ENABLE_REVERSE_PROXY, DEFAULT_ENABLE_STATIC_SERVER, - _env_threadless_compliant, + DEFAULT_DATA_DIRECTORY_PATH, DEFAULT_SERVER_RECVBUF_SIZE, + DEFAULT_CACHE_DIRECTORY_PATH, DEFAULT_ENABLE_REVERSE_PROXY, + DEFAULT_ENABLE_STATIC_SERVER, _env_threadless_compliant, ) @@ -83,6 +83,7 @@ def mock_default_args(mock_args: mock.Mock) -> None: mock_args.enable_ssh_tunnel = DEFAULT_ENABLE_SSH_TUNNEL mock_args.enable_reverse_proxy = DEFAULT_ENABLE_REVERSE_PROXY mock_args.unix_socket_path = None + mock_args.data_dir = DEFAULT_DATA_DIRECTORY_PATH mock_args.cache_dir = DEFAULT_CACHE_DIRECTORY_PATH @mock.patch('os.remove') From 78248474bca86b3691df658694ee4d383466ff26 Mon Sep 17 00:00:00 2001 From: Abhinav Singh <126065+abhinavsingh@users.noreply.github.com> Date: Tue, 23 Apr 2024 18:29:50 +0530 Subject: [PATCH 06/27] Add `proxy.http.client` utility and base SSH classes (#1395) * Add `proxy.http.client` utility and base SSH classes * py_class_role --- Makefile | 6 ++- docs/conf.py | 1 + proxy.service | 15 ++++++++ proxy/common/constants.py | 16 +++++++- proxy/common/plugins.py | 19 ++++++++++ proxy/core/ssh/base.py | 80 +++++++++++++++++++++++++++++++++++++++ proxy/http/client.py | 68 +++++++++++++++++++++++++++++++++ 7 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 proxy.service create mode 100644 proxy/core/ssh/base.py create mode 100644 proxy/http/client.py diff --git a/Makefile b/Makefile index e42551765b..46b0222d72 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ OPEN=$(shell which xdg-open) endif .PHONY: all https-certificates sign-https-certificates ca-certificates -.PHONY: lib-check lib-clean lib-test lib-package lib-coverage lib-lint lib-pytest +.PHONY: lib-check lib-clean lib-test lib-package lib-coverage lib-lint lib-pytest lib-build .PHONY: lib-release-test lib-release lib-profile lib-doc lib-pre-commit .PHONY: lib-dep lib-flake8 lib-mypy lib-speedscope container-buildx-all-platforms .PHONY: container container-run container-release container-build container-buildx @@ -124,9 +124,11 @@ lib-pytest: lib-test: lib-clean lib-check lib-lint lib-pytest -lib-package: lib-clean lib-check +lib-build: $(PYTHON) -m tox -e cleanup-dists,build-dists,metadata-validation +lib-package: lib-clean lib-check lib-build + lib-release-test: lib-package twine upload --verbose --repository-url https://test.pypi.org/legacy/ dist/* diff --git a/docs/conf.py b/docs/conf.py index 0388bd313e..863864b20f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -318,6 +318,7 @@ (_py_class_role, 'connection.Connection'), (_py_class_role, 'EventQueue'), (_py_class_role, 'T'), + (_py_class_role, 'module'), (_py_class_role, 'HostPort'), (_py_class_role, 'TcpOrTlsSocket'), (_py_class_role, 're.Pattern'), diff --git a/proxy.service b/proxy.service new file mode 100644 index 0000000000..64053fef52 --- /dev/null +++ b/proxy.service @@ -0,0 +1,15 @@ +[Unit] +Description=ProxyPy Server +After=network.target + +[Service] +Type=simple +User=proxypy +Group=proxypy +ExecStart=proxy --hostname 0.0.0.0 +Restart=always +SyslogIdentifier=proxypy +LimitNOFILE=65536 + +[Install] +WantedBy=multi-user.target diff --git a/proxy/common/constants.py b/proxy/common/constants.py index a0a2d9e5af..dcb1f46638 100644 --- a/proxy/common/constants.py +++ b/proxy/common/constants.py @@ -49,12 +49,23 @@ def _env_threadless_compliant() -> bool: AT = b'@' AND = b'&' EQUAL = b'=' +TCP_PROTO = b"tcp" +UDP_PROTO = b"udp" HTTP_PROTO = b'http' HTTPS_PROTO = HTTP_PROTO + b's' +WEBSOCKET_PROTO = b"ws" +WEBSOCKETS_PROTO = WEBSOCKET_PROTO + b"s" +HTTP_PROTOS = [HTTP_PROTO, HTTPS_PROTO, WEBSOCKET_PROTO, WEBSOCKETS_PROTO] +SSL_PROTOS = [HTTPS_PROTO, WEBSOCKETS_PROTO] HTTP_1_0 = HTTP_PROTO.upper() + SLASH + b'1.0' HTTP_1_1 = HTTP_PROTO.upper() + SLASH + b'1.1' -HTTP_URL_PREFIX = HTTP_PROTO + COLON + SLASH + SLASH -HTTPS_URL_PREFIX = HTTPS_PROTO + COLON + SLASH + SLASH +COLON_SLASH_SLASH = COLON + SLASH + SLASH +TCP_URL_PREFIX = TCP_PROTO + COLON_SLASH_SLASH +UDP_URL_PREFIX = UDP_PROTO + COLON_SLASH_SLASH +HTTP_URL_PREFIX = HTTP_PROTO + COLON_SLASH_SLASH +HTTPS_URL_PREFIX = HTTPS_PROTO + COLON_SLASH_SLASH +WEBSOCKET_URL_PREFIX = WEBSOCKET_PROTO + COLON_SLASH_SLASH +WEBSOCKETS_URL_PREFIX = WEBSOCKETS_PROTO + COLON_SLASH_SLASH LOCAL_INTERFACE_HOSTNAMES = ( b'localhost', @@ -135,6 +146,7 @@ def _env_threadless_compliant() -> bool: DEFAULT_HTTP_PORT = 80 DEFAULT_HTTPS_PORT = 443 DEFAULT_WORK_KLASS = 'proxy.http.HttpProtocolHandler' +DEFAULT_SSH_LISTENER_KLASS = "proxy.core.ssh.listener.SshTunnelListener" DEFAULT_ENABLE_PROXY_PROTOCOL = False # 25 milliseconds to keep the loops hot # Will consume ~0.3-0.6% CPU when idle. diff --git a/proxy/common/plugins.py b/proxy/common/plugins.py index f92193ee8c..e4db66eaa3 100644 --- a/proxy/common/plugins.py +++ b/proxy/common/plugins.py @@ -8,11 +8,14 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ +import io import os import inspect import logging import importlib import itertools +# pylint: disable=ungrouped-imports +import importlib.util from types import ModuleType from typing import Any, Dict, List, Tuple, Union, Optional @@ -127,3 +130,19 @@ def locate_klass(klass_module_name: str, klass_path: List[str]) -> Union[type, N if klass is None or module_name is None: raise ValueError('%s is not resolvable as a plugin class' % text_(plugin)) return (klass, module_name) + + @staticmethod + def from_bytes(pyc: bytes, name: str) -> ModuleType: + code_stream = io.BytesIO(pyc) + spec = importlib.util.spec_from_loader( + name, + loader=None, + origin='dynamic', + is_package=True, + ) + assert spec is not None + mod = importlib.util.module_from_spec(spec) + code_stream.seek(0) + # pylint: disable=exec-used + exec(code_stream.read(), mod.__dict__) # noqa: S102 + return mod diff --git a/proxy/core/ssh/base.py b/proxy/core/ssh/base.py new file mode 100644 index 0000000000..5b12e32ceb --- /dev/null +++ b/proxy/core/ssh/base.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import logging +import argparse +from abc import abstractmethod +from typing import TYPE_CHECKING, Any + + +try: + if TYPE_CHECKING: # pragma: no cover + from paramiko.channel import Channel + + from ...common.types import HostPort +except ImportError: # pragma: no cover + pass + +logger = logging.getLogger(__name__) + + +class BaseSshTunnelHandler: + + def __init__(self, flags: argparse.Namespace) -> None: + self.flags = flags + + @abstractmethod + def on_connection( + self, + chan: 'Channel', + origin: 'HostPort', + server: 'HostPort', + ) -> None: + raise NotImplementedError() + + @abstractmethod + def shutdown(self) -> None: + raise NotImplementedError() + + +class BaseSshTunnelListener: + + def __init__( + self, + flags: argparse.Namespace, + handler: BaseSshTunnelHandler, + *args: Any, + **kwargs: Any, + ) -> None: + self.flags = flags + self.handler = handler + + def __enter__(self) -> 'BaseSshTunnelListener': + self.setup() + return self + + def __exit__(self, *args: Any) -> None: + self.shutdown() + + @abstractmethod + def is_alive(self) -> bool: + raise NotImplementedError() + + @abstractmethod + def is_active(self) -> bool: + raise NotImplementedError() + + @abstractmethod + def setup(self) -> None: + raise NotImplementedError() + + @abstractmethod + def shutdown(self) -> None: + raise NotImplementedError() diff --git a/proxy/http/client.py b/proxy/http/client.py new file mode 100644 index 0000000000..67a87fa46c --- /dev/null +++ b/proxy/http/client.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +import ssl +from typing import Optional + +from .parser import HttpParser, httpParserTypes +from ..common.utils import build_http_request, new_socket_connection +from ..common.constants import HTTPS_PROTO, DEFAULT_TIMEOUT + + +def client( + host: bytes, + port: int, + path: bytes, + method: bytes, + body: Optional[bytes] = None, + conn_close: bool = True, + scheme: bytes = HTTPS_PROTO, + timeout: float = DEFAULT_TIMEOUT, +) -> Optional[HttpParser]: + """Makes a request to remote registry endpoint""" + request = build_http_request( + method=method, + url=path, + headers={ + b'Host': host, + b'Content-Type': b'application/x-www-form-urlencoded', + }, + body=body, + conn_close=conn_close, + ) + try: + conn = new_socket_connection((host.decode(), port)) + except ConnectionRefusedError: + return None + try: + sock = ( + ssl.wrap_socket(sock=conn, ssl_version=ssl.PROTOCOL_TLSv1_2) + if scheme == HTTPS_PROTO + else conn + ) + except Exception: + conn.close() + return None + parser = HttpParser( + httpParserTypes.RESPONSE_PARSER, + ) + sock.settimeout(timeout) + try: + sock.sendall(request) + while True: + chunk = sock.recv(1024) + if not chunk: + break + parser.parse(memoryview(chunk)) + if parser.is_complete: + break + finally: + sock.close() + return parser From 380e0cc3ce58f8e0b65f94908e707426fed9f54b Mon Sep 17 00:00:00 2001 From: Abhinav Singh <126065+abhinavsingh@users.noreply.github.com> Date: Tue, 23 Apr 2024 18:51:53 +0530 Subject: [PATCH 07/27] Catch `KeyError` within Threadless executors (#1396) --- .gitignore | 1 - .vscode/settings.json | 16 +++++++++++++++- proxy/core/work/threadless.py | 22 +++++++++++++++------- proxy/http/parser/parser.py | 7 +++++++ 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 088334e275..dcc36e2b61 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,5 @@ build pyreverse.png profile.svg - *-pre-push jaxl-api-credentials*.json diff --git a/.vscode/settings.json b/.vscode/settings.json index e2fed886b5..f8894c3ddd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -41,5 +41,19 @@ "python.linting.flake8Args": ["--config", ".flake8"], "python.linting.mypyEnabled": true, "python.formatting.provider": "autopep8", - "autoDocstring.docstringFormat": "sphinx" + "autoDocstring.docstringFormat": "sphinx", + "emeraldwalk.runonsave": { + "commands": [ + { + "match": "\\.py$", + "isAsync": false, + "cmd": "./.venv/bin/autoflake --in-place --remove-all-unused-imports \"${file}\"" + }, + { + "match": "\\.py$", + "isAsync": false, + "cmd": "./.venv/bin/isort \"${file}\"" + } + ] + } } diff --git a/proxy/core/work/threadless.py b/proxy/core/work/threadless.py index f43c0a4732..e638940fe4 100644 --- a/proxy/core/work/threadless.py +++ b/proxy/core/work/threadless.py @@ -191,13 +191,21 @@ async def _update_work_events(self, work_id: int) -> None: # # TODO: Also remove offending work from pool to avoid spin loop. elif fileno != -1: - self.selector.register(fileno, events=mask, data=work_id) - self.registered_events_by_work_ids[work_id][fileno] = mask - logger.debug( - 'fd#{0} registered for mask#{1} by work#{2}'.format( - fileno, mask, work_id, - ), - ) + try: + self.selector.register(fileno, events=mask, data=work_id) + self.registered_events_by_work_ids[work_id][fileno] = mask + logger.debug( + 'fd#{0} registered for mask#{1} by work#{2}'.format( + fileno, + mask, + work_id, + ), + ) + except KeyError as exc: + logger.warning( + 'KeyError when trying to register fd#{0}'.format(fileno), + exc_info=exc, + ) async def _update_conn_pool_events(self) -> None: if not self._upstream_conn_pool: diff --git a/proxy/http/parser/parser.py b/proxy/http/parser/parser.py index 6b882f8fd9..778466711d 100644 --- a/proxy/http/parser/parser.py +++ b/proxy/http/parser/parser.py @@ -216,6 +216,13 @@ def is_connection_upgrade(self) -> bool: self.has_header(b'Connection') and \ self.has_header(b'Upgrade') + @property + def is_websocket_upgrade(self) -> bool: + return ( + self.is_connection_upgrade + and self.header(b'upgrade').lower() == b'websocket' + ) + @property def is_https_tunnel(self) -> bool: """Returns true for HTTPS CONNECT tunnel request.""" From 67706ac1ef600ec4070b3494fc84e181cfd52af6 Mon Sep 17 00:00:00 2001 From: Abhinav Singh <126065+abhinavsingh@users.noreply.github.com> Date: Tue, 23 Apr 2024 19:21:45 +0530 Subject: [PATCH 08/27] Reverse proxy ability to return Url, memoryview or TcpServerConnection object (#1397) * Reverse proxy enhancements * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- proxy/core/acceptor/pool.py | 19 +++-- proxy/core/listener/tcp.py | 5 +- proxy/http/server/plugin.py | 41 +++++++++-- proxy/http/server/reverse.py | 132 ++++++++++++++++++++++++++-------- proxy/http/server/web.py | 52 ++++++++------ proxy/plugin/reverse_proxy.py | 12 +++- 6 files changed, 194 insertions(+), 67 deletions(-) diff --git a/proxy/core/acceptor/pool.py b/proxy/core/acceptor/pool.py index 09fb9f447f..5fbae26a3d 100644 --- a/proxy/core/acceptor/pool.py +++ b/proxy/core/acceptor/pool.py @@ -98,12 +98,17 @@ def setup(self) -> None: """Setup acceptors.""" self._start() execution_mode = ( - 'threadless (local)' - if self.flags.local_executor - else 'threadless (remote)' - ) if self.flags.threadless else 'threaded' - logger.info( - 'Started %d acceptors in %s mode' % ( + ( + 'threadless (local)' + if self.flags.local_executor + else 'threadless (remote)' + ) + if self.flags.threadless + else 'threaded' + ) + logger.debug( + 'Started %d acceptors in %s mode' + % ( self.flags.num_acceptors, execution_mode, ), @@ -122,7 +127,7 @@ def setup(self) -> None: self.fd_queues[index].close() def shutdown(self) -> None: - logger.info('Shutting down %d acceptors' % self.flags.num_acceptors) + logger.debug('Shutting down %d acceptors' % self.flags.num_acceptors) for acceptor in self.acceptors: acceptor.running.set() for acceptor in self.acceptors: diff --git a/proxy/core/listener/tcp.py b/proxy/core/listener/tcp.py index b6dc15e8ef..ed041373e3 100644 --- a/proxy/core/listener/tcp.py +++ b/proxy/core/listener/tcp.py @@ -92,8 +92,7 @@ def listen(self) -> socket.socket: sock.listen(self.flags.backlog) sock.setblocking(False) self._port = sock.getsockname()[1] - logger.info( - 'Listening on %s:%s' % - (self.hostname, self._port), + logger.debug( + 'Listening on %s:%s' % (self.hostname, self._port), ) return sock diff --git a/proxy/http/server/plugin.py b/proxy/http/server/plugin.py index 48cc5eb2a7..720f6415b1 100644 --- a/proxy/http/server/plugin.py +++ b/proxy/http/server/plugin.py @@ -22,10 +22,11 @@ from ..descriptors import DescriptorsHandlerMixin from ...common.types import RePattern from ...common.utils import bytes_ +from ...http.server.protocols import httpProtocolTypes if TYPE_CHECKING: # pragma: no cover - from ...core.connection import UpstreamConnectionPool + from ...core.connection import TcpServerConnection, UpstreamConnectionPool class HttpWebServerBasePlugin(DescriptorsHandlerMixin, ABC): @@ -64,7 +65,7 @@ def serve_static_file(path: str, min_compression_length: int) -> memoryview: # TODO: Should we really close or take advantage of keep-alive? conn_close=True, ) - except FileNotFoundError: + except OSError: return NOT_FOUND_RESPONSE_PKT def name(self) -> str: @@ -88,6 +89,17 @@ def on_client_connection_close(self) -> None: """Client has closed the connection, do any clean up task now.""" pass + def do_upgrade(self, request: HttpParser) -> bool: + return True + + def on_client_data( + self, + request: HttpParser, + raw: memoryview, + ) -> Optional[memoryview]: + """Return None to avoid default webserver parsing of client data.""" + return raw + # No longer abstract since v2.4.0 # # @abstractmethod @@ -125,7 +137,7 @@ def on_access_log(self, context: Dict[str, Any]) -> Optional[Dict[str, Any]]: return context -class ReverseProxyBasePlugin(ABC): +class ReverseProxyBasePlugin(DescriptorsHandlerMixin, ABC): """ReverseProxy base plugin class.""" def __init__( @@ -161,13 +173,24 @@ def routes(self) -> List[Union[str, Tuple[str, List[bytes]]]]: must return the url to serve.""" raise NotImplementedError() # pragma: no cover + def protocols(self) -> List[int]: + return [ + httpProtocolTypes.HTTP, + httpProtocolTypes.HTTPS, + httpProtocolTypes.WEBSOCKET, + ] + def before_routing(self, request: HttpParser) -> Optional[HttpParser]: """Plugins can modify request, return response, close connection. If None is returned, request will be dropped and closed.""" return request # pragma: no cover - def handle_route(self, request: HttpParser, pattern: RePattern) -> Url: + def handle_route( + self, + request: HttpParser, + pattern: RePattern, + ) -> Union[memoryview, Url, 'TcpServerConnection']: """Implement this method if you have configured dynamic routes.""" raise NotImplementedError() @@ -182,3 +205,13 @@ def regexes(self) -> List[str]: else: raise ValueError('Invalid route type') return routes + + def on_access_log(self, context: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Use this method to override default access log format (see + DEFAULT_REVERSE_PROXY_ACCESS_LOG_FORMAT) or to add/update/modify passed context + for usage by default access logger. + + Return updated log context to use for default logging format, OR + Return None if plugin has logged the request. + """ + return context diff --git a/proxy/http/server/reverse.py b/proxy/http/server/reverse.py index c4cadf3e00..4d91bf3a0a 100644 --- a/proxy/http/server/reverse.py +++ b/proxy/http/server/reverse.py @@ -16,13 +16,14 @@ from proxy.http import Url from proxy.core.base import TcpUpstreamConnectionHandler from proxy.http.parser import HttpParser -from proxy.http.server import HttpWebServerBasePlugin, httpProtocolTypes +from proxy.http.server import HttpWebServerBasePlugin from proxy.common.utils import text_ from proxy.http.exception import HttpProtocolException from proxy.common.constants import ( HTTPS_PROTO, DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT, DEFAULT_REVERSE_PROXY_ACCESS_LOG_FORMAT, ) +from ...common.types import Readables, Writables, Descriptors if TYPE_CHECKING: # pragma: no cover @@ -44,6 +45,11 @@ def __init__(self, *args: Any, **kwargs: Any): self.uid, self.flags, self.client, self.event_queue, self.upstream_conn_pool, ) self.plugins.append(plugin) + self._upstream_proxy_pass: Optional[str] = None + + def do_upgrade(self, request: HttpParser) -> bool: + """Signal web protocol handler to not upgrade websocket requests by default.""" + return False def handle_upstream_data(self, raw: memoryview) -> None: # TODO: Parse response and implement plugin hook per parsed response object @@ -54,8 +60,8 @@ def routes(self) -> List[Tuple[int, str]]: r = [] for plugin in self.plugins: for route in plugin.regexes(): - r.append((httpProtocolTypes.HTTP, route)) - r.append((httpProtocolTypes.HTTPS, route)) + for proto in plugin.protocols(): + r.append((proto, route)) return r def handle_request(self, request: HttpParser) -> None: @@ -66,9 +72,12 @@ def handle_request(self, request: HttpParser) -> None: raise HttpProtocolException('before_routing closed connection') request = r + needs_upstream = False + # routes for plugin in self.plugins: for route in plugin.routes(): + # Static routes if isinstance(route, tuple): pattern = re.compile(route[0]) if pattern.match(text_(request.path)): @@ -76,39 +85,55 @@ def handle_request(self, request: HttpParser) -> None: random.choice(route[1]), ) break + # Dynamic routes elif isinstance(route, str): pattern = re.compile(route) if pattern.match(text_(request.path)): - self.choice = plugin.handle_route(request, pattern) + choice = plugin.handle_route(request, pattern) + if isinstance(choice, Url): + self.choice = choice + needs_upstream = True + self._upstream_proxy_pass = str(self.choice) + elif isinstance(choice, memoryview): + self.client.queue(choice) + self._upstream_proxy_pass = '{0} bytes'.format(len(choice)) + else: + self.upstream = choice + self._upstream_proxy_pass = '{0}:{1}'.format( + *self.upstream.addr, + ) break else: raise ValueError('Invalid route') - assert self.choice and self.choice.hostname - port = self.choice.port or \ - DEFAULT_HTTP_PORT \ - if self.choice.scheme == b'http' \ - else DEFAULT_HTTPS_PORT - self.initialize_upstream(text_(self.choice.hostname), port) - assert self.upstream - try: - self.upstream.connect() - if self.choice.scheme == HTTPS_PROTO: - self.upstream.wrap( - text_( - self.choice.hostname, + if needs_upstream: + assert self.choice and self.choice.hostname + port = ( + self.choice.port or DEFAULT_HTTP_PORT + if self.choice.scheme == b'http' + else DEFAULT_HTTPS_PORT + ) + self.initialize_upstream(text_(self.choice.hostname), port) + assert self.upstream + try: + self.upstream.connect() + if self.choice.scheme == HTTPS_PROTO: + self.upstream.wrap( + text_( + self.choice.hostname, + ), + as_non_blocking=True, + ca_file=self.flags.ca_file, + ) + request.path = self.choice.remainder + self.upstream.queue(memoryview(request.build())) + except ConnectionRefusedError: + raise HttpProtocolException( # pragma: no cover + 'Connection refused by upstream server {0}:{1}'.format( + text_(self.choice.hostname), + port, ), - as_non_blocking=True, - ca_file=self.flags.ca_file, ) - request.path = self.choice.remainder - self.upstream.queue(memoryview(request.build())) - except ConnectionRefusedError: - raise HttpProtocolException( # pragma: no cover - 'Connection refused by upstream server {0}:{1}'.format( - text_(self.choice.hostname), port, - ), - ) def on_client_connection_close(self) -> None: if self.upstream and not self.upstream.closed: @@ -116,9 +141,54 @@ def on_client_connection_close(self) -> None: self.upstream.close() self.upstream = None + def on_client_data( + self, + request: HttpParser, + raw: memoryview, + ) -> Optional[memoryview]: + if request.is_websocket_upgrade: + assert self.upstream + self.upstream.queue(raw) + return raw + def on_access_log(self, context: Dict[str, Any]) -> Optional[Dict[str, Any]]: - context.update({ - 'upstream_proxy_pass': str(self.choice) if self.choice else None, - }) - logger.info(DEFAULT_REVERSE_PROXY_ACCESS_LOG_FORMAT.format_map(context)) + context.update( + { + 'upstream_proxy_pass': self._upstream_proxy_pass, + }, + ) + log_handled = False + for plugin in self.plugins: + ctx = plugin.on_access_log(context) + if ctx is None: + log_handled = True + break + context = ctx + if not log_handled: + logger.info(DEFAULT_REVERSE_PROXY_ACCESS_LOG_FORMAT.format_map(context)) return None + + async def get_descriptors(self) -> Descriptors: + r, w = await super().get_descriptors() + # TODO(abhinavsingh): We need to keep a mapping of plugin and + # descriptors registered by them, so that within write/read blocks + # we can invoke the right plugin callbacks. + for plugin in self.plugins: + plugin_read_desc, plugin_write_desc = await plugin.get_descriptors() + r.extend(plugin_read_desc) + w.extend(plugin_write_desc) + return r, w + + async def read_from_descriptors(self, r: Readables) -> bool: + for plugin in self.plugins: + teardown = await plugin.read_from_descriptors(r) + if teardown: + return True + return await super().read_from_descriptors(r) + + async def write_to_descriptors(self, w: Writables) -> bool: + for plugin in self.plugins: + teardown = await plugin.write_to_descriptors(w) + if teardown: + return True + return await super().write_to_descriptors(w) diff --git a/proxy/http/server/web.py b/proxy/http/server/web.py index 06072493b2..f756494380 100644 --- a/proxy/http/server/web.py +++ b/proxy/http/server/web.py @@ -101,6 +101,9 @@ def __init__( if b'HttpWebServerBasePlugin' in self.flags.plugins: self._initialize_web_plugins() + self._response_size = 0 + self._post_request_data_size = 0 + @staticmethod def protocols() -> List[int]: return [httpProtocols.WEB_SERVER] @@ -138,17 +141,17 @@ def switch_to_websocket(self) -> None: def on_request_complete(self) -> Union[socket.socket, bool]: path = self.request.path or b'/' teardown = self._try_route(path) - # Try route signaled to teardown - # or if it did find a valid route - if teardown or self.route is not None: + if teardown: return teardown # No-route found, try static serving if enabled - if self.flags.enable_static_server: - self._try_static_or_404(path) + if self.route is None: + if self.flags.enable_static_server: + self._try_static_or_404(path) + return True + # Catch all unhandled web server requests, return 404 + self.client.queue(NOT_FOUND_RESPONSE_PKT) return True - # Catch all unhandled web server requests, return 404 - self.client.queue(NOT_FOUND_RESPONSE_PKT) - return True + return False async def get_descriptors(self) -> Descriptors: r, w = [], [] @@ -173,6 +176,9 @@ async def read_from_descriptors(self, r: Readables) -> bool: return False def on_client_data(self, raw: memoryview) -> None: + self._post_request_data_size += len(raw) + if self.route and self.route.on_client_data(self.request, raw) is None: + return if self.switched_protocol == httpProtocolTypes.WEBSOCKET: # TODO(abhinavsingh): Do we really tobytes() here? # Websocket parser currently doesn't depend on internal @@ -211,6 +217,7 @@ def on_client_data(self, raw: memoryview) -> None: self.pipeline_request = None def on_response_chunk(self, chunk: List[memoryview]) -> List[memoryview]: + self._response_size += sum([len(c) for c in chunk]) return chunk def on_client_connection_close(self) -> None: @@ -221,11 +228,15 @@ def on_client_connection_close(self) -> None: # Request 'request_method': text_(self.request.method), 'request_path': text_(self.request.path), - 'request_bytes': self.request.total_size, - 'request_ua': text_(self.request.header(b'user-agent')) - if self.request.has_header(b'user-agent') - else None, - 'request_version': None if not self.request.version else text_(self.request.version), + 'request_bytes': self.request.total_size + self._post_request_data_size, + 'request_ua': ( + text_(self.request.header(b'user-agent')) + if self.request.has_header(b'user-agent') + else None + ), + 'request_version': ( + None if not self.request.version else text_(self.request.version) + ), # Response # # TODO: Track and inject web server specific response attributes @@ -234,7 +245,7 @@ def on_client_connection_close(self) -> None: # several attributes required below. At least for code and # reason attributes. # - # 'response_bytes': self.response.total_size, + 'response_bytes': self._response_size, # 'response_code': text_(self.response.code), # 'response_reason': text_(self.response.reason), } @@ -256,8 +267,7 @@ def access_log(self, context: Dict[str, Any]) -> None: @property def _protocol(self) -> Tuple[bool, int]: - do_ws_upgrade = self.request.is_connection_upgrade and \ - self.request.header(b'upgrade').lower() == b'websocket' + do_ws_upgrade = self.request.is_websocket_upgrade return do_ws_upgrade, httpProtocolTypes.WEBSOCKET \ if do_ws_upgrade \ else httpProtocolTypes.HTTPS \ @@ -271,7 +281,7 @@ def _try_route(self, path: bytes) -> bool: self.route = self.routes[protocol][route] assert self.route # Optionally, upgrade protocol - if do_ws_upgrade: + if do_ws_upgrade and self.route.do_upgrade(self.request): self.switch_to_websocket() assert self.route # Invoke plugin.on_websocket_open @@ -279,9 +289,11 @@ def _try_route(self, path: bytes) -> bool: else: # Invoke plugin.handle_request self.route.handle_request(self.request) - if self.request.has_header(b'connection') and \ - self.request.header(b'connection').lower() == b'close': - return True + # if self.request.has_header(b'connection') and \ + # self.request.header(b'connection').lower() == b'close': + # return True + # Bailout on first match + break return False def _try_static_or_404(self, path: bytes) -> None: diff --git a/proxy/plugin/reverse_proxy.py b/proxy/plugin/reverse_proxy.py index fb96e15486..7b7a5a4b38 100644 --- a/proxy/plugin/reverse_proxy.py +++ b/proxy/plugin/reverse_proxy.py @@ -13,7 +13,7 @@ Lua """ import re -from typing import List, Tuple, Union +from typing import TYPE_CHECKING, List, Tuple, Union from ..http import Url from ..http.parser import HttpParser @@ -22,6 +22,10 @@ from ..http.exception.base import HttpProtocolException +if TYPE_CHECKING: + from ..core.connection import TcpServerConnection + + class ReverseProxyPlugin(ReverseProxyBasePlugin): """This example plugin is equivalent to following Nginx configuration:: @@ -49,7 +53,11 @@ def routes(self) -> List[Union[str, Tuple[str, List[bytes]]]]: r'/get/(\d+)$', ] - def handle_route(self, request: HttpParser, pattern: RePattern) -> Url: + def handle_route( + self, + request: HttpParser, + pattern: RePattern, + ) -> Union[memoryview, Url, 'TcpServerConnection']: """For our example dynamic route, we want to simply convert any incoming request to "/get/1" into "/get?id=1" when serving from upstream. """ From 81aa82b9a4ed7f04a139679dba3ab77c7a040218 Mon Sep 17 00:00:00 2001 From: Abhinav Singh <126065+abhinavsingh@users.noreply.github.com> Date: Wed, 24 Apr 2024 15:13:14 +0530 Subject: [PATCH 09/27] SSH handler/listener plugins (#1398) * SSH handler/listener plugins * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Readme updated * Fix listener tests * pyclassrole * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Trigger rebuild * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Trigger build * pre-commit default language version 3.10 * Language version --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- README.md | 63 ++++++++++++++++--------------- docs/conf.py | 1 + proxy/core/ssh/listener.py | 71 ++++++++++++++++++++++++----------- proxy/http/proxy/server.py | 6 +-- proxy/http/websocket/frame.py | 4 +- proxy/proxy.py | 54 +++++++++++++++++--------- tests/test_main.py | 30 +++++++-------- 8 files changed, 140 insertions(+), 91 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5b73b61ae5..eb1308f7b2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -144,7 +144,7 @@ repos: rev: 3.9.2 hooks: - id: flake8 - language_version: python3 + language_version: python3.10 additional_dependencies: - flake8-2020 >= 1.6.0 - flake8-docstrings >= 1.5.0 diff --git a/README.md b/README.md index b93c88af4b..83b68c6486 100644 --- a/README.md +++ b/README.md @@ -2341,25 +2341,25 @@ To run standalone benchmark for `proxy.py`, use the following command from repo ```console ❯ proxy -h -usage: -m [-h] [--tunnel-hostname TUNNEL_HOSTNAME] [--tunnel-port TUNNEL_PORT] - [--tunnel-username TUNNEL_USERNAME] +usage: -m [-h] [--threadless] [--threaded] [--num-workers NUM_WORKERS] + [--enable-events] [--local-executor LOCAL_EXECUTOR] + [--backlog BACKLOG] [--hostname HOSTNAME] + [--hostnames HOSTNAMES [HOSTNAMES ...]] [--port PORT] + [--ports PORTS [PORTS ...]] [--port-file PORT_FILE] + [--unix-socket-path UNIX_SOCKET_PATH] + [--num-acceptors NUM_ACCEPTORS] [--tunnel-hostname TUNNEL_HOSTNAME] + [--tunnel-port TUNNEL_PORT] [--tunnel-username TUNNEL_USERNAME] [--tunnel-ssh-key TUNNEL_SSH_KEY] [--tunnel-ssh-key-passphrase TUNNEL_SSH_KEY_PASSPHRASE] - [--tunnel-remote-port TUNNEL_REMOTE_PORT] [--threadless] - [--threaded] [--num-workers NUM_WORKERS] [--enable-events] - [--local-executor LOCAL_EXECUTOR] [--backlog BACKLOG] - [--hostname HOSTNAME] [--hostnames HOSTNAMES [HOSTNAMES ...]] - [--port PORT] [--ports PORTS [PORTS ...]] [--port-file PORT_FILE] - [--unix-socket-path UNIX_SOCKET_PATH] - [--num-acceptors NUM_ACCEPTORS] [--version] [--log-level LOG_LEVEL] - [--log-file LOG_FILE] [--log-format LOG_FORMAT] - [--open-file-limit OPEN_FILE_LIMIT] + [--tunnel-remote-port TUNNEL_REMOTE_PORT] [--version] + [--log-level LOG_LEVEL] [--log-file LOG_FILE] + [--log-format LOG_FORMAT] [--open-file-limit OPEN_FILE_LIMIT] [--plugins PLUGINS [PLUGINS ...]] [--enable-dashboard] [--basic-auth BASIC_AUTH] [--enable-ssh-tunnel] [--work-klass WORK_KLASS] [--pid-file PID_FILE] [--openssl OPENSSL] - [--data-dir DATA_DIR] [--enable-proxy-protocol] [--enable-conn-pool] - [--key-file KEY_FILE] [--cert-file CERT_FILE] - [--client-recvbuf-size CLIENT_RECVBUF_SIZE] + [--data-dir DATA_DIR] [--ssh-listener-klass SSH_LISTENER_KLASS] + [--enable-proxy-protocol] [--enable-conn-pool] [--key-file KEY_FILE] + [--cert-file CERT_FILE] [--client-recvbuf-size CLIENT_RECVBUF_SIZE] [--server-recvbuf-size SERVER_RECVBUF_SIZE] [--max-sendbuf-size MAX_SENDBUF_SIZE] [--timeout TIMEOUT] [--disable-http-proxy] [--disable-headers DISABLE_HEADERS] @@ -2379,25 +2379,10 @@ usage: -m [-h] [--tunnel-hostname TUNNEL_HOSTNAME] [--tunnel-port TUNNEL_PORT] [--filtered-client-ips FILTERED_CLIENT_IPS] [--filtered-url-regex-config FILTERED_URL_REGEX_CONFIG] -proxy.py v2.4.4rc6.dev85+g9335918b +proxy.py v2.4.4rc6.dev164+g73497f30 options: -h, --help show this help message and exit - --tunnel-hostname TUNNEL_HOSTNAME - Default: None. Remote hostname or IP address to which - SSH tunnel will be established. - --tunnel-port TUNNEL_PORT - Default: 22. SSH port of the remote host. - --tunnel-username TUNNEL_USERNAME - Default: None. Username to use for establishing SSH - tunnel. - --tunnel-ssh-key TUNNEL_SSH_KEY - Default: None. Private key path in pem format - --tunnel-ssh-key-passphrase TUNNEL_SSH_KEY_PASSPHRASE - Default: None. Private key passphrase - --tunnel-remote-port TUNNEL_REMOTE_PORT - Default: 8899. Remote port which will be forwarded - locally for proxy. --threadless Default: True. Enabled by default on Python 3.8+ (mac, linux). When disabled a new thread is spawned to handle each client connection. @@ -2434,6 +2419,21 @@ options: --host and --port flags are ignored --num-acceptors NUM_ACCEPTORS Defaults to number of CPU cores. + --tunnel-hostname TUNNEL_HOSTNAME + Default: None. Remote hostname or IP address to which + SSH tunnel will be established. + --tunnel-port TUNNEL_PORT + Default: 22. SSH port of the remote host. + --tunnel-username TUNNEL_USERNAME + Default: None. Username to use for establishing SSH + tunnel. + --tunnel-ssh-key TUNNEL_SSH_KEY + Default: None. Private key path in pem format + --tunnel-ssh-key-passphrase TUNNEL_SSH_KEY_PASSPHRASE + Default: None. Private key passphrase + --tunnel-remote-port TUNNEL_REMOTE_PORT + Default: 8899. Remote port which will be forwarded + locally for proxy. --version, -v Prints proxy.py version. --log-level LOG_LEVEL Valid options: DEBUG, INFO (default), WARNING, ERROR, @@ -2461,6 +2461,9 @@ options: --openssl OPENSSL Default: openssl. Path to openssl binary. By default, assumption is that openssl is in your PATH. --data-dir DATA_DIR Default: ~/.proxypy. Path to proxypy data directory. + --ssh-listener-klass SSH_LISTENER_KLASS + Default: proxy.core.ssh.listener.SshTunnelListener. An + implementation of BaseSshTunnelListener --enable-proxy-protocol Default: False. If used, will enable proxy protocol. Only version 1 is currently supported. diff --git a/docs/conf.py b/docs/conf.py index 863864b20f..da38c54ddc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -324,6 +324,7 @@ (_py_class_role, 're.Pattern'), (_py_class_role, 'proxy.core.base.tcp_server.T'), (_py_class_role, 'proxy.common.types.RePattern'), + (_py_class_role, 'BaseSshTunnelHandler'), (_py_obj_role, 'proxy.core.work.threadless.T'), (_py_obj_role, 'proxy.core.work.work.T'), (_py_obj_role, 'proxy.core.base.tcp_server.T'), diff --git a/proxy/core/ssh/listener.py b/proxy/core/ssh/listener.py index d851600fdd..72e0369a9f 100644 --- a/proxy/core/ssh/listener.py +++ b/proxy/core/ssh/listener.py @@ -8,21 +8,20 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ +import sys +import socket import logging import argparse -from typing import TYPE_CHECKING, Any, Set, Callable, Optional +from typing import TYPE_CHECKING, Any, Set, Optional, cast try: - from paramiko import SSHClient, AutoAddPolicy - from paramiko.transport import Transport - if TYPE_CHECKING: # pragma: no cover - from paramiko.channel import Channel - + if TYPE_CHECKING: # pragma: no cover from ...common.types import HostPort except ImportError: # pragma: no cover pass +from .base import BaseSshTunnelHandler, BaseSshTunnelListener from ...common.flag import flags @@ -72,18 +71,27 @@ ) -class SshTunnelListener: +class SshTunnelListener(BaseSshTunnelListener): """Connects over SSH and forwards a remote port to local host. Incoming connections are delegated to provided callback.""" def __init__( - self, - flags: argparse.Namespace, - on_connection_callback: Callable[['Channel', 'HostPort', 'HostPort'], None], + self, + flags: argparse.Namespace, + handler: BaseSshTunnelHandler, + *args: Any, + **kwargs: Any, ) -> None: + paramiko_logger = logging.getLogger('paramiko') + paramiko_logger.setLevel(logging.WARNING) + + # pylint: disable=import-outside-toplevel + from paramiko import SSHClient + from paramiko.transport import Transport + self.flags = flags - self.on_connection_callback = on_connection_callback + self.handler = handler self.ssh: Optional[SSHClient] = None self.transport: Optional[Transport] = None self.forwarded: Set['HostPort'] = set() @@ -92,24 +100,20 @@ def start_port_forward(self, remote_addr: 'HostPort') -> None: assert self.transport is not None self.transport.request_port_forward( *remote_addr, - handler=self.on_connection_callback, + handler=self.handler.on_connection, ) self.forwarded.add(remote_addr) - logger.info('%s:%d forwarding successful...' % remote_addr) + logger.debug('%s:%d forwarding successful...' % remote_addr) def stop_port_forward(self, remote_addr: 'HostPort') -> None: assert self.transport is not None self.transport.cancel_port_forward(*remote_addr) self.forwarded.remove(remote_addr) - def __enter__(self) -> 'SshTunnelListener': - self.setup() - return self - - def __exit__(self, *args: Any) -> None: - self.shutdown() - def setup(self) -> None: + # pylint: disable=import-outside-toplevel + from paramiko import SSHClient, AutoAddPolicy + self.ssh = SSHClient() self.ssh.load_system_host_keys() self.ssh.set_missing_host_key_policy(AutoAddPolicy()) @@ -119,14 +123,30 @@ def setup(self) -> None: username=self.flags.tunnel_username, key_filename=self.flags.tunnel_ssh_key, passphrase=self.flags.tunnel_ssh_key_passphrase, + compress=True, + timeout=10, + auth_timeout=7, ) - logger.info( - 'SSH connection established to %s:%d...' % ( + logger.debug( + 'SSH connection established to %s:%d...' + % ( self.flags.tunnel_hostname, self.flags.tunnel_port, ), ) self.transport = self.ssh.get_transport() + assert self.transport + sock = cast(socket.socket, self.transport.sock) # type: ignore[redundant-cast] + # Enable TCP keep-alive + sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + # Keep-alive interval (in seconds) + if sys.platform != 'darwin': + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 30) + # Keep-alive probe interval (in seconds) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 5) + # Number of keep-alive probes before timeout + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5) + self.start_port_forward(('', self.flags.tunnel_remote_port)) def shutdown(self) -> None: for remote_addr in list(self.forwarded): @@ -136,3 +156,10 @@ def shutdown(self) -> None: self.transport.close() if self.ssh is not None: self.ssh.close() + self.handler.shutdown() + + def is_alive(self) -> bool: + return self.transport.is_alive() if self.transport else False + + def is_active(self) -> bool: + return self.transport.is_active() if self.transport else False diff --git a/proxy/http/proxy/server.py b/proxy/http/proxy/server.py index 70d3369ec4..4e2f44ac3f 100644 --- a/proxy/http/proxy/server.py +++ b/proxy/http/proxy/server.py @@ -726,9 +726,9 @@ def generate_upstream_certificate( ): raise HttpProtocolException( f'For certificate generation all the following flags are mandatory: ' - f'--ca-cert-file:{ self.flags.ca_cert_file }, ' - f'--ca-key-file:{ self.flags.ca_key_file }, ' - f'--ca-signing-key-file:{ self.flags.ca_signing_key_file }', + f'--ca-cert-file:{ self.flags.ca_cert_file}, ' + f'--ca-key-file:{ self.flags.ca_key_file}, ' + f'--ca-signing-key-file:{ self.flags.ca_signing_key_file}', ) cert_file_path = HttpProxyPlugin.generated_cert_file_path( self.flags.ca_cert_dir, text_(self.request.host), diff --git a/proxy/http/websocket/frame.py b/proxy/http/websocket/frame.py index af6e0c7e9e..08954f0725 100644 --- a/proxy/http/websocket/frame.py +++ b/proxy/http/websocket/frame.py @@ -128,8 +128,8 @@ def build(self) -> bytes: ) else: raise ValueError( - f'Invalid payload_length { self.payload_length },' - f'maximum allowed { 1 << 64 }', + f'Invalid payload_length { self.payload_length},' + f'maximum allowed { 1 << 64}', ) if self.masked and self.data: mask = secrets.token_bytes(4) if self.mask is None else self.mask diff --git a/proxy/proxy.py b/proxy/proxy.py index 4279c611d5..2350f4e88f 100644 --- a/proxy/proxy.py +++ b/proxy/proxy.py @@ -14,10 +14,10 @@ import pprint import signal import logging +import argparse import threading -from typing import TYPE_CHECKING, Any, List, Optional, cast +from typing import TYPE_CHECKING, Any, List, Type, Optional, cast -from .core.ssh import SshTunnelListener, SshHttpProtocolHandler from .core.work import ThreadlessPool from .core.event import EventManager from .common.flag import FlagParser, flags @@ -25,16 +25,19 @@ from .core.work.fd import RemoteFdExecutor from .core.acceptor import AcceptorPool from .core.listener import ListenerPool +from .core.ssh.base import BaseSshTunnelListener from .common.constants import ( IS_WINDOWS, DEFAULT_PLUGINS, DEFAULT_VERSION, DEFAULT_LOG_FILE, DEFAULT_PID_FILE, DEFAULT_LOG_LEVEL, DEFAULT_BASIC_AUTH, DEFAULT_LOG_FORMAT, DEFAULT_WORK_KLASS, DEFAULT_OPEN_FILE_LIMIT, DEFAULT_ENABLE_DASHBOARD, DEFAULT_ENABLE_SSH_TUNNEL, + DEFAULT_SSH_LISTENER_KLASS, ) if TYPE_CHECKING: # pragma: no cover from .core.listener import TcpSocketListener + from .core.ssh.base import BaseSshTunnelHandler logger = logging.getLogger(__name__) @@ -152,6 +155,15 @@ help='Default: ~/.proxypy. Path to proxypy data directory.', ) +flags.add_argument( + '--ssh-listener-klass', + type=str, + default=DEFAULT_SSH_LISTENER_KLASS, + help='Default: ' + + DEFAULT_SSH_LISTENER_KLASS + + '. An implementation of BaseSshTunnelListener', +) + class Proxy: """Proxy is a context manager to control proxy.py library core. @@ -175,13 +187,13 @@ class Proxy: """ def __init__(self, input_args: Optional[List[str]] = None, **opts: Any) -> None: + self.opts = opts self.flags = FlagParser.initialize(input_args, **opts) self.listeners: Optional[ListenerPool] = None self.executors: Optional[ThreadlessPool] = None self.acceptors: Optional[AcceptorPool] = None self.event_manager: Optional[EventManager] = None - self.ssh_http_protocol_handler: Optional[SshHttpProtocolHandler] = None - self.ssh_tunnel_listener: Optional[SshTunnelListener] = None + self.ssh_tunnel_listener: Optional[BaseSshTunnelListener] = None def __enter__(self) -> 'Proxy': self.setup() @@ -261,21 +273,29 @@ def setup(self) -> None: self.acceptors.setup() # Start SSH tunnel acceptor if enabled if self.flags.enable_ssh_tunnel: - self.ssh_http_protocol_handler = SshHttpProtocolHandler( - flags=self.flags, - ) - self.ssh_tunnel_listener = SshTunnelListener( + self.ssh_tunnel_listener = self._setup_tunnel( flags=self.flags, - on_connection_callback=self.ssh_http_protocol_handler.on_connection, - ) - self.ssh_tunnel_listener.setup() - self.ssh_tunnel_listener.start_port_forward( - ('', self.flags.tunnel_remote_port), + **self.opts, ) # TODO: May be close listener fd as we don't need it now if threading.current_thread() == threading.main_thread(): self._register_signals() + @staticmethod + def _setup_tunnel( + flags: argparse.Namespace, + ssh_handler_klass: Type['BaseSshTunnelHandler'], + ssh_listener_klass: Any, + **kwargs: Any, + ) -> BaseSshTunnelListener: + tunnel = cast(Type[BaseSshTunnelListener], ssh_listener_klass)( + flags=flags, + handler=ssh_handler_klass(flags=flags), + **kwargs, + ) + tunnel.setup() + return tunnel + def shutdown(self) -> None: if self.flags.enable_ssh_tunnel: assert self.ssh_tunnel_listener is not None @@ -339,14 +359,14 @@ def _register_signals(self) -> None: @staticmethod def _handle_exit_signal(signum: int, _frame: Any) -> None: - logger.info('Received signal %d' % signum) + logger.debug('Received signal %d' % signum) sys.exit(0) def _handle_siginfo(self, _signum: int, _frame: Any) -> None: pprint.pprint(self.flags.__dict__) # pragma: no cover -def sleep_loop() -> None: +def sleep_loop(p: Optional[Proxy] = None) -> None: while True: try: time.sleep(1) @@ -355,8 +375,8 @@ def sleep_loop() -> None: def main(**opts: Any) -> None: - with Proxy(sys.argv[1:], **opts): - sleep_loop() + with Proxy(sys.argv[1:], **opts) as p: + sleep_loop(p) def entry_point() -> None: diff --git a/tests/test_main.py b/tests/test_main.py index d939273cb4..de9f69e5f6 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -330,26 +330,27 @@ def test_enable_devtools( @mock.patch('proxy.proxy.AcceptorPool') @mock.patch('proxy.proxy.ThreadlessPool') @mock.patch('proxy.proxy.ListenerPool') - @mock.patch('proxy.proxy.SshHttpProtocolHandler') - @mock.patch('proxy.proxy.SshTunnelListener') def test_enable_ssh_tunnel( - self, - mock_ssh_tunnel_listener: mock.Mock, - mock_ssh_http_proto_handler: mock.Mock, - mock_listener_pool: mock.Mock, - mock_executor_pool: mock.Mock, - mock_acceptor_pool: mock.Mock, - mock_event_manager: mock.Mock, - mock_parse_args: mock.Mock, - mock_load_plugins: mock.Mock, - mock_sleep: mock.Mock, + self, + mock_listener_pool: mock.Mock, + mock_executor_pool: mock.Mock, + mock_acceptor_pool: mock.Mock, + mock_event_manager: mock.Mock, + mock_parse_args: mock.Mock, + mock_load_plugins: mock.Mock, + mock_sleep: mock.Mock, ) -> None: mock_sleep.side_effect = KeyboardInterrupt() mock_args = mock_parse_args.return_value self.mock_default_args(mock_args) mock_args.enable_ssh_tunnel = True mock_args.local_executor = 0 - main() + mock_ssh_tunnel_listener = mock.MagicMock() + mock_ssh_http_proto_handler = mock.MagicMock() + main( + ssh_listener_klass=mock_ssh_tunnel_listener, + ssh_handler_klass=mock_ssh_http_proto_handler, + ) mock_load_plugins.assert_called() self.assertEqual( mock_load_plugins.call_args_list[0][0][0], [ @@ -367,10 +368,7 @@ def test_enable_ssh_tunnel( mock_ssh_http_proto_handler.assert_called_once() mock_ssh_tunnel_listener.assert_called_once() mock_ssh_tunnel_listener.return_value.setup.assert_called_once() - mock_ssh_tunnel_listener.return_value.start_port_forward.assert_called_once() mock_ssh_tunnel_listener.return_value.shutdown.assert_called_once() - # shutdown will internally call stop port forward - mock_ssh_tunnel_listener.return_value.stop_port_forward.assert_not_called() class TestProxyContextManager(unittest.TestCase): From 5b0c484e6ad502c47648d0b8f763f612799eb929 Mon Sep 17 00:00:00 2001 From: Abhinav Singh <126065+abhinavsingh@users.noreply.github.com> Date: Thu, 25 Apr 2024 09:51:52 +0530 Subject: [PATCH 10/27] Fix support for multiple ephemeral ports (#1399) --- proxy/core/listener/pool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/proxy/core/listener/pool.py b/proxy/core/listener/pool.py index f9befa9c17..b0945bfd10 100644 --- a/proxy/core/listener/pool.py +++ b/proxy/core/listener/pool.py @@ -39,9 +39,9 @@ def setup(self) -> None: if self.flags.unix_socket_path: self.add(UnixSocketListener) hostnames = {self.flags.hostname, *self.flags.hostnames} - ports = set(self.flags.ports) + ports = list(self.flags.ports) if not self.flags.unix_socket_path: - ports.add(self.flags.port) + ports.append(self.flags.port) for hostname, port in itertools.product(hostnames, ports): self.add(TcpSocketListener, hostname=hostname, port=port) From 8b929f079e5bd888f0c99e1065424e05a9ef06dd Mon Sep 17 00:00:00 2001 From: Abhinav Singh <126065+abhinavsingh@users.noreply.github.com> Date: Fri, 26 Apr 2024 12:00:35 +0530 Subject: [PATCH 11/27] Fix ssh tunnel use case (#1400) * Fix for `--enable-ssh-tunnel` use case * Update readme with correct instructions * Update readme --- README.md | 65 +++++++++++++++++++-------------------- proxy/core/ssh/handler.py | 12 ++++++-- proxy/proxy.py | 11 ++++--- 3 files changed, 49 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 83b68c6486..f147249d04 100644 --- a/README.md +++ b/README.md @@ -1303,17 +1303,16 @@ See [requirements-tunnel.txt](https://github.com/abhinavsingh/proxy.py/blob/deve | +------------+ | +----------+ | LOCAL | | | REMOTE | - | HOST | <== SSH ==== :8900 == | SERVER | + | HOST | <== SSH ==== :8900 == | PROXY | +------------+ | +----------+ :8899 proxy.py | | FIREWALL (allow tcp/22) -## What +### What -Proxy HTTP(s) requests made on a `remote` server through `proxy.py` server -running on `localhost`. +Proxy HTTP(s) requests made on a `remote` proxy server through `proxy.py` server running on `localhost`. ### How @@ -1335,7 +1334,7 @@ Start `proxy.py` as: ```console ❯ # On localhost -❯ proxy --enable-tunnel \ +❯ proxy --enable-ssh-tunnel \ --tunnel-username username \ --tunnel-hostname ip.address.or.domain.name \ --tunnel-port 22 \ @@ -2341,19 +2340,19 @@ To run standalone benchmark for `proxy.py`, use the following command from repo ```console ❯ proxy -h -usage: -m [-h] [--threadless] [--threaded] [--num-workers NUM_WORKERS] - [--enable-events] [--local-executor LOCAL_EXECUTOR] - [--backlog BACKLOG] [--hostname HOSTNAME] - [--hostnames HOSTNAMES [HOSTNAMES ...]] [--port PORT] - [--ports PORTS [PORTS ...]] [--port-file PORT_FILE] - [--unix-socket-path UNIX_SOCKET_PATH] - [--num-acceptors NUM_ACCEPTORS] [--tunnel-hostname TUNNEL_HOSTNAME] - [--tunnel-port TUNNEL_PORT] [--tunnel-username TUNNEL_USERNAME] +usage: -m [-h] [--tunnel-hostname TUNNEL_HOSTNAME] [--tunnel-port TUNNEL_PORT] + [--tunnel-username TUNNEL_USERNAME] [--tunnel-ssh-key TUNNEL_SSH_KEY] [--tunnel-ssh-key-passphrase TUNNEL_SSH_KEY_PASSPHRASE] - [--tunnel-remote-port TUNNEL_REMOTE_PORT] [--version] - [--log-level LOG_LEVEL] [--log-file LOG_FILE] - [--log-format LOG_FORMAT] [--open-file-limit OPEN_FILE_LIMIT] + [--tunnel-remote-port TUNNEL_REMOTE_PORT] [--threadless] + [--threaded] [--num-workers NUM_WORKERS] [--enable-events] + [--local-executor LOCAL_EXECUTOR] [--backlog BACKLOG] + [--hostname HOSTNAME] [--hostnames HOSTNAMES [HOSTNAMES ...]] + [--port PORT] [--ports PORTS [PORTS ...]] [--port-file PORT_FILE] + [--unix-socket-path UNIX_SOCKET_PATH] + [--num-acceptors NUM_ACCEPTORS] [--version] [--log-level LOG_LEVEL] + [--log-file LOG_FILE] [--log-format LOG_FORMAT] + [--open-file-limit OPEN_FILE_LIMIT] [--plugins PLUGINS [PLUGINS ...]] [--enable-dashboard] [--basic-auth BASIC_AUTH] [--enable-ssh-tunnel] [--work-klass WORK_KLASS] [--pid-file PID_FILE] [--openssl OPENSSL] @@ -2379,10 +2378,25 @@ usage: -m [-h] [--threadless] [--threaded] [--num-workers NUM_WORKERS] [--filtered-client-ips FILTERED_CLIENT_IPS] [--filtered-url-regex-config FILTERED_URL_REGEX_CONFIG] -proxy.py v2.4.4rc6.dev164+g73497f30 +proxy.py v2.4.4rc6.dev172+ge1879403.d20240425 options: -h, --help show this help message and exit + --tunnel-hostname TUNNEL_HOSTNAME + Default: None. Remote hostname or IP address to which + SSH tunnel will be established. + --tunnel-port TUNNEL_PORT + Default: 22. SSH port of the remote host. + --tunnel-username TUNNEL_USERNAME + Default: None. Username to use for establishing SSH + tunnel. + --tunnel-ssh-key TUNNEL_SSH_KEY + Default: None. Private key path in pem format + --tunnel-ssh-key-passphrase TUNNEL_SSH_KEY_PASSPHRASE + Default: None. Private key passphrase + --tunnel-remote-port TUNNEL_REMOTE_PORT + Default: 8899. Remote port which will be forwarded + locally for proxy. --threadless Default: True. Enabled by default on Python 3.8+ (mac, linux). When disabled a new thread is spawned to handle each client connection. @@ -2419,21 +2433,6 @@ options: --host and --port flags are ignored --num-acceptors NUM_ACCEPTORS Defaults to number of CPU cores. - --tunnel-hostname TUNNEL_HOSTNAME - Default: None. Remote hostname or IP address to which - SSH tunnel will be established. - --tunnel-port TUNNEL_PORT - Default: 22. SSH port of the remote host. - --tunnel-username TUNNEL_USERNAME - Default: None. Username to use for establishing SSH - tunnel. - --tunnel-ssh-key TUNNEL_SSH_KEY - Default: None. Private key path in pem format - --tunnel-ssh-key-passphrase TUNNEL_SSH_KEY_PASSPHRASE - Default: None. Private key passphrase - --tunnel-remote-port TUNNEL_REMOTE_PORT - Default: 8899. Remote port which will be forwarded - locally for proxy. --version, -v Prints proxy.py version. --log-level LOG_LEVEL Valid options: DEBUG, INFO (default), WARNING, ERROR, @@ -2506,7 +2505,7 @@ options: Default: None. Signing certificate to use for signing dynamically generated HTTPS certificates. If used, must also pass --ca-key-file and --ca-signing-key-file - --ca-file CA_FILE Default: /Users/abhinavsingh/Dev/proxy.py/.venv31010/l + --ca-file CA_FILE Default: /Users/abhinavsingh/Dev/proxy.py/.venv31013/l ib/python3.10/site-packages/certifi/cacert.pem. Provide path to custom CA bundle for peer certificate verification diff --git a/proxy/core/ssh/handler.py b/proxy/core/ssh/handler.py index ed6ea789ff..1281d4675e 100644 --- a/proxy/core/ssh/handler.py +++ b/proxy/core/ssh/handler.py @@ -8,9 +8,14 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ +import logging import argparse from typing import TYPE_CHECKING +from .base import BaseSshTunnelHandler + + +logger = logging.getLogger(__name__) if TYPE_CHECKING: # pragma: no cover from ...common.types import HostPort @@ -20,7 +25,7 @@ pass -class SshHttpProtocolHandler: +class SshHttpProtocolHandler(BaseSshTunnelHandler): """Handles incoming connections over forwarded SSH transport.""" def __init__(self, flags: argparse.Namespace) -> None: @@ -32,4 +37,7 @@ def on_connection( origin: 'HostPort', server: 'HostPort', ) -> None: - pass + logger.debug('handle proxy request') + + def shutdown(self) -> None: + logger.debug('ssh handler shutdown') diff --git a/proxy/proxy.py b/proxy/proxy.py index 2350f4e88f..233893f15f 100644 --- a/proxy/proxy.py +++ b/proxy/proxy.py @@ -18,6 +18,7 @@ import threading from typing import TYPE_CHECKING, Any, List, Type, Optional, cast +from .core.ssh import SshTunnelListener, SshHttpProtocolHandler from .core.work import ThreadlessPool from .core.event import EventManager from .common.flag import FlagParser, flags @@ -284,13 +285,15 @@ def setup(self) -> None: @staticmethod def _setup_tunnel( flags: argparse.Namespace, - ssh_handler_klass: Type['BaseSshTunnelHandler'], - ssh_listener_klass: Any, + ssh_handler_klass: Optional[Type['BaseSshTunnelHandler']] = None, + ssh_listener_klass: Optional[Any] = None, **kwargs: Any, ) -> BaseSshTunnelListener: - tunnel = cast(Type[BaseSshTunnelListener], ssh_listener_klass)( + listener_klass = ssh_listener_klass or SshTunnelListener + handler_klass = ssh_handler_klass or SshHttpProtocolHandler + tunnel = cast(Type[BaseSshTunnelListener], listener_klass)( flags=flags, - handler=ssh_handler_klass(flags=flags), + handler=handler_klass(flags=flags), **kwargs, ) tunnel.setup() From fce40f03a3a85aef83b4638e3ecd624711504501 Mon Sep 17 00:00:00 2001 From: Abhinav Singh <126065+abhinavsingh@users.noreply.github.com> Date: Tue, 30 Apr 2024 17:40:35 +0530 Subject: [PATCH 12/27] `compress` option for `serve_static_file` utility method (#1405) * `compress` option for `serve_static_file` utility method * Pin to `macos-12` for now * Change default to `True` (old default behavior) --- .github/workflows/test-library.yml | 4 ++-- proxy/common/constants.py | 7 +++++-- proxy/http/handler.py | 7 ++++--- proxy/http/server/plugin.py | 7 ++++++- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test-library.yml b/.github/workflows/test-library.yml index d9f6931634..e886eeeed2 100644 --- a/.github/workflows/test-library.yml +++ b/.github/workflows/test-library.yml @@ -439,7 +439,7 @@ jobs: # max-parallel: 4 matrix: os: - - macOS-latest + - macOS-12 - Ubuntu-20.04 - Windows-latest python: @@ -694,7 +694,7 @@ jobs: name: 📊 Node ${{ matrix.node }} @ ${{ matrix.os }} strategy: matrix: - os: [ubuntu-20.04, windows-latest, macOS-latest] + os: [ubuntu-20.04, windows-latest, macOS-12] node: ['10.x', '11.x', '12.x'] # max-parallel: 4 fail-fast: false diff --git a/proxy/common/constants.py b/proxy/common/constants.py index dcb1f46638..673f9a903c 100644 --- a/proxy/common/constants.py +++ b/proxy/common/constants.py @@ -125,8 +125,11 @@ def _env_threadless_compliant() -> bool: DEFAULT_HTTPS_PROXY_ACCESS_LOG_FORMAT = '{client_ip}:{client_port} - ' + \ '{request_method} {server_host}:{server_port} - ' + \ '{response_bytes} bytes - {connection_time_ms}ms' -DEFAULT_REVERSE_PROXY_ACCESS_LOG_FORMAT = '{client_ip}:{client_port} - ' + \ - '{request_method} {request_path} -> {upstream_proxy_pass} - {connection_time_ms}ms' +DEFAULT_REVERSE_PROXY_ACCESS_LOG_FORMAT = ( + "{client_ip}:{client_port} - " + + "{request_method} {request_path} - {request_ua} -> " + + "{upstream_proxy_pass} - {connection_time_ms}ms" +) DEFAULT_NUM_ACCEPTORS = 0 DEFAULT_NUM_WORKERS = 0 DEFAULT_OPEN_FILE_LIMIT = 1024 diff --git a/proxy/http/handler.py b/proxy/http/handler.py index 581e911a33..0bdcfa22c0 100644 --- a/proxy/http/handler.py +++ b/proxy/http/handler.py @@ -181,7 +181,7 @@ def handle_data(self, data: memoryview) -> Optional[bool]: elif self.plugin: self.plugin.on_client_data(data) except HttpProtocolException as e: - logger.info('HttpProtocolException: %s' % e) + logger.warning('HttpProtocolException: %s' % e) response: Optional[memoryview] = e.response(self.request) if response: self.work.queue(response) @@ -209,9 +209,10 @@ async def handle_writables(self, writables: Writables) -> bool: 'BrokenPipeError when flushing buffer for client', ) return True - except OSError: - logger.warning( # pragma: no cover + except OSError as exc: + logger.exception( # pragma: no cover 'OSError when flushing buffer to client', + exc_info=exc, ) return True return False diff --git a/proxy/http/server/plugin.py b/proxy/http/server/plugin.py index 720f6415b1..d3536dd10b 100644 --- a/proxy/http/server/plugin.py +++ b/proxy/http/server/plugin.py @@ -47,7 +47,11 @@ def __init__( self.upstream_conn_pool = upstream_conn_pool @staticmethod - def serve_static_file(path: str, min_compression_length: int) -> memoryview: + def serve_static_file( + path: str, + min_compression_length: int, + compress: bool = True, + ) -> memoryview: try: with open(path, 'rb') as f: content = f.read() @@ -61,6 +65,7 @@ def serve_static_file(path: str, min_compression_length: int) -> memoryview: return okResponse( content=content, headers=headers, + compress=compress, min_compression_length=min_compression_length, # TODO: Should we really close or take advantage of keep-alive? conn_close=True, From e713752e49f8bc5fe9a3777a54f2f3e74161d773 Mon Sep 17 00:00:00 2001 From: Abhinav Singh <126065+abhinavsingh@users.noreply.github.com> Date: Thu, 9 May 2024 10:32:47 +0530 Subject: [PATCH 13/27] Teardown on unhandled exceptions by work (#1406) --- proxy/core/work/threadless.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/proxy/core/work/threadless.py b/proxy/core/work/threadless.py index e638940fe4..e8f7339def 100644 --- a/proxy/core/work/threadless.py +++ b/proxy/core/work/threadless.py @@ -370,6 +370,8 @@ async def _run_once(self) -> bool: work_id = task._work_id # type: ignore try: teardown = task.result() + except Exception: + teardown = True finally: if teardown: self._cleanup(work_id) From 367205826df500bf59e1592690f3b0a976a3fe6b Mon Sep 17 00:00:00 2001 From: Abhinav Singh <126065+abhinavsingh@users.noreply.github.com> Date: Fri, 10 May 2024 13:53:52 +0530 Subject: [PATCH 14/27] Grout: ngrok Alternative (#1407) * Grout: An Ngrok Alternative * Consume `grout` entry point within `proxy.py` * Revert `check.py` --- README.md | 161 +++++++++++++++++++++++++++++++++------------- proxy/__init__.py | 5 +- proxy/proxy.py | 110 ++++++++++++++++++++++++++++++- setup.cfg | 1 + 4 files changed, 230 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index f147249d04..b06d7ce81b 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,9 @@ - [End-to-End Encryption](#end-to-end-encryption) - [TLS Interception](#tls-interception) - [TLS Interception With Docker](#tls-interception-with-docker) +- [GROUT (NGROK Alternative)](#grout-ngrok-alternative) + - [How Grout works](#how-grout-works) + - [Self-hosted Grout](#self-hosted-grout) - [Proxy Over SSH Tunnel](#proxy-over-ssh-tunnel) - [Proxy Remote Requests Locally](#proxy-remote-requests-locally) - [Proxy Local Requests Remotely](#proxy-local-requests-remotely) @@ -138,6 +141,7 @@ [//]: # (DO-NOT-REMOVE-docs-badges-END) # Features +- [A drop-in alternative to `ngrok`](#grout-ngrok-alternative) - Fast & Scalable - Scale up by using all available cores on the system @@ -1290,6 +1294,76 @@ with TLS Interception: } ``` +# GROUT (NGROK Alternative) + +`grout` is a drop-in alternative to `ngrok` that comes packaged within `proxy.py` + +```console +❯ grout +NAME: + grout - securely tunnel local files, folders and services to public URLs + +USAGE: + grout route [name] + +DESCRIPTION: + grout exposes local networked services behinds NATs and firewalls to the + public internet over a secure tunnel. Share local folders, directories and websites, + build/test webhook consumers and self-host personal services to public URLs. + +EXAMPLES: + Share Files and Folders: + grout C:\path\to\folder # Share a folder on your system + grout /path/to/folder # Share a folder on your system + grout /path/to/folder --basic-auth user:pass # Add authentication for shared folder + grout /path/to/photo.jpg # Share a specific file on your system + + Expose HTTP, HTTPS and Websockets: + grout http://localhost:9090 # Expose HTTP service running on port 9090 + grout https://localhost:8080 # Expose HTTPS service running on port 8080 + grout https://localhost:8080 --path /worker/ # Expose only certain paths of HTTPS service on port 8080 + grout https://localhost:8080 --basic-auth u:p # Add authentication for exposed HTTPS service on port 8080 + + Expose TCP Services: + grout tcp://:6379 # Expose Redis service running locally on port 6379 + grout tcp://:22 # Expose SSH service running locally on port 22 + + Custom URLs: + grout https://localhost:8080 abhinavsingh # Custom URL for HTTPS service running on port 8080 + grout tcp://:22 abhinavsingh # Custom URL for SSH service running locally on port 22 + + Custom Domains: + grout tcp://:5432 abhinavsingh.domain.tld # Custom URL for Postgres service running locally on port 5432 + + Self-hosted solutions: + grout tcp://:5432 abhinavsingh.my.server # Custom URL for Postgres service running locally on port 5432 + +SUPPORT: + Write to us at support@jaxl.com + + Privacy policy and Terms & conditions + https://jaxl.com/privacy/ + + Created by Jaxl™ + https://jaxl.io +``` + +## How Grout works + +- `grout` infrastructure has 2 components: client and server +- `grout` client has 2 components: a thin and a thick client +- `grout` thin client is part of open source `proxy.py` (BSD 3-Clause License) +- `grout` thick client and servers are hosted at [jaxl.io](https://jaxl.io) + and a copyright of [Jaxl Innovations Private Limited](https://jaxl.com) +- `grout` server has 3 components: a registry server, a reverse proxy server and a tunnel server + +## Self-Hosted `grout` + +- `grout` thick client and servers can also be hosted on your GCP, AWS, Cloud infrastructures +- With a self-hosted version, your traffic flows through the network you control and trust +- `grout` developers at [jaxl.io](https://jaxl.io) provides GCP, AWS, Docker images for self-hosted solutions +- Please drop an email at [support@jaxl.com](mailto:support@jaxl.com) to get started. + # Proxy Over SSH Tunnel **This is a WIP and may not work as documented** @@ -2340,12 +2414,17 @@ To run standalone benchmark for `proxy.py`, use the following command from repo ```console ❯ proxy -h -usage: -m [-h] [--tunnel-hostname TUNNEL_HOSTNAME] [--tunnel-port TUNNEL_PORT] +usage: -m [-h] [--enable-proxy-protocol] [--threadless] [--threaded] + [--num-workers NUM_WORKERS] [--enable-events] [--enable-conn-pool] + [--key-file KEY_FILE] [--cert-file CERT_FILE] + [--client-recvbuf-size CLIENT_RECVBUF_SIZE] + [--server-recvbuf-size SERVER_RECVBUF_SIZE] + [--max-sendbuf-size MAX_SENDBUF_SIZE] [--timeout TIMEOUT] + [--tunnel-hostname TUNNEL_HOSTNAME] [--tunnel-port TUNNEL_PORT] [--tunnel-username TUNNEL_USERNAME] [--tunnel-ssh-key TUNNEL_SSH_KEY] [--tunnel-ssh-key-passphrase TUNNEL_SSH_KEY_PASSPHRASE] - [--tunnel-remote-port TUNNEL_REMOTE_PORT] [--threadless] - [--threaded] [--num-workers NUM_WORKERS] [--enable-events] + [--tunnel-remote-port TUNNEL_REMOTE_PORT] [--local-executor LOCAL_EXECUTOR] [--backlog BACKLOG] [--hostname HOSTNAME] [--hostnames HOSTNAMES [HOSTNAMES ...]] [--port PORT] [--ports PORTS [PORTS ...]] [--port-file PORT_FILE] @@ -2357,10 +2436,6 @@ usage: -m [-h] [--tunnel-hostname TUNNEL_HOSTNAME] [--tunnel-port TUNNEL_PORT] [--basic-auth BASIC_AUTH] [--enable-ssh-tunnel] [--work-klass WORK_KLASS] [--pid-file PID_FILE] [--openssl OPENSSL] [--data-dir DATA_DIR] [--ssh-listener-klass SSH_LISTENER_KLASS] - [--enable-proxy-protocol] [--enable-conn-pool] [--key-file KEY_FILE] - [--cert-file CERT_FILE] [--client-recvbuf-size CLIENT_RECVBUF_SIZE] - [--server-recvbuf-size SERVER_RECVBUF_SIZE] - [--max-sendbuf-size MAX_SENDBUF_SIZE] [--timeout TIMEOUT] [--disable-http-proxy] [--disable-headers DISABLE_HEADERS] [--ca-key-file CA_KEY_FILE] [--ca-cert-dir CA_CERT_DIR] [--ca-cert-file CA_CERT_FILE] [--ca-file CA_FILE] @@ -2378,10 +2453,45 @@ usage: -m [-h] [--tunnel-hostname TUNNEL_HOSTNAME] [--tunnel-port TUNNEL_PORT] [--filtered-client-ips FILTERED_CLIENT_IPS] [--filtered-url-regex-config FILTERED_URL_REGEX_CONFIG] -proxy.py v2.4.4rc6.dev172+ge1879403.d20240425 +proxy.py v2.4.4rc6.dev191+gef5a8922 options: -h, --help show this help message and exit + --enable-proxy-protocol + Default: False. If used, will enable proxy protocol. + Only version 1 is currently supported. + --threadless Default: True. Enabled by default on Python 3.8+ (mac, + linux). When disabled a new thread is spawned to + handle each client connection. + --threaded Default: False. Disabled by default on Python < 3.8 + and windows. When enabled a new thread is spawned to + handle each client connection. + --num-workers NUM_WORKERS + Defaults to number of CPU cores. + --enable-events Default: False. Enables core to dispatch lifecycle + events. Plugins can be used to subscribe for core + events. + --enable-conn-pool Default: False. (WIP) Enable upstream connection + pooling. + --key-file KEY_FILE Default: None. Server key file to enable end-to-end + TLS encryption with clients. If used, must also pass + --cert-file. + --cert-file CERT_FILE + Default: None. Server certificate to enable end-to-end + TLS encryption with clients. If used, must also pass + --key-file. + --client-recvbuf-size CLIENT_RECVBUF_SIZE + Default: 128 KB. Maximum amount of data received from + the client in a single recv() operation. + --server-recvbuf-size SERVER_RECVBUF_SIZE + Default: 128 KB. Maximum amount of data received from + the server in a single recv() operation. + --max-sendbuf-size MAX_SENDBUF_SIZE + Default: 64 KB. Maximum amount of data to flush in a + single send() operation. + --timeout TIMEOUT Default: 10.0. Number of seconds after which an + inactive connection must be dropped. Inactivity is + defined by no data sent or received by the client. --tunnel-hostname TUNNEL_HOSTNAME Default: None. Remote hostname or IP address to which SSH tunnel will be established. @@ -2397,17 +2507,6 @@ options: --tunnel-remote-port TUNNEL_REMOTE_PORT Default: 8899. Remote port which will be forwarded locally for proxy. - --threadless Default: True. Enabled by default on Python 3.8+ (mac, - linux). When disabled a new thread is spawned to - handle each client connection. - --threaded Default: False. Disabled by default on Python < 3.8 - and windows. When enabled a new thread is spawned to - handle each client connection. - --num-workers NUM_WORKERS - Defaults to number of CPU cores. - --enable-events Default: False. Enables core to dispatch lifecycle - events. Plugins can be used to subscribe for core - events. --local-executor LOCAL_EXECUTOR Default: 1. Enabled by default. Use 0 to disable. When enabled acceptors will make use of local (same @@ -2463,30 +2562,6 @@ options: --ssh-listener-klass SSH_LISTENER_KLASS Default: proxy.core.ssh.listener.SshTunnelListener. An implementation of BaseSshTunnelListener - --enable-proxy-protocol - Default: False. If used, will enable proxy protocol. - Only version 1 is currently supported. - --enable-conn-pool Default: False. (WIP) Enable upstream connection - pooling. - --key-file KEY_FILE Default: None. Server key file to enable end-to-end - TLS encryption with clients. If used, must also pass - --cert-file. - --cert-file CERT_FILE - Default: None. Server certificate to enable end-to-end - TLS encryption with clients. If used, must also pass - --key-file. - --client-recvbuf-size CLIENT_RECVBUF_SIZE - Default: 128 KB. Maximum amount of data received from - the client in a single recv() operation. - --server-recvbuf-size SERVER_RECVBUF_SIZE - Default: 128 KB. Maximum amount of data received from - the server in a single recv() operation. - --max-sendbuf-size MAX_SENDBUF_SIZE - Default: 64 KB. Maximum amount of data to flush in a - single send() operation. - --timeout TIMEOUT Default: 10.0. Number of seconds after which an - inactive connection must be dropped. Inactivity is - defined by no data sent or received by the client. --disable-http-proxy Default: False. Whether to disable proxy.HttpProxyPlugin. --disable-headers DISABLE_HEADERS diff --git a/proxy/__init__.py b/proxy/__init__.py index 08da6e2aa0..421d39e601 100755 --- a/proxy/__init__.py +++ b/proxy/__init__.py @@ -8,11 +8,14 @@ :copyright: (c) 2013-present by Abhinav Singh and contributors. :license: BSD, see LICENSE for more details. """ -from .proxy import Proxy, main, sleep_loop, entry_point +from .proxy import Proxy, main, grout, sleep_loop, entry_point from .testing import TestCase __all__ = [ + # Grout entry point. See + # https://jaxl.io/ + 'grout', # PyPi package entry_point. See # https://github.com/abhinavsingh/proxy.py#from-command-line-when-installed-using-pip 'entry_point', diff --git a/proxy/proxy.py b/proxy/proxy.py index 233893f15f..c607dc29eb 100644 --- a/proxy/proxy.py +++ b/proxy/proxy.py @@ -10,26 +10,35 @@ """ import os import sys +import gzip +import json import time import pprint import signal +import socket +import getpass import logging import argparse import threading -from typing import TYPE_CHECKING, Any, List, Type, Optional, cast +from typing import TYPE_CHECKING, Any, Dict, List, Type, Tuple, Optional, cast from .core.ssh import SshTunnelListener, SshHttpProtocolHandler from .core.work import ThreadlessPool from .core.event import EventManager +from .http.codes import httpStatusCodes from .common.flag import FlagParser, flags +from .http.client import client from .common.utils import bytes_ from .core.work.fd import RemoteFdExecutor +from .http.methods import httpMethods from .core.acceptor import AcceptorPool from .core.listener import ListenerPool from .core.ssh.base import BaseSshTunnelListener +from .common.plugins import Plugins +from .common.version import __version__ from .common.constants import ( - IS_WINDOWS, DEFAULT_PLUGINS, DEFAULT_VERSION, DEFAULT_LOG_FILE, - DEFAULT_PID_FILE, DEFAULT_LOG_LEVEL, DEFAULT_BASIC_AUTH, + IS_WINDOWS, HTTPS_PROTO, DEFAULT_PLUGINS, DEFAULT_VERSION, + DEFAULT_LOG_FILE, DEFAULT_PID_FILE, DEFAULT_LOG_LEVEL, DEFAULT_BASIC_AUTH, DEFAULT_LOG_FORMAT, DEFAULT_WORK_KLASS, DEFAULT_OPEN_FILE_LIMIT, DEFAULT_ENABLE_DASHBOARD, DEFAULT_ENABLE_SSH_TUNNEL, DEFAULT_SSH_LISTENER_KLASS, @@ -384,3 +393,98 @@ def main(**opts: Any) -> None: def entry_point() -> None: main() + + +def grout() -> None: # noqa: C901 + default_grout_tld = os.environ.get('JAXL_DEFAULT_GROUT_TLD', 'jaxl.io') + + def _clear_line() -> None: + print('\r' + ' ' * 60, end='', flush=True) + + def _env(scheme: bytes, host: bytes, port: int) -> Optional[Dict[str, Any]]: + response = client( + scheme=scheme, + host=host, + port=port, + path=b'/env/', + method=httpMethods.BIND, + body='v={0}&u={1}&h={2}'.format( + __version__, + os.environ.get('USER', getpass.getuser()), + socket.gethostname(), + ).encode(), + ) + if response: + if ( + response.code is not None + and int(response.code) == httpStatusCodes.OK + and response.body is not None + ): + return cast( + Dict[str, Any], + json.loads( + ( + gzip.decompress(response.body).decode() + if response.has_header(b'content-encoding') + and response.header(b'content-encoding') == b'gzip' + else response.body.decode() + ), + ), + ) + if response.code is None: + _clear_line() + print('\r\033[91mUnable to fetch\033[0m', end='', flush=True) + else: + _clear_line() + print( + '\r\033[91mError code {0}\033[0m'.format( + response.code.decode(), + ), + end='', + flush=True, + ) + else: + _clear_line() + print('\r\033[91mUnable to connect\033[0m') + return None + + def _parse() -> Tuple[str, int]: + """Here we deduce registry host/port based upon input parameters.""" + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument('route', nargs='?', default=None) + parser.add_argument('name', nargs='?', default=None) + args, _remaining_args = parser.parse_known_args() + grout_tld = default_grout_tld + if args.name is not None and '.' in args.name: + grout_tld = args.name.split('.', maxsplit=1)[1] + grout_tld_parts = grout_tld.split(':') + tld_host = grout_tld_parts[0] + tld_port = 443 + if len(grout_tld_parts) > 1: + tld_port = int(grout_tld_parts[1]) + return tld_host, tld_port + + tld_host, tld_port = _parse() + env = None + attempts = 0 + try: + while True: + env = _env(scheme=HTTPS_PROTO, host=tld_host.encode(), port=int(tld_port)) + attempts += 1 + if env is not None: + print('\rStarting ...' + ' ' * 30 + '\r', end='', flush=True) + break + time.sleep(1) + _clear_line() + print( + '\rWaiting for connection {0}'.format('.' * (attempts % 4)), + end='', + flush=True, + ) + time.sleep(1) + except KeyboardInterrupt: + sys.exit(1) + + assert env is not None + print('\r' + ' ' * 70 + '\r', end='', flush=True) + Plugins.from_bytes(env['m'].encode(), name='client').grout(env=env['e']) # type: ignore[attr-defined] diff --git a/setup.cfg b/setup.cfg index fb14a6515e..4e555abb3e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -111,6 +111,7 @@ install_requires = [options.entry_points] console_scripts = proxy = proxy:entry_point + grout = proxy:grout [options.package_data] proxy = From 32a6bdd47f1522da18602fcdfe010b7d10072df3 Mon Sep 17 00:00:00 2001 From: Abhinav Singh <126065+abhinavsingh@users.noreply.github.com> Date: Wed, 15 May 2024 16:36:49 +0530 Subject: [PATCH 15/27] DockerfileBase (#1410) --- .github/workflows/dockerfile-base.yml | 79 +++++++++++++++++++++++++++ DockerfileBase | 34 ++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 .github/workflows/dockerfile-base.yml create mode 100644 DockerfileBase diff --git a/.github/workflows/dockerfile-base.yml b/.github/workflows/dockerfile-base.yml new file mode 100644 index 0000000000..eb83bf9834 --- /dev/null +++ b/.github/workflows/dockerfile-base.yml @@ -0,0 +1,79 @@ +--- +name: lib + +on: # yamllint disable-line rule:truthy + workflow_dispatch: + +concurrency: + group: >- + ${{ + github.workflow + }}-${{ + github.event.pull_request.number || github.sha + }} + cancel-in-progress: true + +jobs: + pre-setup: + name: ⚙️ Pre-set global build settings + runs-on: ubuntu-20.04 + defaults: + run: + shell: bash + outputs: + container-platforms: ${{ steps.container.outputs.platforms }} + steps: + - name: Calculate container attributes + id: container + shell: bash + run: >- + PLATFORMS="linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x"; + echo "::set-output name=platforms::$PLATFORMS" + + ghcr-latest: + runs-on: ubuntu-20.04 + permissions: + packages: write + if: success() + needs: + - pre-setup # transitive, for accessing settings + name: 🐳 ghcr:latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + ref: ${{ github.event.inputs.release-commitish }} + - name: Login to GHCR + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v2 + # See https://github.com/docker/buildx/issues/850#issuecomment-996408167 + with: + version: v0.7.0 + buildkitd-flags: --debug + config: .github/buildkitd.toml + install: true + - name: Enable Multiarch # This slows down arm build by 4-5x + run: | + docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + - name: Create builder + run: | + docker buildx create --name proxypybuilder + docker buildx use proxypybuilder + docker buildx inspect + docker buildx ls + - name: Push base to GHCR + run: >- + docker buildx build + --push + --platform ${{ + needs.pre-setup.outputs.container-platforms + }} + -t ghcr.io/abhinavsingh/proxy.py:base + -f DockerfileBase . +... diff --git a/DockerfileBase b/DockerfileBase new file mode 100644 index 0000000000..2440bfef1a --- /dev/null +++ b/DockerfileBase @@ -0,0 +1,34 @@ +FROM python:3.11-alpine + +LABEL com.abhinavsingh.name="abhinavsingh/proxy.py" \ + com.abhinavsingh.description="⚡ Fast • 🪶 Lightweight • 0️⃣ Dependency • 🔌 Pluggable • \ + 😈 TLS interception • 🔒 DNS-over-HTTPS • 🔥 Poor Man's VPN • ⏪ Reverse & ⏩ Forward • \ + 👮🏿 \"Proxy Server\" framework • 🌐 \"Web Server\" framework • ➵ ➶ ➷ ➠ \"PubSub\" framework • \ + 👷 \"Work\" acceptor & executor framework" \ + com.abhinavsingh.url="https://github.com/abhinavsingh/proxy.py" \ + com.abhinavsingh.vcs-url="https://github.com/abhinavsingh/proxy.py" \ + com.abhinavsingh.docker.cmd="docker run -it --rm -p 8899:8899 abhinavsingh/proxy.py" \ + org.opencontainers.image.source="https://github.com/abhinavsingh/proxy.py" + +ENV PYTHONUNBUFFERED 1 + +# Install paramiko and cryptography to allow +# users to use tunneling features using Docker +RUN apk update && apk --no-cache add \ + --virtual .builddeps \ + gcc \ + musl-dev \ + libffi-dev \ + openssl-dev \ + python3-dev \ + cargo \ + rust \ + make +RUN python -m venv /proxy/venv && \ + /proxy/venv/bin/pip install \ + -U pip wheel && \ + /proxy/venv/bin/pip install \ + paramiko==3.4.0 \ + cryptography==39.0.1 \ + --prefer-binary +RUN apk del .builddeps From 58f884700125573efbfec86b1965a832b8f542a2 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Wed, 15 May 2024 16:38:05 +0530 Subject: [PATCH 16/27] `base` workflow --- .github/workflows/dockerfile-base.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dockerfile-base.yml b/.github/workflows/dockerfile-base.yml index eb83bf9834..4e7e2e70af 100644 --- a/.github/workflows/dockerfile-base.yml +++ b/.github/workflows/dockerfile-base.yml @@ -1,5 +1,5 @@ --- -name: lib +name: base on: # yamllint disable-line rule:truthy workflow_dispatch: From 0380e8301dba3a89b680720bb1092fc8a3d06605 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Wed, 15 May 2024 17:16:38 +0530 Subject: [PATCH 17/27] `--no-cache-dir` to avoid bloating the docker image --- DockerfileBase | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/DockerfileBase b/DockerfileBase index 2440bfef1a..c5c42f8b42 100644 --- a/DockerfileBase +++ b/DockerfileBase @@ -1,14 +1,17 @@ FROM python:3.11-alpine LABEL com.abhinavsingh.name="abhinavsingh/proxy.py" \ - com.abhinavsingh.description="⚡ Fast • 🪶 Lightweight • 0️⃣ Dependency • 🔌 Pluggable • \ + org.opencontainers.image.title="proxy.py" \ + org.opencontainers.image.description="⚡ Fast • 🪶 Lightweight • 0️⃣ Dependency • 🔌 Pluggable • \ 😈 TLS interception • 🔒 DNS-over-HTTPS • 🔥 Poor Man's VPN • ⏪ Reverse & ⏩ Forward • \ 👮🏿 \"Proxy Server\" framework • 🌐 \"Web Server\" framework • ➵ ➶ ➷ ➠ \"PubSub\" framework • \ 👷 \"Work\" acceptor & executor framework" \ - com.abhinavsingh.url="https://github.com/abhinavsingh/proxy.py" \ - com.abhinavsingh.vcs-url="https://github.com/abhinavsingh/proxy.py" \ + org.opencontainers.url="https://github.com/abhinavsingh/proxy.py" \ + org.opencontainers.image.source="https://github.com/abhinavsingh/proxy.py" \ com.abhinavsingh.docker.cmd="docker run -it --rm -p 8899:8899 abhinavsingh/proxy.py" \ - org.opencontainers.image.source="https://github.com/abhinavsingh/proxy.py" + org.opencontainers.image.licenses="BSD-3-Clause" \ + org.opencontainers.image.authors="Abhinav Singh " \ + org.opencontainers.image.vendor="Abhinav Singh" ENV PYTHONUNBUFFERED 1 @@ -25,9 +28,9 @@ RUN apk update && apk --no-cache add \ rust \ make RUN python -m venv /proxy/venv && \ - /proxy/venv/bin/pip install \ + /proxy/venv/bin/pip install --no-cache-dir \ -U pip wheel && \ - /proxy/venv/bin/pip install \ + /proxy/venv/bin/pip install --no-cache-dir \ paramiko==3.4.0 \ cryptography==39.0.1 \ --prefer-binary From f19db0ce1be5b53b16439a187227e1f10d893f76 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Wed, 15 May 2024 20:24:41 +0530 Subject: [PATCH 18/27] Optimize base docker image size --- DockerfileBase | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/DockerfileBase b/DockerfileBase index c5c42f8b42..99c1ef67ab 100644 --- a/DockerfileBase +++ b/DockerfileBase @@ -14,6 +14,7 @@ LABEL com.abhinavsingh.name="abhinavsingh/proxy.py" \ org.opencontainers.image.vendor="Abhinav Singh" ENV PYTHONUNBUFFERED 1 +ENV PYTHONDONTWRITEBYTECODE 1 # Install paramiko and cryptography to allow # users to use tunneling features using Docker @@ -28,10 +29,13 @@ RUN apk update && apk --no-cache add \ rust \ make RUN python -m venv /proxy/venv && \ - /proxy/venv/bin/pip install --no-cache-dir \ + /proxy/venv/bin/pip install --no-compile --no-cache-dir \ -U pip wheel && \ - /proxy/venv/bin/pip install --no-cache-dir \ + /proxy/venv/bin/pip install --no-compile --no-cache-dir \ paramiko==3.4.0 \ cryptography==39.0.1 \ - --prefer-binary -RUN apk del .builddeps + --prefer-binary && \ + apk del .builddeps && \ + find . -type d -name '__pycache__' | xargs rm -rf && \ + rm -rf /var/cache/apk/* && \ + rm -rf /root/.cache/ From d124d4ec6ec5047db50ece6e15bdaa4dd538dc71 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Wed, 15 May 2024 20:28:58 +0530 Subject: [PATCH 19/27] Use `base` name for base docker image --- .github/workflows/dockerfile-base.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dockerfile-base.yml b/.github/workflows/dockerfile-base.yml index 4e7e2e70af..f08ddc7e47 100644 --- a/.github/workflows/dockerfile-base.yml +++ b/.github/workflows/dockerfile-base.yml @@ -30,14 +30,14 @@ jobs: PLATFORMS="linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x"; echo "::set-output name=platforms::$PLATFORMS" - ghcr-latest: + ghcr-base: runs-on: ubuntu-20.04 permissions: packages: write if: success() needs: - pre-setup # transitive, for accessing settings - name: 🐳 ghcr:latest + name: 🐳 ghcr:base steps: - name: Checkout uses: actions/checkout@v3 From 7bb04c020a5b407d0f0385ac36584fcb3fc3d68b Mon Sep 17 00:00:00 2001 From: Abhinav Singh <126065+abhinavsingh@users.noreply.github.com> Date: Thu, 16 May 2024 00:34:26 +0530 Subject: [PATCH 20/27] Include `openssl`, `cryptography` and `paramiko` in default DockerHub image (#1409) * Include `openssl` in docker images to let users try TLS interception using dockerhub images * Include `requirements-tunnel.txt` within docker image to let users try tunneling using docker images * Docker is always using py311+, hardcode for now * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Simply `apk add py-cryptography` * --prefer-binary * Build deps for cryptography * make required by pynacl * Prepare to use base image once it has been published * Prepare image from `ghcr.io/abhinavsingh/proxy.py:base` * --no-cache-dir to avoid pip cache bloating * Optimize base image size * Use find * `-y` * Cut final image `FROM python:3.11-alpine` * Remove global setuptools and local pip too * wheel it too * end and flush * Try `42.0.4` in next base * Full path cleanup * SSL in final copy --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .github/workflows/test-library.yml | 2 -- Dockerfile | 43 ++++++++++++++++++++++-------- DockerfileBase | 2 +- proxy/proxy.py | 39 ++++++++++++++++++--------- requirements-tunnel.txt | 1 + 5 files changed, 60 insertions(+), 27 deletions(-) diff --git a/.github/workflows/test-library.yml b/.github/workflows/test-library.yml index e886eeeed2..c4b9f608ce 100644 --- a/.github/workflows/test-library.yml +++ b/.github/workflows/test-library.yml @@ -952,7 +952,6 @@ jobs: with: username: abhinavsingh password: ${{ secrets.DOCKER_ACCESS_TOKEN }} - # TODO: openssl image is not published on DockerHub - name: Push to DockerHub run: >- REGISTRY_URL="abhinavsingh/proxy.py"; @@ -964,7 +963,6 @@ jobs: --platform ${{ needs.pre-setup.outputs.container-platforms }} - --build-arg SKIP_OPENSSL=1 --build-arg PROXYPY_PKG_PATH='dist/${{ needs.pre-setup.outputs.wheel-artifact-name }}' diff --git a/Dockerfile b/Dockerfile index 5277069a5a..63306654a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,20 @@ -FROM python:3.11-alpine as base +FROM ghcr.io/abhinavsingh/proxy.py:base as builder LABEL com.abhinavsingh.name="abhinavsingh/proxy.py" \ - com.abhinavsingh.description="⚡ Fast • 🪶 Lightweight • 0️⃣ Dependency • 🔌 Pluggable • \ + org.opencontainers.image.title="proxy.py" \ + org.opencontainers.image.description="⚡ Fast • 🪶 Lightweight • 0️⃣ Dependency • 🔌 Pluggable • \ 😈 TLS interception • 🔒 DNS-over-HTTPS • 🔥 Poor Man's VPN • ⏪ Reverse & ⏩ Forward • \ 👮🏿 \"Proxy Server\" framework • 🌐 \"Web Server\" framework • ➵ ➶ ➷ ➠ \"PubSub\" framework • \ 👷 \"Work\" acceptor & executor framework" \ - com.abhinavsingh.url="https://github.com/abhinavsingh/proxy.py" \ - com.abhinavsingh.vcs-url="https://github.com/abhinavsingh/proxy.py" \ + org.opencontainers.url="https://github.com/abhinavsingh/proxy.py" \ + org.opencontainers.image.source="https://github.com/abhinavsingh/proxy.py" \ com.abhinavsingh.docker.cmd="docker run -it --rm -p 8899:8899 abhinavsingh/proxy.py" \ - org.opencontainers.image.source="https://github.com/abhinavsingh/proxy.py" + org.opencontainers.image.licenses="BSD-3-Clause" \ + org.opencontainers.image.authors="Abhinav Singh " \ + org.opencontainers.image.vendor="Abhinav Singh" ENV PYTHONUNBUFFERED 1 +ENV PYTHONDONTWRITEBYTECODE 1 ARG SKIP_OPENSSL ARG PROXYPY_PKG_PATH @@ -18,16 +22,33 @@ ARG PROXYPY_PKG_PATH COPY README.md / COPY $PROXYPY_PKG_PATH / -RUN pip install --upgrade pip && \ - pip install \ +# proxy.py itself needs no external dependencies +# Optionally, include openssl to allow +# users to use TLS interception features using Docker +# Use `--build-arg SKIP_OPENSSL=1` to disable openssl installation +RUN /proxy/venv/bin/pip install --no-compile --no-cache-dir \ + -U pip && \ + /proxy/venv/bin/pip install --no-compile --no-cache-dir \ --no-index \ --find-links file:/// \ proxy.py && \ - rm *.whl - -# Use `--build-arg SKIP_OPENSSL=1` to disable openssl installation -RUN if [[ -z "$SKIP_OPENSSL" ]]; then apk update && apk add openssl; fi + rm *.whl && \ + find . -type d -name '__pycache__' | xargs rm -rf && \ + rm -rf /var/cache/apk/* && \ + rm -rf /root/.cache/ && \ + /proxy/venv/bin/pip uninstall -y wheel setuptools pip && \ + /usr/local/bin/pip uninstall -y wheel setuptools pip +FROM python:3.11-alpine +COPY --from=builder /README.md /README.md +COPY --from=builder /proxy /proxy +RUN if [[ -z "$SKIP_OPENSSL" ]]; then \ + apk update && \ + apk --no-cache add openssl && \ + rm -rf /var/cache/apk/* && \ + rm -rf /root/.cache/; \ + fi +ENV PATH="/proxy/venv/bin:${PATH}" EXPOSE 8899/tcp ENTRYPOINT [ "proxy" ] CMD [ \ diff --git a/DockerfileBase b/DockerfileBase index 99c1ef67ab..6bee9f7d1e 100644 --- a/DockerfileBase +++ b/DockerfileBase @@ -33,7 +33,7 @@ RUN python -m venv /proxy/venv && \ -U pip wheel && \ /proxy/venv/bin/pip install --no-compile --no-cache-dir \ paramiko==3.4.0 \ - cryptography==39.0.1 \ + cryptography==42.0.4 \ --prefer-binary && \ apk del .builddeps && \ find . -type d -name '__pycache__' | xargs rm -rf && \ diff --git a/proxy/proxy.py b/proxy/proxy.py index c607dc29eb..fc102c8ab2 100644 --- a/proxy/proxy.py +++ b/proxy/proxy.py @@ -402,18 +402,27 @@ def _clear_line() -> None: print('\r' + ' ' * 60, end='', flush=True) def _env(scheme: bytes, host: bytes, port: int) -> Optional[Dict[str, Any]]: - response = client( - scheme=scheme, - host=host, - port=port, - path=b'/env/', - method=httpMethods.BIND, - body='v={0}&u={1}&h={2}'.format( - __version__, - os.environ.get('USER', getpass.getuser()), - socket.gethostname(), - ).encode(), - ) + try: + response = client( + scheme=scheme, + host=host, + port=port, + path=b'/env/', + method=httpMethods.BIND, + body='v={0}&u={1}&h={2}'.format( + __version__, + os.environ.get('USER', getpass.getuser()), + socket.gethostname(), + ).encode(), + ) + except socket.gaierror: + _clear_line() + print( + '\r\033[91mUnable to resolve\033[0m', + end='', + flush=True, + ) + return None if response: if ( response.code is not None @@ -445,7 +454,11 @@ def _env(scheme: bytes, host: bytes, port: int) -> Optional[Dict[str, Any]]: ) else: _clear_line() - print('\r\033[91mUnable to connect\033[0m') + print( + '\r\033[91mUnable to connect\033[0m', + end='', + flush=True, + ) return None def _parse() -> Tuple[str, int]: diff --git a/requirements-tunnel.txt b/requirements-tunnel.txt index a002227042..c80a8cde89 100644 --- a/requirements-tunnel.txt +++ b/requirements-tunnel.txt @@ -3,3 +3,4 @@ paramiko==3.4.0; python_version >= '3.11' types-paramiko==2.11.3; python_version < '3.11' types-paramiko==3.4.0.20240311; python_version >= '3.11' cryptography==36.0.2; python_version <= '3.6' +cryptography==39.0.1; python_version > '3.6' From afa89bc74975cd683f7767a36bbd6864e55db220 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Thu, 16 May 2024 01:27:02 +0530 Subject: [PATCH 21/27] Grout (ngrok alternative) using Docker doc --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index b06d7ce81b..2d842c452b 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ - [TLS Interception](#tls-interception) - [TLS Interception With Docker](#tls-interception-with-docker) - [GROUT (NGROK Alternative)](#grout-ngrok-alternative) + - [Grout using Docker](#grout-using-docker) - [How Grout works](#how-grout-works) - [Self-hosted Grout](#self-hosted-grout) - [Proxy Over SSH Tunnel](#proxy-over-ssh-tunnel) @@ -1348,6 +1349,22 @@ SUPPORT: https://jaxl.io ``` +## Grout using Docker + +```console +❯ docker run -it \ + --entrypoint grout \ + --rm -v ~/.proxy:/root/.proxy \ + abhinavsingh/proxy.py:latest \ + http://host.docker.internal:29876 +``` + +Above: + +- We changed `--entrypoint` to `grout` +- We replaced `localhost` with `host.docker.internal`, so that `grout` can route traffic to port `29876` running on the host machine +- *(Optional)* Mount host machine `~/.proxy` folder, so that `grout` credentials can persist across container restarts + ## How Grout works - `grout` infrastructure has 2 components: client and server From e34da54323fe52553e96d7571990337c0cff1f37 Mon Sep 17 00:00:00 2001 From: Abhinav Singh Date: Thu, 16 May 2024 08:12:47 +0530 Subject: [PATCH 22/27] Grout (ngrok alternative) using Docker doc From a7077cf8db3bb66a6667a9d968a401e8f805e092 Mon Sep 17 00:00:00 2001 From: Abhinav Singh <126065+abhinavsingh@users.noreply.github.com> Date: Sun, 9 Jun 2024 22:52:37 +0530 Subject: [PATCH 23/27] Add `ModifyRequestHeaderPlugin` (#1420) * Add `ModifyRequestHeaderPlugin` * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add to README * Fix lint issues shown by `Python3.11.8` --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- README.md | 26 +++++++++++++++++ proxy/http/server/web.py | 2 +- proxy/plugin/__init__.py | 2 ++ proxy/plugin/modify_request_header.py | 40 +++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 proxy/plugin/modify_request_header.py diff --git a/README.md b/README.md index 2d842c452b..cafa99d656 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ - [Proxy Pool Plugin](#proxypoolplugin) - [Filter By Client IP Plugin](#filterbyclientipplugin) - [Modify Chunk Response Plugin](#modifychunkresponseplugin) + - [Modify Request Header Plugin](#modifyrequestheaderplugin) - [Cloudflare DNS Resolver Plugin](#cloudflarednsresolverplugin) - [Custom DNS Resolver Plugin](#customdnsresolverplugin) - [Custom Network Interface](#customnetworkinterface) @@ -932,6 +933,31 @@ plugin Modify `ModifyChunkResponsePlugin` to your taste. Example, instead of sending hard-coded chunks, parse and modify the original `JSON` chunks received from the upstream server. +### ModifyRequestHeaderPlugin + +This plugin demonstrate how to modify outgoing HTTPS request headers under TLS interception mode. + +Start `proxy.py` as: + +```console +❯ proxy \ + --plugins proxy.plugin.ModifyRequestHeaderPlugin \ + ... [TLS interception flags] ... +``` + +Verify using `curl -x localhost:8899 --cacert ca-cert.pem https://httpbin.org/get`: + +```console +{ + "args": {}, + "headers": { + ... [redacted] ..., + "X-Proxy-Py-Version": "2.4.4rc6.dev15+gf533c711" + }, + ... [redacted] ... +} +``` + ### CloudflareDnsResolverPlugin This plugin uses `Cloudflare` hosted `DNS-over-HTTPS` [API](https://developers.cloudflare.com/1.1.1.1/encrypted-dns/dns-over-https/make-api-requests/dns-json) (json). diff --git a/proxy/http/server/web.py b/proxy/http/server/web.py index f756494380..f3899e890c 100644 --- a/proxy/http/server/web.py +++ b/proxy/http/server/web.py @@ -217,7 +217,7 @@ def on_client_data(self, raw: memoryview) -> None: self.pipeline_request = None def on_response_chunk(self, chunk: List[memoryview]) -> List[memoryview]: - self._response_size += sum([len(c) for c in chunk]) + self._response_size += sum(len(c) for c in chunk) return chunk def on_client_connection_close(self) -> None: diff --git a/proxy/plugin/__init__.py b/proxy/plugin/__init__.py index c3ad91945b..74c7e8d4ef 100644 --- a/proxy/plugin/__init__.py +++ b/proxy/plugin/__init__.py @@ -32,6 +32,7 @@ from .filter_by_client_ip import FilterByClientIpPlugin from .filter_by_url_regex import FilterByURLRegexPlugin from .modify_chunk_response import ModifyChunkResponsePlugin +from .modify_request_header import ModifyRequestHeaderPlugin from .redirect_to_custom_server import RedirectToCustomServerPlugin @@ -53,4 +54,5 @@ 'CustomDnsResolverPlugin', 'CloudflareDnsResolverPlugin', 'ProgramNamePlugin', + 'ModifyRequestHeaderPlugin', ] diff --git a/proxy/plugin/modify_request_header.py b/proxy/plugin/modify_request_header.py new file mode 100644 index 0000000000..72735c2781 --- /dev/null +++ b/proxy/plugin/modify_request_header.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +""" + proxy.py + ~~~~~~~~ + ⚡⚡⚡ Fast, Lightweight, Pluggable, TLS interception capable proxy server focused on + Network monitoring, controls & Application development, testing, debugging. + + :copyright: (c) 2013-present by Abhinav Singh and contributors. + :license: BSD, see LICENSE for more details. +""" +from typing import Optional + +from ..http.proxy import HttpProxyBasePlugin +from ..http.parser import HttpParser +from ..common.utils import bytes_ +from ..common.version import __version__ + + +class ModifyRequestHeaderPlugin(HttpProxyBasePlugin): + """Modify request header before sending to upstream server.""" + + # def before_upstream_connection(self, request: HttpParser) -> Optional[HttpParser]: + # """NOTE: Use this for HTTP only request headers modification.""" + # request.add_header( + # b"x-proxy-py-version", + # bytes_(__version__), + # ) + # return request + + def handle_client_request(self, request: HttpParser) -> Optional[HttpParser]: + """NOTE: This is for HTTPS request headers modification when under TLS interception. + + For HTTPS requests, modification of request under TLS interception WILL NOT WORK + through before_upstream_connection. + """ + request.add_header( + b'x-proxy-py-version', + bytes_(__version__), + ) + return request From 84c36b60c2a0e44c9dd700e27101222d070e44ff Mon Sep 17 00:00:00 2001 From: Abhinav Singh <126065+abhinavsingh@users.noreply.github.com> Date: Sun, 28 Jul 2024 10:43:37 +0530 Subject: [PATCH 24/27] Static route reverse proxy always `needs_upstream` (#1434) * Static route reverse proxy always needs_upstream * reverse hack done in https://github.com/abhinavsingh/proxy.py/pull/1371/files\#diff-8e4998393b40035040fb8494b321b9538b897ed0f25d35aeeb037a99f623abc3 to make tests work * httpbingo is an issue on github workflows --- proxy/http/server/reverse.py | 1 + 1 file changed, 1 insertion(+) diff --git a/proxy/http/server/reverse.py b/proxy/http/server/reverse.py index 4d91bf3a0a..303b627f20 100644 --- a/proxy/http/server/reverse.py +++ b/proxy/http/server/reverse.py @@ -84,6 +84,7 @@ def handle_request(self, request: HttpParser) -> None: self.choice = Url.from_bytes( random.choice(route[1]), ) + needs_upstream = True break # Dynamic routes elif isinstance(route, str): From 2dbc8af0bb96314ae17b208c804ead137ece5c5a Mon Sep 17 00:00:00 2001 From: Abhinav Singh <126065+abhinavsingh@users.noreply.github.com> Date: Mon, 5 Aug 2024 23:01:54 +0530 Subject: [PATCH 25/27] Support Grout Wildcards (#1439) * Support Grout Wildcards * Disable `Upload coverage to Codecov` which is already broken for sometime --- .github/workflows/test-library.yml | 10 +++++----- proxy/proxy.py | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test-library.yml b/.github/workflows/test-library.yml index c4b9f608ce..69c99a0ce3 100644 --- a/.github/workflows/test-library.yml +++ b/.github/workflows/test-library.yml @@ -545,11 +545,11 @@ jobs: --parallel-live --skip-missing-interpreters false --skip-pkg-install - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - with: - flags: pytest, GHA, Python ${{ matrix.python }}, ${{ runner.os }} - verbose: true + # - name: Upload coverage to Codecov + # uses: codecov/codecov-action@v3 + # with: + # flags: pytest, GHA, Python ${{ matrix.python }}, ${{ runner.os }} + # verbose: true test-container: runs-on: ubuntu-20.04 diff --git a/proxy/proxy.py b/proxy/proxy.py index fc102c8ab2..faa6cf3412 100644 --- a/proxy/proxy.py +++ b/proxy/proxy.py @@ -466,10 +466,11 @@ def _parse() -> Tuple[str, int]: parser = argparse.ArgumentParser(add_help=False) parser.add_argument('route', nargs='?', default=None) parser.add_argument('name', nargs='?', default=None) + parser.add_argument('--wildcard', action='store_true', help='Enable wildcard') args, _remaining_args = parser.parse_known_args() grout_tld = default_grout_tld if args.name is not None and '.' in args.name: - grout_tld = args.name.split('.', maxsplit=1)[1] + grout_tld = args.name if args.wildcard else args.name.split('.', maxsplit=1)[1] grout_tld_parts = grout_tld.split(':') tld_host = grout_tld_parts[0] tld_port = 443 From b9fa0d5a4c690693eb57b3a020e098e81fcf518d Mon Sep 17 00:00:00 2001 From: Abhinav Singh <126065+abhinavsingh@users.noreply.github.com> Date: Tue, 6 Aug 2024 10:02:32 +0530 Subject: [PATCH 26/27] Renable Codecov (#1440) * Renable Codecov * Bump codecov-action and provide CODECOV_TOKEN --- .github/workflows/test-library.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test-library.yml b/.github/workflows/test-library.yml index 69c99a0ce3..86fe218088 100644 --- a/.github/workflows/test-library.yml +++ b/.github/workflows/test-library.yml @@ -545,12 +545,13 @@ jobs: --parallel-live --skip-missing-interpreters false --skip-pkg-install - # - name: Upload coverage to Codecov - # uses: codecov/codecov-action@v3 - # with: - # flags: pytest, GHA, Python ${{ matrix.python }}, ${{ runner.os }} - # verbose: true - + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + flags: pytest, GHA, Python ${{ matrix.python }}, ${{ runner.os }} + verbose: true + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} test-container: runs-on: ubuntu-20.04 permissions: From 6ac5aedd2631985abc7630bba2b6974e30956707 Mon Sep 17 00:00:00 2001 From: Abhinav Singh <126065+abhinavsingh@users.noreply.github.com> Date: Tue, 6 Aug 2024 11:58:58 +0530 Subject: [PATCH 27/27] `Grout Wildcard` documentation (#1441) * Add documentation around `Grout Wildcard` support * Fix spellcheck * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- README.md | 59 ++++++++++++++++++++++++++++++++++++-- docs/spelling_wordlist.txt | 3 ++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cafa99d656..a82ce93fc8 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,10 @@ - [TLS Interception](#tls-interception) - [TLS Interception With Docker](#tls-interception-with-docker) - [GROUT (NGROK Alternative)](#grout-ngrok-alternative) + - [Grout Usage](#grout-usage) + - [Grout Authentication](#grout-authentication) + - [Grout Paths](#grout-paths) + - [Grout Wildcard Domains](#grout-wildcard-domains) - [Grout using Docker](#grout-using-docker) - [How Grout works](#how-grout-works) - [Self-hosted Grout](#self-hosted-grout) @@ -1323,7 +1327,10 @@ with TLS Interception: # GROUT (NGROK Alternative) -`grout` is a drop-in alternative to `ngrok` that comes packaged within `proxy.py` +1. `grout` is a drop-in alternative for `ngrok` and `frp` +2. `grout` comes packaged within `proxy.py` + +## Grout Usage ```console ❯ grout @@ -1375,12 +1382,58 @@ SUPPORT: https://jaxl.io ``` +## Grout Authentication + +Grout supports authentication to protect your files, folders and services from unauthorized +access. Use `--basic-auth` flag to enforce authentication. Example: + +```console +grout /path/to/folder --basic-auth user:pass +grout https://localhost:8080 --basic-auth u:p +``` + +## Grout Paths + +By default, Grout allows access to all paths on the services. Use `--path` flag to restrict +access to only certain paths on your web service. Example: + +```console +grout https://localhost:8080 --path /worker/ +grout https://localhost:8080 --path /webhook/ --path /callback/ +``` + +## Grout Wildcard Domains + +By default, Grout client serves incoming traffic on a dedicated subdomain. +However, some services (e.g. Kubernetes) may want to serve traffic on adhoc subdomains. +Starting a dedicated Grout client for every adhoc subdomain may not be a practical solution. + +For such scenarios, Grout supports wildcard domains. Here is how to configure your own +wildcard domain for use with Grout clients. + +1. Choose a domain e.g. `custom.example.com` +2. Your service wants to serve traffic for `custom.example.com` and `*.custom.example.com` +3. If you plan on using `https://`, you need to setup a load balancer: + - Setup a HTTPS load balancer (LB) + - Configure LB with certificate generated for `custom.example.com` and `*.custom.example.com` + - Point traffic to Grout service public IP addresses +4. Contact Grout team at support@jaxl.com to whitelist `custom.example.com`. Grout team will make + sure you really own the domain and you have configured a valid SSL certificate as described above + +Start Grout with `--wildcard` flag. Example: + +```console +grout https://localhost:8080 custom.example.com --wildcard +2024-08-05 18:24:59,294 - grout - Logged in as someone@gmail.com +2024-08-05 18:25:03,159 - setup - Grouting https://*.custom.domain.com +``` + ## Grout using Docker ```console -❯ docker run -it \ +❯ docker run --rm -it \ --entrypoint grout \ - --rm -v ~/.proxy:/root/.proxy \ + -v ~/.proxy:/root/.proxy \ abhinavsingh/proxy.py:latest \ http://host.docker.internal:29876 ``` diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 89bbd31845..103cac65a4 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -28,3 +28,6 @@ websocket writables www youtube +Kubernetes +adhoc +balancer