From 32d4048c6bc5cb43d908362fdd1f5644d42e45e6 Mon Sep 17 00:00:00 2001 From: konsisumer Date: Mon, 27 Apr 2026 16:57:08 +0200 Subject: [PATCH] fix: MatrixAdapter respects proxy configuration --- gateway/platforms/base.py | 25 +++++---- gateway/platforms/matrix.py | 57 +++++++++++++++++-- pyproject.toml | 2 +- tests/gateway/test_matrix.py | 87 +++++++++++++++++++++++++++++ tests/gateway/test_platform_base.py | 46 +++++++++++++++ 5 files changed, 201 insertions(+), 16 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index d03978cd1b..47aff244f8 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -307,9 +307,14 @@ def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]: """Build kwargs for standalone ``aiohttp.ClientSession`` with proxy. Returns ``(session_kwargs, request_kwargs)`` where: - - SOCKS → ``({"connector": ProxyConnector(...)}, {})`` - - HTTP → ``({}, {"proxy": url})`` - - None → ``({}, {})`` + - With aiohttp-socks → ``({"connector": ProxyConnector(...)}, {})`` + for *all* proxy schemes (SOCKS **and** HTTP/HTTPS). + - HTTP without aiohttp-socks → ``({}, {"proxy": url})``. + - None → ``({}, {})``. + + Prefer the connector path: it works transparently with libraries + (like mautrix) that call ``session.request()`` without forwarding + per-request ``proxy=`` kwargs. Usage:: @@ -320,20 +325,20 @@ def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]: """ if not proxy_url: return {}, {} - if proxy_url.lower().startswith("socks"): - try: - from aiohttp_socks import ProxyConnector + try: + from aiohttp_socks import ProxyConnector - connector = ProxyConnector.from_url(proxy_url, rdns=True) - return {"connector": connector}, {} - except ImportError: + connector = ProxyConnector.from_url(proxy_url, rdns=True) + return {"connector": connector}, {} + except ImportError: + if proxy_url.lower().startswith("socks"): logger.warning( "aiohttp_socks not installed — SOCKS proxy %s ignored. " "Run: pip install aiohttp-socks", proxy_url, ) return {}, {} - return {}, {"proxy": proxy_url} + return {}, {"proxy": proxy_url} def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = None) -> bool: diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 85d48598fc..8c07f6fc6e 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -11,6 +11,7 @@ Environment variables: MATRIX_PASSWORD Password (alternative to access token) MATRIX_ENCRYPTION Set "true" to enable E2EE MATRIX_DEVICE_ID Stable device ID for E2EE persistence across restarts + MATRIX_PROXY HTTP(S) or SOCKS proxy URL for Matrix traffic MATRIX_ALLOWED_USERS Comma-separated Matrix user IDs (@user:server) MATRIX_HOME_ROOM Room ID for cron/notification delivery MATRIX_REACTIONS Set "false" to disable processing lifecycle reactions @@ -96,6 +97,8 @@ from gateway.platforms.base import ( MessageType, ProcessingOutcome, SendResult, + resolve_proxy_url, + proxy_kwargs_for_aiohttp, ) from gateway.platforms.helpers import ThreadParticipationTracker @@ -162,6 +165,39 @@ def _looks_like_matrix_image_filename(text: str) -> bool: return suffix in _MATRIX_IMAGE_FILENAME_EXTS +def _create_matrix_session(proxy_url: str | None): + """Create an ``aiohttp.ClientSession`` whose proxy applies to *all* requests. + + mautrix's ``HTTPAPI._send()`` calls ``session.request()`` without forwarding + per-request ``proxy=`` kwargs. For HTTP(S) proxies we use aiohttp's native + ``proxy=`` session parameter which sets a default for every request. For SOCKS + we use ``aiohttp_socks.ProxyConnector`` (connector-level). + When no proxy is configured we enable ``trust_env`` so standard env vars + (``HTTP_PROXY`` / ``HTTPS_PROXY``) are honoured automatically. + """ + import aiohttp + + if not proxy_url: + return aiohttp.ClientSession(trust_env=True) + + if proxy_url.split("://")[0].lower().startswith("socks"): + try: + from aiohttp_socks import ProxyConnector + + return aiohttp.ClientSession( + connector=ProxyConnector.from_url(proxy_url, rdns=True), + ) + except ImportError: + logger.warning( + "aiohttp_socks not installed — SOCKS proxy %s ignored. " + "Run: pip install aiohttp-socks", + proxy_url, + ) + return aiohttp.ClientSession(trust_env=True) + + return aiohttp.ClientSession(proxy=proxy_url) + + def _check_e2ee_deps() -> bool: """Return True if mautrix E2EE dependencies (python-olm) are available.""" try: @@ -315,6 +351,11 @@ class MatrixAdapter(BasePlatformAdapter): ).lower() not in ("false", "0", "no") self._pending_reactions: dict[tuple[str, str], str] = {} + # Proxy support — resolve once at init, reuse for all HTTP traffic. + self._proxy_url: str | None = resolve_proxy_url(platform_env_var="MATRIX_PROXY") + if self._proxy_url: + logger.info("Matrix: proxy configured — %s", self._proxy_url) + # Text batching: merge rapid successive messages (Telegram-style). # Matrix clients split long messages around 4000 chars. self._text_batch_delay_seconds = float( @@ -467,9 +508,11 @@ class MatrixAdapter(BasePlatformAdapter): _STORE_DIR.mkdir(parents=True, exist_ok=True) # Create the HTTP API layer. + client_session = _create_matrix_session(self._proxy_url) api = HTTPAPI( base_url=self._homeserver, token=self._access_token or "", + client_session=client_session, ) # Create the client. @@ -931,10 +974,12 @@ class MatrixAdapter(BasePlatformAdapter): # Try aiohttp first (always available), fall back to httpx try: import aiohttp as _aiohttp - - async with _aiohttp.ClientSession(trust_env=True) as http: + _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(self._proxy_url) + async with _aiohttp.ClientSession(**_sess_kw) as http: async with http.get( - image_url, timeout=_aiohttp.ClientTimeout(total=30) + image_url, + timeout=_aiohttp.ClientTimeout(total=30), + **_req_kw, ) as resp: resp.raise_for_status() data = await resp.read() @@ -944,8 +989,10 @@ class MatrixAdapter(BasePlatformAdapter): ) except ImportError: import httpx - - async with httpx.AsyncClient() as http: + _httpx_kw: dict = {} + if self._proxy_url: + _httpx_kw["proxy"] = self._proxy_url + async with httpx.AsyncClient(**_httpx_kw) as http: resp = await http.get(image_url, follow_redirects=True, timeout=30) resp.raise_for_status() data = resp.content diff --git a/pyproject.toml b/pyproject.toml index 4b7e8816ac..57a752877e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "py messaging = ["python-telegram-bot[webhooks]>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4", "qrcode>=7.0,<8"] cron = ["croniter>=6.0.0,<7"] slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"] -matrix = ["mautrix[encryption]>=0.20,<1", "Markdown>=3.6,<4", "aiosqlite>=0.20", "asyncpg>=0.29"] +matrix = ["mautrix[encryption]>=0.20,<1", "Markdown>=3.6,<4", "aiosqlite>=0.20", "asyncpg>=0.29", "aiohttp-socks>=0.10,<1"] cli = ["simple-term-menu>=1.0,<2"] tts-premium = ["elevenlabs>=1.0,<2"] voice = [ diff --git a/tests/gateway/test_matrix.py b/tests/gateway/test_matrix.py index a4b17725d8..722fc9f703 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -2258,3 +2258,90 @@ class TestMatrixDmAutoThread: _body, _is_dm, _chat_type, thread_id, _display, _source = ctx assert thread_id is None + + +# --------------------------------------------------------------------------- +# Proxy configuration +# --------------------------------------------------------------------------- + +class TestMatrixProxyConfig: + """Verify that MatrixAdapter resolves and propagates proxy settings.""" + + def _make_adapter(self, monkeypatch, proxy_env=None): + monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test") + monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") + # Clear generic proxy vars so they don't leak from the host + for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", + "https_proxy", "http_proxy", "all_proxy", "MATRIX_PROXY"): + monkeypatch.delenv(key, raising=False) + if proxy_env: + for k, v in proxy_env.items(): + monkeypatch.setenv(k, v) + with patch.dict("sys.modules", _make_fake_mautrix()): + from gateway.platforms.matrix import MatrixAdapter + cfg = PlatformConfig(enabled=True, token="syt_test", + extra={"homeserver": "https://matrix.example.org", + "user_id": "@bot:example.org"}) + return MatrixAdapter(cfg) + + def test_no_proxy_by_default(self, monkeypatch): + adapter = self._make_adapter(monkeypatch) + assert adapter._proxy_url is None + + def test_matrix_proxy_env_var(self, monkeypatch): + adapter = self._make_adapter(monkeypatch, + proxy_env={"MATRIX_PROXY": "socks5://proxy:1080"}) + assert adapter._proxy_url == "socks5://proxy:1080" + + def test_generic_proxy_fallback(self, monkeypatch): + adapter = self._make_adapter(monkeypatch, + proxy_env={"HTTPS_PROXY": "http://corp:8080"}) + assert adapter._proxy_url == "http://corp:8080" + + def test_matrix_proxy_takes_priority(self, monkeypatch): + adapter = self._make_adapter(monkeypatch, + proxy_env={"MATRIX_PROXY": "socks5://special:1080", + "HTTPS_PROXY": "http://generic:8080"}) + assert adapter._proxy_url == "socks5://special:1080" + + +class TestCreateMatrixSession: + """Verify _create_matrix_session applies proxy at the session level.""" + + @pytest.mark.asyncio + async def test_no_proxy_returns_trust_env_session(self): + with patch.dict("sys.modules", _make_fake_mautrix()): + from gateway.platforms.matrix import _create_matrix_session + session = _create_matrix_session(None) + try: + assert session.trust_env is True + finally: + await session.close() + + @pytest.mark.asyncio + async def test_http_proxy_sets_default_proxy(self): + with patch.dict("sys.modules", _make_fake_mautrix()): + from gateway.platforms.matrix import _create_matrix_session + session = _create_matrix_session("http://proxy:8080") + try: + assert str(session._default_proxy) == "http://proxy:8080" + finally: + await session.close() + + @pytest.mark.asyncio + async def test_socks_proxy_uses_connector(self): + fake_connector = MagicMock() + with patch.dict("sys.modules", _make_fake_mautrix()): + with patch.dict("sys.modules", { + "aiohttp_socks": MagicMock( + ProxyConnector=MagicMock( + from_url=MagicMock(return_value=fake_connector) + ) + ), + }): + from gateway.platforms.matrix import _create_matrix_session + session = _create_matrix_session("socks5://proxy:1080") + try: + assert session.connector is fake_connector + finally: + await session.close() diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index 690a820954..59246b7990 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -3,6 +3,8 @@ import os from unittest.mock import patch +import pytest + from gateway.platforms.base import ( BasePlatformAdapter, GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE, @@ -582,3 +584,47 @@ class TestTruncateMessageUtf16: f"Chunk {i} has unbalanced fences ({fence_count})" ) + +class TestProxyKwargsForAiohttp: + """Verify proxy_kwargs_for_aiohttp routes all schemes through ProxyConnector.""" + + def test_none_returns_empty(self): + from gateway.platforms.base import proxy_kwargs_for_aiohttp + + sess_kw, req_kw = proxy_kwargs_for_aiohttp(None) + assert sess_kw == {} + assert req_kw == {} + + def test_http_proxy_uses_connector_when_aiohttp_socks_available(self): + pytest.importorskip("aiohttp_socks") + from unittest.mock import MagicMock + from gateway.platforms.base import proxy_kwargs_for_aiohttp + + sentinel = MagicMock(name="ProxyConnector") + with patch("aiohttp_socks.ProxyConnector.from_url", return_value=sentinel): + sess_kw, req_kw = proxy_kwargs_for_aiohttp("http://proxy:8080") + assert sess_kw.get("connector") is sentinel, ( + "HTTP proxy must use ProxyConnector so libraries that don't " + "forward per-request proxy= kwargs still route through the proxy" + ) + assert req_kw == {} + + def test_socks_proxy_uses_connector(self): + pytest.importorskip("aiohttp_socks") + from unittest.mock import MagicMock + from gateway.platforms.base import proxy_kwargs_for_aiohttp + + sentinel = MagicMock(name="ProxyConnector") + with patch("aiohttp_socks.ProxyConnector.from_url", return_value=sentinel): + sess_kw, req_kw = proxy_kwargs_for_aiohttp("socks5://proxy:1080") + assert sess_kw.get("connector") is sentinel + assert req_kw == {} + + def test_http_proxy_falls_back_without_aiohttp_socks(self): + from gateway.platforms.base import proxy_kwargs_for_aiohttp + + with patch.dict("sys.modules", {"aiohttp_socks": None}): + sess_kw, req_kw = proxy_kwargs_for_aiohttp("http://proxy:8080") + assert sess_kw == {} + assert req_kw == {"proxy": "http://proxy:8080"} +