diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index d3bc138..4cff5e8 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -28,7 +28,7 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install poetry - poetry install --with dev + poetry install --with dev --all-extras - name: Check with ruff run: | poetry run ruff check src diff --git a/docs/user-guide/advanced/SOCKS-Proxy.md b/docs/user-guide/advanced/SOCKS-Proxy.md new file mode 100644 index 0000000..fd1eb2b --- /dev/null +++ b/docs/user-guide/advanced/SOCKS-Proxy.md @@ -0,0 +1,48 @@ +# Connecting Through a SOCKS5 Proxy + +If your target service is only reachable via an SSH gateway or resides behind a +firewall, you can route your [`pydase.Client`][pydase.Client] connection through a local +SOCKS5 proxy. This is particularly useful in network environments where direct access to +the service is not possible. + +## Setting Up a SOCKS5 Proxy + +You can create a local [SOCKS5 proxy](https://en.wikipedia.org/wiki/SOCKS) using SSH's +`-D` option: + +```bash +ssh -D 2222 user@gateway.example.com +``` + +This command sets up a SOCKS5 proxy on `localhost:2222`, securely forwarding traffic +over the SSH connection. + +## Using the Proxy in Your Python Client + +Once the proxy is running, configure the [`pydase.Client`][pydase.Client] to route +traffic through it using the `proxy_url` parameter: + +```python +import pydase + +client = pydase.Client( + url="ws://target-service:8001", + proxy_url="socks5://localhost:2222" +).proxy +``` + +* You can also use this setup with `wss://` URLs for encrypted WebSocket connections. + +## Installing Required Dependencies + +To use this feature, you must install the optional `socks` dependency group, which +includes [`aiohttp_socks`](https://pypi.org/project/aiohttp-socks/): + +- `poetry` + ```bash + poetry add "pydase[socks]" + ``` +- `pip` + ```bash + pip install "pydase[socks]" + ``` diff --git a/docs/user-guide/interaction/Python-Client.md b/docs/user-guide/interaction/Python-Client.md index 34e575f..2ac5190 100644 --- a/docs/user-guide/interaction/Python-Client.md +++ b/docs/user-guide/interaction/Python-Client.md @@ -1,6 +1,6 @@ # Python RPC Client -The [`pydase.Client`][pydase.Client] allows you to connect to a remote `pydase` service using socket.io, facilitating interaction with the service as though it were running locally. +The [`pydase.Client`][pydase.Client] allows you to connect to a remote `pydase` service using Socket.IO, facilitating interaction with the service as though it were running locally. ## Basic Usage @@ -9,6 +9,7 @@ import pydase # Replace and with the appropriate values for your service client_proxy = pydase.Client(url="ws://:").proxy + # For SSL-encrypted services, use the wss protocol # client_proxy = pydase.Client(url="wss://your-domain.ch").proxy @@ -22,6 +23,12 @@ The proxy acts as a local representation of the remote service, enabling intuiti The proxy class automatically synchronizes with the server's attributes and methods, keeping itself up-to-date with any changes. This dynamic synchronization essentially mirrors the server's API, making it feel like you're working with a local object. +### Accessing Services Behind Firewalls or SSH Gateways + +If your service is only reachable through a private network or SSH gateway, you can route your connection through a local SOCKS5 proxy using the `proxy_url` parameter. + +See [Connecting Through a SOCKS5 Proxy](../advanced/SOCKS-Proxy.md) for details. + ## Context Manager Support You can also use the client within a context manager, which automatically handles connection management (i.e., opening and closing the connection): @@ -53,6 +60,7 @@ class MyService(pydase.DataService): block_until_connected=False, client_id="my_pydase_client_id", ).proxy + # For SSL-encrypted services, use the wss protocol # proxy = pydase.Client( # url="wss://your-domain.ch", @@ -68,12 +76,12 @@ if __name__ == "__main__": In this example: - The `MyService` class has a `proxy` attribute that connects to a `pydase` service at `:`. -- By setting `block_until_connected=False`, the service can start without waiting for the connection to succeed, which is particularly useful in distributed systems where services may initialize in any order. -- By setting `client_id`, the server will provide more accurate logs of the connecting client. If set, this ID is sent as `X-Client-Id` header in the HTTP(s) request. +- By setting `block_until_connected=False`, the service can start without waiting for the connection to succeed. +- By setting `client_id`, the server will log a descriptive identifier for this client via the `X-Client-Id` HTTP header. ## Custom `socketio.AsyncClient` Connection Parameters -You can also configure advanced connection options by passing additional arguments to the underlying [`AsyncClient`][socketio.AsyncClient] via `sio_client_kwargs`. This allows you to fine-tune reconnection behaviour, delays, and other settings: +You can configure advanced connection options by passing arguments to the underlying [`AsyncClient`][socketio.AsyncClient] via `sio_client_kwargs`. For example: ```python client = pydase.Client( diff --git a/mkdocs.yml b/mkdocs.yml index 45fc799..a3d6496 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -19,6 +19,7 @@ nav: - Logging in pydase: user-guide/Logging.md - Advanced: - Deploying behind a Reverse Proxy: user-guide/advanced/Reverse-Proxy.md + - Connecting through a SOCKS Proxy: user-guide/advanced/SOCKS-Proxy.md - Developer Guide: - Developer Guide: dev-guide/README.md - API Reference: dev-guide/api.md diff --git a/poetry.lock b/poetry.lock index 461e5e8..58aeec6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -133,6 +133,23 @@ aiohttp = ">=3.8.1,<4.0.0" async-timeout = ">=4.0.2,<5.0.0" yarl = ">=1.5.1,<2.0.0" +[[package]] +name = "aiohttp-socks" +version = "0.10.1" +description = "Proxy connector for aiohttp" +optional = true +python-versions = ">=3.8.0" +groups = ["main"] +markers = "extra == \"socks\"" +files = [ + {file = "aiohttp_socks-0.10.1-py3-none-any.whl", hash = "sha256:6fd4d46c09f952f971a011ff446170daab8d539cf5310c0627f8423df2fb15ea"}, + {file = "aiohttp_socks-0.10.1.tar.gz", hash = "sha256:49f2e1f8051f2885719beb1b77e312b5a27c3e4b60f0b045a388f194d995e068"}, +] + +[package.dependencies] +aiohttp = ">=3.10.0" +python-socks = {version = ">=2.4.3,<3.0.0", extras = ["asyncio"]} + [[package]] name = "aiosignal" version = "1.3.2" @@ -2289,6 +2306,28 @@ asyncio-client = ["aiohttp (>=3.4)"] client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"] docs = ["sphinx"] +[[package]] +name = "python-socks" +version = "2.7.1" +description = "Proxy (SOCKS4, SOCKS5, HTTP CONNECT) client for Python" +optional = true +python-versions = ">=3.8.0" +groups = ["main"] +markers = "extra == \"socks\"" +files = [ + {file = "python_socks-2.7.1-py3-none-any.whl", hash = "sha256:2603c6454eeaeb82b464ad705be188989e8cf1a4a16f0af3c921d6dd71a49cec"}, + {file = "python_socks-2.7.1.tar.gz", hash = "sha256:f1a0bb603830fe81e332442eada96757b8f8dec02bd22d1d6f5c99a79704c550"}, +] + +[package.dependencies] +async-timeout = {version = ">=4.0", optional = true, markers = "python_version < \"3.11\" and extra == \"asyncio\""} + +[package.extras] +anyio = ["anyio (>=3.3.4,<5.0.0)"] +asyncio = ["async-timeout (>=4.0) ; python_version < \"3.11\""] +curio = ["curio (>=1.4)"] +trio = ["trio (>=0.24)"] + [[package]] name = "pyyaml" version = "6.0.2" @@ -2776,7 +2815,10 @@ idna = ">=2.0" multidict = ">=4.0" propcache = ">=0.2.1" +[extras] +socks = ["aiohttp-socks"] + [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "3a5ca427686cd80e749ac69aa795540a0e0b3bd0d539cd3a8a264e94eeb48782" +content-hash = "07754bc1fa6fc5e4b15c253a68cfe32368ae0a1bb9e83d8f7fd80ee61013c401" diff --git a/pyproject.toml b/pyproject.toml index 4216c44..3725da1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,9 @@ dependencies = [ "anyio (>=4.9.0,<5.0.0)" ] +[project.optional-dependencies] +socks = ["aiohttp-socks (>=0.10.1,<0.11.0)"] + [tool.poetry] packages = [{include = "pydase", from = "src"}] diff --git a/src/pydase/client/client.py b/src/pydase/client/client.py index f06c1ed..bdec294 100644 --- a/src/pydase/client/client.py +++ b/src/pydase/client/client.py @@ -6,6 +6,8 @@ import urllib.parse from types import TracebackType from typing import TYPE_CHECKING, Any, TypedDict, cast +import aiohttp +import aiohttp_socks.connector import socketio # type: ignore from pydase.client.proxy_class import ProxyClass @@ -40,47 +42,52 @@ def asyncio_loop_thread(loop: asyncio.AbstractEventLoop) -> None: 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 connection, disconnection, and updates, and ensures that the proxy object is up-to-date with the server state. Args: - url: - The URL of the pydase Socket.IO server. This should always contain the - protocol and the hostname. - block_until_connected: - If set to True, the constructor will block until the connection to the - service has been established. This is useful for ensuring the client is - ready to use immediately after instantiation. Default is True. - sio_client_kwargs: - Additional keyword arguments passed to the underlying + url: 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 + a path prefix (e.g., `ws://localhost:8001/service`). + block_until_connected: If set to True, the constructor will block until the + connection to the service has been established. This is useful for ensuring + the client is ready to use immediately after instantiation. Default is True. + sio_client_kwargs: Additional keyword arguments passed to the underlying [`AsyncClient`][socketio.AsyncClient]. This allows fine-tuning of the client's behaviour (e.g., reconnection attempts or reconnection delay). - Default is an empty dictionary. - client_id: Client identification that will be shown in the server logs this - client is connecting to. This ID is passed as a `X-Client-Id` header in the - HTTP(s) request. Defaults to None. + client_id: An optional client identifier. This ID is sent to the server as the + `X-Client-Id` HTTP header. It can be used for logging or authentication + purposes on the server side. + 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: - The following example demonstrates a `Client` instance that connects to another - pydase service, while customising some of the connection settings for the - underlying [`AsyncClient`][socketio.AsyncClient]. + Connect to a service directly: ```python - pydase.Client(url="ws://localhost:8001", sio_client_kwargs={ - "reconnection_attempts": 2, - "reconnection_delay": 2, - "reconnection_delay_max": 8, - }) + client = pydase.Client(url="ws://localhost:8001") ``` - When connecting to a server over a secure connection (i.e., the server is using - SSL/TLS encryption), make sure that the `wss` protocol is used instead of `ws`: + Connect over a secure connection: ```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, sio_client_kwargs: dict[str, Any] = {}, client_id: str | None = None, + proxy_url: str | None = None, ): # Parse the URL to separate base URL and path prefix parsed_url = urllib.parse.urlparse(url) @@ -103,8 +111,9 @@ class Client: # 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._url = url - self._sio = socketio.AsyncClient(**sio_client_kwargs) + self._proxy_url = proxy_url self._client_id = client_id + self._sio_client_kwargs = sio_client_kwargs self._loop: asyncio.AbstractEventLoop | None = None self._thread: threading.Thread | None = None self.proxy: ProxyClass @@ -126,6 +135,12 @@ class Client: def connect(self, block_until_connected: bool = True) -> None: if self._thread is None or self._loop is None: self._loop = self._initialize_loop_and_thread() + self._initialize_socketio_client() + self.proxy = ProxyClass( + sio_client=self._sio, + loop=self._loop, + reconnect=self.connect, + ) connection_future = asyncio.run_coroutine_threadsafe( self._connect(), self._loop @@ -133,17 +148,26 @@ class Client: if block_until_connected: connection_future.result() + def _initialize_socketio_client(self) -> 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) + def _initialize_loop_and_thread(self) -> asyncio.AbstractEventLoop: """Initialize a new asyncio event loop, start it in a background thread, and create the ProxyClass instance bound to that loop. """ loop = asyncio.new_event_loop() - self.proxy = ProxyClass( - sio_client=self._sio, - loop=loop, - reconnect=self.connect, - ) self._thread = threading.Thread( target=asyncio_loop_thread, args=(loop,),