Repository: abhinavsingh/proxy.py
Pull request #992 (2022-01-15 08:21:35)

[SshTunnel] WIP

codecov[bot]: # [Codecov](https://codecov.io/gh/abhinavsingh/proxy.py/pull/992?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh) Report
> Merging [#992](https://codecov.io/gh/abhinavsingh/proxy.py/pull/992?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh) (64fb9ed) into [develop](https://codecov.io/gh/abhinavsingh/proxy.py/commit/4222172d011442dcb13f40b66825206159abf31f?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh) (4222172) will **decrease** coverage by `0.29%`.
> The diff coverage is `55.29%`.

[![Impacted file tree graph](https://codecov.io/gh/abhinavsingh/proxy.py/pull/992/graphs/tree.svg?width=650&height=150&src=pr&token=Zh9J7b4la2&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh)](https://codecov.io/gh/abhinavsingh/proxy.py/pull/992?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh)

```diff
@@             Coverage Diff             @@
##           develop     #992      +/-   ##
===========================================
- Coverage    86.74%   86.45%   -0.30%     
===========================================
  Files          145      145              
  Lines         6217     6246      +29     
  Branches       621      576      -45     
===========================================
+ Hits          5393     5400       +7     
- Misses         716      730      +14     
- Partials       108      116       +8     
```

| Flag | Coverage Δ | |
|---|---|---|
| pytest | `86.45% <55.29%> (-0.18%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/abhinavsingh/proxy.py/pull/992?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh) | Coverage Δ | |
|---|---|---|
| [proxy/core/ssh/listener.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/992/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh#diff-cHJveHkvY29yZS9zc2gvbGlzdGVuZXIucHk=) | `45.09% <45.09%> (ø)` | |
| [proxy/proxy.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/992/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh#diff-cHJveHkvcHJveHkucHk=) | `92.37% <46.66%> (-7.63%)` | :arrow_down: |
| [proxy/core/ssh/handler.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/992/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh#diff-cHJveHkvY29yZS9zc2gvaGFuZGxlci5weQ==) | `77.77% <77.77%> (ø)` | |
| [proxy/common/constants.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/992/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh#diff-cHJveHkvY29tbW9uL2NvbnN0YW50cy5weQ==) | `100.00% <100.00%> (ø)` | |
| [proxy/core/ssh/\_\_init\_\_.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/992/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh#diff-cHJveHkvY29yZS9zc2gvX19pbml0X18ucHk=) | `100.00% <100.00%> (ø)` | |
| [proxy/core/work/threadless.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/992/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh#diff-cHJveHkvY29yZS93b3JrL3RocmVhZGxlc3MucHk=) | `80.66% <100.00%> (ø)` | |
| [tests/test\_main.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/992/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh#diff-dGVzdHMvdGVzdF9tYWluLnB5) | `98.83% <100.00%> (-1.17%)` | :arrow_down: |
| [tests/test\_set\_open\_file\_limit.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/992/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh#diff-dGVzdHMvdGVzdF9zZXRfb3Blbl9maWxlX2xpbWl0LnB5) | `96.42% <0.00%> (-3.58%)` | :arrow_down: |
| [proxy/http/handler.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/992/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh#diff-cHJveHkvaHR0cC9oYW5kbGVyLnB5) | `70.56% <0.00%> (-2.29%)` | :arrow_down: |
| [proxy/testing/test\_case.py](https://codecov.io/gh/abhinavsingh/proxy.py/pull/992/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh#diff-cHJveHkvdGVzdGluZy90ZXN0X2Nhc2UucHk=) | `97.77% <0.00%> (-2.23%)` | :arrow_down: |
| ... and [6 more](https://codecov.io/gh/abhinavsingh/proxy.py/pull/992/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/abhinavsingh/proxy.py/pull/992?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/abhinavsingh/proxy.py/pull/992?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh). Last update [4222172...64fb9ed](https://codecov.io/gh/abhinavsingh/proxy.py/pull/992?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Abhinav+Singh).


Patch:
diff --git a/README.md b/README.md
index 11c1c4d..ec7f717 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 8543ee8..8daf9fd 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -284,6 +284,7 @@ nitpick_ignore = [
     (_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 1da2f5e..37ef452 100644
--- a/proxy/common/constants.py
+++ b/proxy/common/constants.py
@@ -88,6 +88,7 @@ DEFAULT_DEVTOOLS_WS_PATH = b'/devtools'
 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 e373108..2150e15 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 4657b1a..0000000
--- 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 0000000..fdf6f3a
--- /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 0000000..47c9b41
--- /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 4a89954..0000000
--- 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 d713e94..bf50507 100644
--- a/proxy/core/work/threadless.py
+++ b/proxy/core/work/threadless.py
@@ -25,7 +25,7 @@ from ...common.types import Readables, SelectableEvents, Writables
 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 @@ class Threadless(ABC, Generic[T]):
         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 b37e80a..9aed833 100644
--- a/proxy/proxy.py
+++ b/proxy/proxy.py
@@ -16,15 +16,19 @@ import logging
 
 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 @@ flags.add_argument(
     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 @@ class Proxy:
         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 @@ class Proxy:
             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:


Test patch:
diff --git a/tests/test_main.py b/tests/test_main.py
index 8ac149f..6b465c4 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -17,7 +17,7 @@ from unittest import mock
 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 @@ class TestMain(unittest.TestCase):
         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 @@ class TestMain(unittest.TestCase):
         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 @@ class TestMain(unittest.TestCase):
         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 @@ class TestMain(unittest.TestCase):
         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()