diff --git a/README.md b/README.md index 11c1c4d088..ec7f71713f 100644 --- a/README.md +++ b/README.md @@ -1272,8 +1272,15 @@ Start `proxy.py` as: --tunnel-username username \ --tunnel-hostname ip.address.or.domain.name \ --tunnel-port 22 \ - --tunnel-remote-host 127.0.0.1 - --tunnel-remote-port 8899 + --tunnel-remote-port 8899 \ + --tunnel-ssh-key /path/to/ssh/private.key \ + --tunnel-ssh-key-passphrase XXXXX +...[redacted]... [I] listener.setup:97 - Listening on 127.0.0.1:8899 +...[redacted]... [I] pool.setup:106 - Started 16 acceptors in threadless (local) mode +...[redacted]... [I] transport._log:1873 - Connected (version 2.0, client OpenSSH_7.6p1) +...[redacted]... [I] transport._log:1873 - Authentication (publickey) successful! +...[redacted]... [I] listener.setup:116 - SSH connection established to ip.address.or.domain.name:22... +...[redacted]... [I] listener.start_port_forward:91 - :8899 forwarding successful... ``` Make a HTTP proxy request on `remote` server and @@ -1312,6 +1319,13 @@ access_log:328 - remote:52067 - GET httpbin.org:80 FIREWALL (allow tcp/22) +Not planned. + +If you have a valid use case, kindly open an issue. You are always welcome to send +contributions via pull-requests to add this functionality :) + +> To proxy local requests remotely, make use of [Proxy Pool Plugin](#proxypoolplugin). + # Embed proxy.py ## Blocking Mode diff --git a/docs/conf.py b/docs/conf.py index 8543ee8014..8daf9fd725 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -284,6 +284,7 @@ (_py_class_role, '_asyncio.Task'), (_py_class_role, 'asyncio.events.AbstractEventLoop'), (_py_class_role, 'CacheStore'), + (_py_class_role, 'Channel'), (_py_class_role, 'HttpParser'), (_py_class_role, 'HttpProtocolHandlerPlugin'), (_py_class_role, 'HttpProxyBasePlugin'), diff --git a/proxy/common/constants.py b/proxy/common/constants.py index 1da2f5e30a..37ef452f54 100644 --- a/proxy/common/constants.py +++ b/proxy/common/constants.py @@ -88,6 +88,7 @@ def _env_threadless_compliant() -> bool: DEFAULT_DISABLE_HEADERS: List[bytes] = [] DEFAULT_DISABLE_HTTP_PROXY = False DEFAULT_ENABLE_DASHBOARD = False +DEFAULT_ENABLE_SSH_TUNNEL = False DEFAULT_ENABLE_DEVTOOLS = False DEFAULT_ENABLE_EVENTS = False DEFAULT_EVENTS_QUEUE = None diff --git a/proxy/core/ssh/__init__.py b/proxy/core/ssh/__init__.py index e37310801c..2150e1557c 100644 --- a/proxy/core/ssh/__init__.py +++ b/proxy/core/ssh/__init__.py @@ -12,10 +12,10 @@ Submodules """ -from .client import SshClient -from .tunnel import Tunnel +from .handler import SshHttpProtocolHandler +from .listener import SshTunnelListener __all__ = [ - 'SshClient', - 'Tunnel', + 'SshHttpProtocolHandler', + 'SshTunnelListener', ] diff --git a/proxy/core/ssh/client.py b/proxy/core/ssh/client.py deleted file mode 100644 index 4657b1a3c1..0000000000 --- a/proxy/core/ssh/client.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- 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 socket -import ssl -from typing import Union - -from ..connection import TcpClientConnection - - -class SshClient(TcpClientConnection): - """Overrides TcpClientConnection. - - This is necessary because paramiko ``fileno()`` can be used for polling - but not for send / recv. - """ - - @property - def connection(self) -> Union[ssl.SSLSocket, socket.socket]: - # Dummy return to comply with - return socket.socket() diff --git a/proxy/core/ssh/handler.py b/proxy/core/ssh/handler.py new file mode 100644 index 0000000000..fdf6f3aa65 --- /dev/null +++ b/proxy/core/ssh/handler.py @@ -0,0 +1,34 @@ +# -*- 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 argparse + +from typing import TYPE_CHECKING, Tuple + +if TYPE_CHECKING: + try: + from paramiko.channel import Channel + except ImportError: + pass + + +class SshHttpProtocolHandler: + """Handles incoming connections over forwarded SSH transport.""" + + def __init__(self, flags: argparse.Namespace) -> None: + self.flags = flags + + def on_connection( + self, + chan: 'Channel', + origin: Tuple[str, int], + server: Tuple[str, int], + ) -> None: + pass diff --git a/proxy/core/ssh/listener.py b/proxy/core/ssh/listener.py new file mode 100644 index 0000000000..47c9b41419 --- /dev/null +++ b/proxy/core/ssh/listener.py @@ -0,0 +1,135 @@ +# -*- 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 argparse +import logging + +from typing import TYPE_CHECKING, Any, Callable, Optional, Set, Tuple + +try: + from paramiko import SSHClient, AutoAddPolicy + from paramiko.transport import Transport + if TYPE_CHECKING: + from paramiko.channel import Channel +except ImportError: + pass + +from ...common.flag import flags + +logger = logging.getLogger(__name__) + + +flags.add_argument( + '--tunnel-hostname', + type=str, + default=None, + help='Default: None. Remote hostname or IP address to which SSH tunnel will be established.', +) + +flags.add_argument( + '--tunnel-port', + type=int, + default=22, + help='Default: 22. SSH port of the remote host.', +) + +flags.add_argument( + '--tunnel-username', + type=str, + default=None, + help='Default: None. Username to use for establishing SSH tunnel.', +) + +flags.add_argument( + '--tunnel-ssh-key', + type=str, + default=None, + help='Default: None. Private key path in pem format', +) + +flags.add_argument( + '--tunnel-ssh-key-passphrase', + type=str, + default=None, + help='Default: None. Private key passphrase', +) + +flags.add_argument( + '--tunnel-remote-port', + type=int, + default=8899, + help='Default: 8899. Remote port which will be forwarded locally for proxy.', +) + + +class SshTunnelListener: + """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', Tuple[str, int], Tuple[str, int]], None], + ) -> None: + self.flags = flags + self.on_connection_callback = on_connection_callback + self.ssh: Optional[SSHClient] = None + self.transport: Optional[Transport] = None + self.forwarded: Set[Tuple[str, int]] = set() + + def start_port_forward(self, remote_addr: Tuple[str, int]) -> None: + assert self.transport is not None + self.transport.request_port_forward( + *remote_addr, + handler=self.on_connection_callback, + ) + self.forwarded.add(remote_addr) + logger.info('%s:%d forwarding successful...' % remote_addr) + + def stop_port_forward(self, remote_addr: Tuple[str, int]) -> 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: + self.ssh = SSHClient() + self.ssh.load_system_host_keys() + self.ssh.set_missing_host_key_policy(AutoAddPolicy()) + self.ssh.connect( + hostname=self.flags.tunnel_hostname, + port=self.flags.tunnel_port, + username=self.flags.tunnel_username, + key_filename=self.flags.tunnel_ssh_key, + passphrase=self.flags.tunnel_ssh_key_passphrase, + ) + logger.info( + 'SSH connection established to %s:%d...' % ( + self.flags.tunnel_hostname, + self.flags.tunnel_port, + ), + ) + self.transport = self.ssh.get_transport() + + def shutdown(self) -> None: + for remote_addr in list(self.forwarded): + self.stop_port_forward(remote_addr) + self.forwarded.clear() + if self.transport is not None: + self.transport.close() + if self.ssh is not None: + self.ssh.close() diff --git a/proxy/core/ssh/tunnel.py b/proxy/core/ssh/tunnel.py deleted file mode 100644 index 4a899543ae..0000000000 --- a/proxy/core/ssh/tunnel.py +++ /dev/null @@ -1,70 +0,0 @@ -# -*- 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 paramiko - -from typing import Optional, Tuple, Callable - -logger = logging.getLogger(__name__) - - -class Tunnel: - """Establishes a tunnel between local (machine where Tunnel is running) and remote host. - Once a tunnel has been established, remote host can route HTTP(s) traffic to - ``localhost`` over tunnel. - """ - - def __init__( - self, - ssh_username: str, - remote_addr: Tuple[str, int], - private_pem_key: str, - remote_proxy_port: int, - conn_handler: Callable[[paramiko.channel.Channel], None], - ) -> None: - self.remote_addr = remote_addr - self.ssh_username = ssh_username - self.private_pem_key = private_pem_key - self.remote_proxy_port = remote_proxy_port - self.conn_handler = conn_handler - - def run(self) -> None: - ssh = paramiko.SSHClient() - ssh.load_system_host_keys() - ssh.set_missing_host_key_policy(paramiko.WarningPolicy()) - try: - ssh.connect( - hostname=self.remote_addr[0], - port=self.remote_addr[1], - username=self.ssh_username, - key_filename=self.private_pem_key, - ) - logger.info('SSH connection established...') - transport: Optional[paramiko.transport.Transport] = ssh.get_transport( - ) - assert transport is not None - transport.request_port_forward('', self.remote_proxy_port) - logger.info('Tunnel port forward setup successful...') - while True: - conn: Optional[paramiko.channel.Channel] = transport.accept( - timeout=1, - ) - assert conn is not None - e = transport.get_exception() - if e: - raise e - if conn is None: - continue - self.conn_handler(conn) - except KeyboardInterrupt: - pass - finally: - ssh.close() diff --git a/proxy/core/work/threadless.py b/proxy/core/work/threadless.py index d713e94440..bf50507310 100644 --- a/proxy/core/work/threadless.py +++ b/proxy/core/work/threadless.py @@ -25,7 +25,7 @@ from ...common.constants import DEFAULT_INACTIVE_CONN_CLEANUP_TIMEOUT, DEFAULT_SELECTOR_SELECT_TIMEOUT from ...common.constants import DEFAULT_WAIT_FOR_TASKS_TIMEOUT -from ..connection import TcpClientConnection, UpstreamConnectionPool +from ..connection import TcpClientConnection from ..event import eventNames if TYPE_CHECKING: # pragma: no cover @@ -91,7 +91,10 @@ def __init__( self.wait_timeout: float = DEFAULT_WAIT_FOR_TASKS_TIMEOUT self.cleanup_inactive_timeout: float = DEFAULT_INACTIVE_CONN_CLEANUP_TIMEOUT self._total: int = 0 - self._upstream_conn_pool: Optional[UpstreamConnectionPool] = None + # When put at the top, causes circular import error + # since integrated ssh tunnel was introduced. + from ..connection import UpstreamConnectionPool # pylint: disable=C0415 + self._upstream_conn_pool: Optional['UpstreamConnectionPool'] = None self._upstream_conn_filenos: Set[int] = set() if self.flags.enable_conn_pool: self._upstream_conn_pool = UpstreamConnectionPool() diff --git a/proxy/proxy.py b/proxy/proxy.py index b37e80a812..9aed833f69 100644 --- a/proxy/proxy.py +++ b/proxy/proxy.py @@ -16,15 +16,19 @@ from typing import List, Optional, Any +from proxy.core.ssh.listener import SshTunnelListener + from .core.work import ThreadlessPool from .core.event import EventManager +from .core.ssh import SshHttpProtocolHandler from .core.acceptor import AcceptorPool, Listener from .common.utils import bytes_ from .common.flag import FlagParser, flags -from .common.constants import DEFAULT_LOCAL_EXECUTOR, DEFAULT_LOG_FILE, DEFAULT_LOG_FORMAT, DEFAULT_LOG_LEVEL, IS_WINDOWS +from .common.constants import DEFAULT_ENABLE_SSH_TUNNEL, DEFAULT_LOCAL_EXECUTOR, DEFAULT_LOG_FILE from .common.constants import DEFAULT_OPEN_FILE_LIMIT, DEFAULT_PLUGINS, DEFAULT_VERSION from .common.constants import DEFAULT_ENABLE_DASHBOARD, DEFAULT_WORK_KLASS, DEFAULT_PID_FILE +from .common.constants import DEFAULT_LOG_FORMAT, DEFAULT_LOG_LEVEL, IS_WINDOWS logger = logging.getLogger(__name__) @@ -96,6 +100,13 @@ help='Default: False. Enables proxy.py dashboard.', ) +flags.add_argument( + '--enable-ssh-tunnel', + action='store_true', + default=DEFAULT_ENABLE_SSH_TUNNEL, + help='Default: False. Enable SSH tunnel.', +) + flags.add_argument( '--work-klass', type=str, @@ -135,6 +146,8 @@ def __init__(self, input_args: Optional[List[str]] = None, **opts: Any) -> 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 def __enter__(self) -> 'Proxy': self.setup() @@ -193,10 +206,26 @@ def setup(self) -> None: event_queue=event_queue, ) 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( + 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), + ) # TODO: May be close listener fd as we don't need it now self._register_signals() def shutdown(self) -> None: + if self.flags.enable_ssh_tunnel: + assert self.ssh_tunnel_listener is not None + self.ssh_tunnel_listener.shutdown() assert self.acceptors self.acceptors.shutdown() if self.remote_executors_enabled: diff --git a/tests/test_main.py b/tests/test_main.py index 8ac149fb53..6b465c47f9 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -17,7 +17,7 @@ from proxy.proxy import main, entry_point from proxy.common.utils import bytes_ from proxy.common.constants import ( # noqa: WPS450 - DEFAULT_CA_CERT_DIR, DEFAULT_PORT, DEFAULT_PLUGINS, DEFAULT_TIMEOUT, DEFAULT_KEY_FILE, + DEFAULT_CA_CERT_DIR, DEFAULT_ENABLE_SSH_TUNNEL, DEFAULT_PORT, DEFAULT_PLUGINS, DEFAULT_TIMEOUT, DEFAULT_KEY_FILE, DEFAULT_LOG_FILE, DEFAULT_PAC_FILE, DEFAULT_PID_FILE, PLUGIN_DASHBOARD, DEFAULT_CERT_FILE, DEFAULT_LOG_LEVEL, DEFAULT_PORT_FILE, PLUGIN_HTTP_PROXY, PLUGIN_PROXY_AUTH, PLUGIN_WEB_SERVER, DEFAULT_BASIC_AUTH, @@ -75,6 +75,7 @@ def mock_default_args(mock_args: mock.Mock) -> None: mock_args.work_klass = DEFAULT_WORK_KLASS mock_args.local_executor = int(DEFAULT_LOCAL_EXECUTOR) mock_args.port_file = DEFAULT_PORT_FILE + mock_args.enable_ssh_tunnel = DEFAULT_ENABLE_SSH_TUNNEL @mock.patch('os.remove') @mock.patch('os.path.exists') @@ -103,6 +104,7 @@ def test_entry_point( mock_initialize.return_value.enable_events = False mock_initialize.return_value.pid_file = pid_file mock_initialize.return_value.port_file = None + mock_initialize.return_value.enable_ssh_tunnel = False entry_point() mock_event_manager.assert_not_called() mock_listener.assert_called_once_with( @@ -151,6 +153,7 @@ def test_main_with_no_flags( mock_initialize.return_value.local_executor = 0 mock_initialize.return_value.enable_events = False mock_initialize.return_value.port_file = None + mock_initialize.return_value.enable_ssh_tunnel = False main() mock_event_manager.assert_not_called() mock_listener.assert_called_once_with( @@ -192,6 +195,7 @@ def test_enable_events( mock_initialize.return_value.local_executor = 0 mock_initialize.return_value.enable_events = True mock_initialize.return_value.port_file = None + mock_initialize.return_value.enable_ssh_tunnel = False main() mock_event_manager.assert_called_once() mock_event_manager.return_value.setup.assert_called_once()