feat: adds support for services behind a SOCKS5 proxy

This commit is contained in:
Mose Müller 2025-05-19 14:21:42 +02:00
parent 3d65240784
commit 18c66a8318

View File

@ -6,6 +6,8 @@ import urllib.parse
from types import TracebackType from types import TracebackType
from typing import TYPE_CHECKING, Any, TypedDict, cast from typing import TYPE_CHECKING, Any, TypedDict, cast
import aiohttp
import aiohttp_socks.connector
import socketio # type: ignore import socketio # type: ignore
from pydase.client.proxy_class import ProxyClass from pydase.client.proxy_class import ProxyClass
@ -40,47 +42,52 @@ def asyncio_loop_thread(loop: asyncio.AbstractEventLoop) -> None:
class Client: class Client:
""" """A client for connecting to a remote pydase service using Socket.IO. This client
A client for connecting to a remote pydase service using socket.io. This client
handles asynchronous communication with a service, manages events such as handles asynchronous communication with a service, manages events such as
connection, disconnection, and updates, and ensures that the proxy object is connection, disconnection, and updates, and ensures that the proxy object is
up-to-date with the server state. up-to-date with the server state.
Args: Args:
url: url: The URL of the pydase Socket.IO server. This should always contain the
The URL of the pydase Socket.IO server. This should always contain the protocol (e.g., `ws` or `wss`) and the hostname, and can optionally include
protocol and the hostname. a path prefix (e.g., `ws://localhost:8001/service`).
block_until_connected: block_until_connected: If set to True, the constructor will block until the
If set to True, the constructor will block until the connection to the connection to the service has been established. This is useful for ensuring
service has been established. This is useful for ensuring the client is the client is ready to use immediately after instantiation. Default is True.
ready to use immediately after instantiation. Default is True. sio_client_kwargs: Additional keyword arguments passed to the underlying
sio_client_kwargs:
Additional keyword arguments passed to the underlying
[`AsyncClient`][socketio.AsyncClient]. This allows fine-tuning of the [`AsyncClient`][socketio.AsyncClient]. This allows fine-tuning of the
client's behaviour (e.g., reconnection attempts or reconnection delay). client's behaviour (e.g., reconnection attempts or reconnection delay).
Default is an empty dictionary. client_id: An optional client identifier. This ID is sent to the server as the
client_id: Client identification that will be shown in the server logs this `X-Client-Id` HTTP header. It can be used for logging or authentication
client is connecting to. This ID is passed as a `X-Client-Id` header in the purposes on the server side.
HTTP(s) request. Defaults to None. proxy_url: An optional proxy URL to route the connection through. This is useful
if the service is only reachable via an SSH tunnel or behind a firewall
(e.g., `socks5://localhost:2222`).
Example: Example:
The following example demonstrates a `Client` instance that connects to another Connect to a service directly:
pydase service, while customising some of the connection settings for the
underlying [`AsyncClient`][socketio.AsyncClient].
```python ```python
pydase.Client(url="ws://localhost:8001", sio_client_kwargs={ client = pydase.Client(url="ws://localhost:8001")
"reconnection_attempts": 2,
"reconnection_delay": 2,
"reconnection_delay_max": 8,
})
``` ```
When connecting to a server over a secure connection (i.e., the server is using Connect over a secure connection:
SSL/TLS encryption), make sure that the `wss` protocol is used instead of `ws`:
```python ```python
pydase.Client(url="wss://my-service.example.com") client = pydase.Client(url="wss://my-service.example.com")
```
Connect using a SOCKS5 proxy (e.g., through an SSH tunnel):
```bash
ssh -D 2222 user@gateway.example.com
```
```python
client = pydase.Client(
url="ws://remote-server:8001",
proxy_url="socks5://localhost:2222"
)
``` ```
""" """
@ -91,6 +98,7 @@ class Client:
block_until_connected: bool = True, block_until_connected: bool = True,
sio_client_kwargs: dict[str, Any] = {}, sio_client_kwargs: dict[str, Any] = {},
client_id: str | None = None, client_id: str | None = None,
proxy_url: str | None = None,
): ):
# Parse the URL to separate base URL and path prefix # Parse the URL to separate base URL and path prefix
parsed_url = urllib.parse.urlparse(url) parsed_url = urllib.parse.urlparse(url)
@ -103,8 +111,9 @@ class Client:
# Store the path prefix (e.g., "/service" in "ws://localhost:8081/service") # Store the path prefix (e.g., "/service" in "ws://localhost:8081/service")
self._path_prefix = parsed_url.path.rstrip("/") # Remove trailing slash if any self._path_prefix = parsed_url.path.rstrip("/") # Remove trailing slash if any
self._url = url self._url = url
self._sio = socketio.AsyncClient(**sio_client_kwargs) self._proxy_url = proxy_url
self._client_id = client_id self._client_id = client_id
self._sio_client_kwargs = sio_client_kwargs
self._loop: asyncio.AbstractEventLoop | None = None self._loop: asyncio.AbstractEventLoop | None = None
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
self.proxy: ProxyClass self.proxy: ProxyClass
@ -125,6 +134,19 @@ class Client:
def connect(self, block_until_connected: bool = True) -> None: def connect(self, block_until_connected: bool = True) -> None:
if self._thread is None or self._loop is None: if self._thread is None or self._loop is None:
if self._proxy_url is not None:
session = aiohttp.ClientSession(
connector=aiohttp_socks.connector.ProxyConnector.from_url(
url=self._proxy_url, loop=self._loop
),
loop=self._loop,
)
self._sio = socketio.AsyncClient(
http_session=session, **self._sio_client_kwargs
)
else:
self._sio = socketio.AsyncClient(**self._sio_client_kwargs)
self._loop = self._initialize_loop_and_thread() self._loop = self._initialize_loop_and_thread()
connection_future = asyncio.run_coroutine_threadsafe( connection_future = asyncio.run_coroutine_threadsafe(