fix: MatrixAdapter respects proxy configuration
This commit is contained in:
parent
1eab5960f0
commit
32d4048c6b
@ -307,9 +307,14 @@ def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]:
|
|||||||
"""Build kwargs for standalone ``aiohttp.ClientSession`` with proxy.
|
"""Build kwargs for standalone ``aiohttp.ClientSession`` with proxy.
|
||||||
|
|
||||||
Returns ``(session_kwargs, request_kwargs)`` where:
|
Returns ``(session_kwargs, request_kwargs)`` where:
|
||||||
- SOCKS → ``({"connector": ProxyConnector(...)}, {})``
|
- With aiohttp-socks → ``({"connector": ProxyConnector(...)}, {})``
|
||||||
- HTTP → ``({}, {"proxy": url})``
|
for *all* proxy schemes (SOCKS **and** HTTP/HTTPS).
|
||||||
- None → ``({}, {})``
|
- 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::
|
Usage::
|
||||||
|
|
||||||
@ -320,20 +325,20 @@ def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]:
|
|||||||
"""
|
"""
|
||||||
if not proxy_url:
|
if not proxy_url:
|
||||||
return {}, {}
|
return {}, {}
|
||||||
if proxy_url.lower().startswith("socks"):
|
try:
|
||||||
try:
|
from aiohttp_socks import ProxyConnector
|
||||||
from aiohttp_socks import ProxyConnector
|
|
||||||
|
|
||||||
connector = ProxyConnector.from_url(proxy_url, rdns=True)
|
connector = ProxyConnector.from_url(proxy_url, rdns=True)
|
||||||
return {"connector": connector}, {}
|
return {"connector": connector}, {}
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
if proxy_url.lower().startswith("socks"):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"aiohttp_socks not installed — SOCKS proxy %s ignored. "
|
"aiohttp_socks not installed — SOCKS proxy %s ignored. "
|
||||||
"Run: pip install aiohttp-socks",
|
"Run: pip install aiohttp-socks",
|
||||||
proxy_url,
|
proxy_url,
|
||||||
)
|
)
|
||||||
return {}, {}
|
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:
|
def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = None) -> bool:
|
||||||
|
|||||||
@ -11,6 +11,7 @@ Environment variables:
|
|||||||
MATRIX_PASSWORD Password (alternative to access token)
|
MATRIX_PASSWORD Password (alternative to access token)
|
||||||
MATRIX_ENCRYPTION Set "true" to enable E2EE
|
MATRIX_ENCRYPTION Set "true" to enable E2EE
|
||||||
MATRIX_DEVICE_ID Stable device ID for E2EE persistence across restarts
|
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_ALLOWED_USERS Comma-separated Matrix user IDs (@user:server)
|
||||||
MATRIX_HOME_ROOM Room ID for cron/notification delivery
|
MATRIX_HOME_ROOM Room ID for cron/notification delivery
|
||||||
MATRIX_REACTIONS Set "false" to disable processing lifecycle reactions
|
MATRIX_REACTIONS Set "false" to disable processing lifecycle reactions
|
||||||
@ -96,6 +97,8 @@ from gateway.platforms.base import (
|
|||||||
MessageType,
|
MessageType,
|
||||||
ProcessingOutcome,
|
ProcessingOutcome,
|
||||||
SendResult,
|
SendResult,
|
||||||
|
resolve_proxy_url,
|
||||||
|
proxy_kwargs_for_aiohttp,
|
||||||
)
|
)
|
||||||
from gateway.platforms.helpers import ThreadParticipationTracker
|
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
|
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:
|
def _check_e2ee_deps() -> bool:
|
||||||
"""Return True if mautrix E2EE dependencies (python-olm) are available."""
|
"""Return True if mautrix E2EE dependencies (python-olm) are available."""
|
||||||
try:
|
try:
|
||||||
@ -315,6 +351,11 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||||||
).lower() not in ("false", "0", "no")
|
).lower() not in ("false", "0", "no")
|
||||||
self._pending_reactions: dict[tuple[str, str], str] = {}
|
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).
|
# Text batching: merge rapid successive messages (Telegram-style).
|
||||||
# Matrix clients split long messages around 4000 chars.
|
# Matrix clients split long messages around 4000 chars.
|
||||||
self._text_batch_delay_seconds = float(
|
self._text_batch_delay_seconds = float(
|
||||||
@ -467,9 +508,11 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||||||
_STORE_DIR.mkdir(parents=True, exist_ok=True)
|
_STORE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Create the HTTP API layer.
|
# Create the HTTP API layer.
|
||||||
|
client_session = _create_matrix_session(self._proxy_url)
|
||||||
api = HTTPAPI(
|
api = HTTPAPI(
|
||||||
base_url=self._homeserver,
|
base_url=self._homeserver,
|
||||||
token=self._access_token or "",
|
token=self._access_token or "",
|
||||||
|
client_session=client_session,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create the client.
|
# Create the client.
|
||||||
@ -931,10 +974,12 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||||||
# Try aiohttp first (always available), fall back to httpx
|
# Try aiohttp first (always available), fall back to httpx
|
||||||
try:
|
try:
|
||||||
import aiohttp as _aiohttp
|
import aiohttp as _aiohttp
|
||||||
|
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(self._proxy_url)
|
||||||
async with _aiohttp.ClientSession(trust_env=True) as http:
|
async with _aiohttp.ClientSession(**_sess_kw) as http:
|
||||||
async with http.get(
|
async with http.get(
|
||||||
image_url, timeout=_aiohttp.ClientTimeout(total=30)
|
image_url,
|
||||||
|
timeout=_aiohttp.ClientTimeout(total=30),
|
||||||
|
**_req_kw,
|
||||||
) as resp:
|
) as resp:
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = await resp.read()
|
data = await resp.read()
|
||||||
@ -944,8 +989,10 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
import httpx
|
import httpx
|
||||||
|
_httpx_kw: dict = {}
|
||||||
async with httpx.AsyncClient() as http:
|
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 = await http.get(image_url, follow_redirects=True, timeout=30)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.content
|
data = resp.content
|
||||||
|
|||||||
@ -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"]
|
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"]
|
cron = ["croniter>=6.0.0,<7"]
|
||||||
slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
|
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"]
|
cli = ["simple-term-menu>=1.0,<2"]
|
||||||
tts-premium = ["elevenlabs>=1.0,<2"]
|
tts-premium = ["elevenlabs>=1.0,<2"]
|
||||||
voice = [
|
voice = [
|
||||||
|
|||||||
@ -2258,3 +2258,90 @@ class TestMatrixDmAutoThread:
|
|||||||
_body, _is_dm, _chat_type, thread_id, _display, _source = ctx
|
_body, _is_dm, _chat_type, thread_id, _display, _source = ctx
|
||||||
assert thread_id is None
|
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()
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
import os
|
import os
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from gateway.platforms.base import (
|
from gateway.platforms.base import (
|
||||||
BasePlatformAdapter,
|
BasePlatformAdapter,
|
||||||
GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE,
|
GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE,
|
||||||
@ -582,3 +584,47 @@ class TestTruncateMessageUtf16:
|
|||||||
f"Chunk {i} has unbalanced fences ({fence_count})"
|
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"}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user