From 7fd9f5d9fca896e7e855bf2b75204c98fb45c341 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 22 Jul 2026 16:59:15 +0100 Subject: [PATCH 1/4] Implement new `send_federation_http_request` module API This new API allows a module to send an HTTP request authenticated as the homeserver. This allows modules to make arbitrary requests to other homeservers on behalf of the homeserver. --- synapse/module_api/__init__.py | 114 +++++++++++++++++++++++++++- synapse/module_api/errors.py | 10 +++ synapse/module_api/module_errors.py | 58 ++++++++++++++ 3 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 synapse/module_api/module_errors.py diff --git a/synapse/module_api/__init__.py b/synapse/module_api/__init__.py index 1131253028..29b5f2f8ef 100644 --- a/synapse/module_api/__init__.py +++ b/synapse/module_api/__init__.py @@ -43,7 +43,12 @@ from twisted.web.resource import Resource from synapse.api import errors from synapse.api.constants import ProfileFields -from synapse.api.errors import SynapseError +from synapse.api.errors import ( + FederationDeniedError, + HttpResponseException, + RequestSendFailed, + SynapseError, +) from synapse.api.presence import UserPresenceState from synapse.config import ConfigError from synapse.config.repository import MediaUploadLimit @@ -131,6 +136,12 @@ from synapse.module_api.callbacks.third_party_event_rules_callbacks import ( ON_THREEPID_BIND_CALLBACK, ON_USER_DEACTIVATION_STATUS_CHANGED_CALLBACK, ) +from synapse.module_api.module_errors import ( + FederationHttpDeniedException, + FederationHttpNotRetryingDestinationException, + FederationHttpRequestSendFailedException, + FederationHttpResponseException, +) from synapse.push.httppusher import HttpPusher from synapse.rest.client.login import LoginResponse from synapse.storage import DataStore @@ -162,6 +173,7 @@ from synapse.util.caches.descriptors import CachedFunction, cached as _cached from synapse.util.clock import Clock from synapse.util.duration import Duration from synapse.util.frozenutils import freeze +from synapse.util.retryutils import NotRetryingDestination if TYPE_CHECKING: # Old versions don't have `LiteralString` @@ -644,6 +656,106 @@ class ModuleApi: """ return self._http_client + async def send_federation_http_request( + self, + method: str, + remote_server_name: str, + path: str, + query_parameters: Mapping[str, Any] | None = None, + body: JsonDict | None = None, + timeout: int | None = None, + ) -> JsonDict: + """Send an authenticated HTTP request to a remote homeserver. + + Synapse signs the request with the local homeserver's signing key and sends it + using the configured federation routing, TLS, IP filtering, and retry policy. + + Added in Synapse v1.158.0. + + Args: + method: The HTTP method to use. One of `GET`, `PUT`, `POST`, or + `DELETE`. Case-insensitive. + remote_server_name: The Matrix server name to send the request to. + Federation delegation is resolved automatically. + path: The absolute HTTP path for the request. + query_parameters: Query parameters to include in the request. + body: The JSON request body for `PUT` and `POST` requests. + timeout: Number of milliseconds to wait for response headers and the + response body. The configured federation timeout is used by default. + + Returns: + The decoded JSON object returned by the remote homeserver. + + Raises: + ValueError: If `method` is not supported. + FederationHttpResponseException: If the remote homeserver returns an + unsuccessful, non-retryable HTTP response. + FederationHttpNotRetryingDestinationException: If Synapse is backing off + requests to the remote homeserver. + FederationHttpDeniedException: If the remote homeserver is excluded by + Synapse's federation policy. + FederationHttpRequestSendFailedException: If the request could not be sent + or the response could not be decoded. + """ + method = method.upper() + federation_http_client = self._hs.get_federation_http_client() + + try: + if method == "GET": + return await federation_http_client.get_json( + destination=remote_server_name, + path=path, + args=query_parameters, + timeout=timeout, + ) + if method == "PUT": + return await federation_http_client.put_json( + destination=remote_server_name, + path=path, + args=query_parameters, + data=body, + timeout=timeout, + ) + if method == "POST": + return await federation_http_client.post_json( + destination=remote_server_name, + path=path, + args=query_parameters, + data=body, + timeout=timeout, + ) + if method == "DELETE": + return await federation_http_client.delete_json( + destination=remote_server_name, + path=path, + args=query_parameters, + timeout=timeout, + ) + + raise ValueError( + f"method must be one of GET, PUT, POST, or DELETE; received {method!r}" + ) + except HttpResponseException as e: + raise FederationHttpResponseException( + remote_server_name=remote_server_name, + status_code=e.code, + msg=e.msg, + response_body=e.response, + ) from e + except NotRetryingDestination as e: + raise FederationHttpNotRetryingDestinationException( + remote_server_name=remote_server_name + ) from e + except FederationDeniedError as e: + raise FederationHttpDeniedException( + remote_server_name=remote_server_name + ) from e + except RequestSendFailed as e: + raise FederationHttpRequestSendFailedException( + remote_server_name=remote_server_name, + can_retry=e.can_retry, + ) from e + @property def public_room_list_manager(self) -> "PublicRoomListManager": """Allows adding to, removing from and checking the status of rooms in the diff --git a/synapse/module_api/errors.py b/synapse/module_api/errors.py index f04b8fdb9d..18291e3af0 100644 --- a/synapse/module_api/errors.py +++ b/synapse/module_api/errors.py @@ -29,6 +29,12 @@ from synapse.api.errors import ( ) from synapse.config._base import ConfigError from synapse.handlers.push_rules import InvalidRuleException +from synapse.module_api.module_errors import ( + FederationHttpDeniedException, + FederationHttpNotRetryingDestinationException, + FederationHttpRequestSendFailedException, + FederationHttpResponseException, +) from synapse.storage.push_rule import RuleNotFoundException __all__ = [ @@ -37,6 +43,10 @@ __all__ = [ "RedirectException", "SynapseError", "ConfigError", + "FederationHttpDeniedException", + "FederationHttpNotRetryingDestinationException", + "FederationHttpRequestSendFailedException", + "FederationHttpResponseException", "InvalidRuleException", "RuleNotFoundException", ] diff --git a/synapse/module_api/module_errors.py b/synapse/module_api/module_errors.py new file mode 100644 index 0000000000..84144189db --- /dev/null +++ b/synapse/module_api/module_errors.py @@ -0,0 +1,58 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2026 New Vector, Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# See the GNU Affero General Public License for more details: +# . +# + +"""Exception types specific to the module API. + +The definitions cannot live in `synapse.module_api.errors` because +`synapse.module_api` historically re-exports `synapse.api.errors` under the name +`errors`. Importing the `synapse.module_api.errors` submodule while initializing the +parent package would replace that re-export and could break existing modules. + +The public `synapse.module_api.errors` module re-exports these exceptions, while the +parent package imports them directly from here to avoid that namespace collision. +""" + +import attr + + +@attr.s(auto_attribs=True, slots=True) +class FederationHttpResponseException(Exception): + """A remote homeserver returned an unsuccessful HTTP response.""" + + remote_server_name: str + status_code: int + msg: str + response_body: bytes + + +@attr.s(auto_attribs=True, slots=True) +class FederationHttpNotRetryingDestinationException(Exception): + """Synapse is backing off federation requests to the remote homeserver.""" + + remote_server_name: str + + +@attr.s(auto_attribs=True, slots=True) +class FederationHttpDeniedException(Exception): + """The remote homeserver is excluded by Synapse's federation policy.""" + + remote_server_name: str + + +@attr.s(auto_attribs=True, slots=True) +class FederationHttpRequestSendFailedException(Exception): + """Synapse could not send or decode a federation HTTP request.""" + + remote_server_name: str + can_retry: bool From 42d335ef0b61952988851c163cdbe8f50abe7008 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 22 Jul 2026 17:08:12 +0100 Subject: [PATCH 2/4] Write up how to use the new API, using public rooms as an example --- docs/modules/writing_a_module.md | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/modules/writing_a_module.md b/docs/modules/writing_a_module.md index 93f24d0da1..cd4dcc3cfc 100644 --- a/docs/modules/writing_a_module.md +++ b/docs/modules/writing_a_module.md @@ -68,6 +68,45 @@ configuration file takes priority. Modules **must** register their web resources in their `__init__` method. +## Making federation requests + +Modules can make authenticated HTTP requests to other homeservers with +`ModuleApi.send_federation_http_request`. + +For example, you can request a +[public room list over federation](https://spec.matrix.org/v1.19/server-server-api/#post_matrixfederationv1publicrooms): + +```python +from urllib.parse import quote + +from synapse.module_api import JsonDict, ModuleApi + + +class MyModule: + def __init__(self, config: dict, api: ModuleApi): + self.api = api + + async def get_public_room_list( + self, remote_server_name: str, search_term: str + ) -> JsonDict: + return await self.api.send_federation_http_request( + method="POST", + remote_server_name=remote_server_name, + path=f"/_matrix/federation/v1/publicRooms", + query_parameters={}, + body={ + "filter": { + "generic_search_term": search_term + } + } + ) +``` + +The method supports `GET`, `PUT`, `POST`, and `DELETE` requests. `PUT` and `POST` +requests may include a JSON object using the `body` argument. Successful responses are +returned as decoded JSON objects. Failures raise the federation HTTP exception types +from `synapse.module_api.errors`. + ## Registering a callback Modules can use Synapse's module API to register callbacks. Callbacks are functions that From 26faa8e2ce3ea56daefc4e89fc84fdcf9162c227 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 22 Jul 2026 17:08:40 +0100 Subject: [PATCH 3/4] Add unit tests --- tests/module_api/test_api.py | 194 ++++++++++++++++++++++++++++++++++- 1 file changed, 193 insertions(+), 1 deletion(-) diff --git a/tests/module_api/test_api.py b/tests/module_api/test_api.py index b4b14c87b9..befbf20459 100644 --- a/tests/module_api/test_api.py +++ b/tests/module_api/test_api.py @@ -18,28 +18,46 @@ # [This file includes modifications made by New Vector Limited] # # +import importlib from typing import Any from unittest.mock import AsyncMock, Mock +from parameterized import parameterized + from twisted.internet import defer from twisted.internet.testing import MemoryReactor +import synapse.module_api +from synapse.api import errors as api_errors from synapse.api.constants import EduTypes, EventTypes -from synapse.api.errors import NotFoundError +from synapse.api.errors import ( + FederationDeniedError, + HttpResponseException, + NotFoundError, + RequestSendFailed, +) from synapse.events import EventBase from synapse.federation.units import Transaction from synapse.handlers.device import DeviceWriterHandler from synapse.handlers.presence import UserPresenceState from synapse.handlers.push_rules import InvalidRuleException from synapse.module_api import ModuleApi +from synapse.module_api.module_errors import ( + FederationHttpDeniedException, + FederationHttpNotRetryingDestinationException, + FederationHttpRequestSendFailedException, + FederationHttpResponseException, +) from synapse.rest import admin from synapse.rest.client import login, notifications, presence, profile, room from synapse.server import HomeServer from synapse.types import JsonDict, UserID, create_requester from synapse.util.clock import Clock +from synapse.util.retryutils import NotRetryingDestination from tests.events.test_presence_router import send_presence_update, sync_presence from tests.replication._base import BaseMultiWorkerStreamTestCase +from tests.test_utils import FakeResponse from tests.test_utils.event_injection import inject_member_event from tests.unittest import HomeserverTestCase, override_config @@ -379,6 +397,180 @@ class ModuleApiTestCase(BaseModuleApiTestCase): ) self.assertFalse(is_in_public_rooms) + def test_module_errors_preserves_historical_errors_reexport(self) -> None: + self.assertIs(synapse.module_api.errors, api_errors) + + def test_module_errors_reexports_federation_http_exceptions(self) -> None: + original_errors = synapse.module_api.errors + try: + module_api_errors = importlib.import_module("synapse.module_api.errors") + + self.assertIs( + module_api_errors.FederationHttpResponseException, + FederationHttpResponseException, + ) + self.assertIs( + module_api_errors.FederationHttpNotRetryingDestinationException, + FederationHttpNotRetryingDestinationException, + ) + self.assertIs( + module_api_errors.FederationHttpDeniedException, + FederationHttpDeniedException, + ) + self.assertIs( + module_api_errors.FederationHttpRequestSendFailedException, + FederationHttpRequestSendFailedException, + ) + finally: + synapse.module_api.errors = original_errors + + @parameterized.expand( + [ + ("get", "get_json", False), + ("put", "put_json", True), + ("post", "post_json", True), + ("delete", "delete_json", False), + ] + ) + def test_send_federation_http_request_dispatches_request( + self, method: str, client_method_name: str, sends_body: bool + ) -> None: + federation_http_client = self.hs.get_federation_http_client() + client_method = AsyncMock(return_value={"result": "ok"}) + setattr(federation_http_client, client_method_name, client_method) + + body = {"request": "body"} if sends_body else None + result = self.get_success( + self.module_api.send_federation_http_request( + method=method, + remote_server_name="remote.example", + path="/_matrix/federation/v1/test", + query_parameters={"key": ["one", "two"]}, + body=body, + timeout=1234, + ) + ) + + self.assertEqual(result, {"result": "ok"}) + expected_args: dict[str, object] = { + "destination": "remote.example", + "path": "/_matrix/federation/v1/test", + "args": {"key": ["one", "two"]}, + "timeout": 1234, + } + if sends_body: + expected_args["data"] = body + + client_method.assert_awaited_once_with(**expected_args) + + def test_send_federation_http_request_is_signed(self) -> None: + federation_http_client = self.hs.get_federation_http_client() + request_mock = Mock( + return_value=defer.succeed( + FakeResponse.json(payload={"rooms": [], "inaccessible_children": []}) + ) + ) + federation_http_client.agent.request = request_mock # type: ignore[method-assign] + + result = self.get_success( + self.module_api.send_federation_http_request( + method="GET", + remote_server_name="remote.example", + path="/_matrix/federation/v1/hierarchy/%21room%3Atest", + query_parameters={"suggested_only": "true"}, + ) + ) + + self.assertEqual(result, {"rooms": [], "inaccessible_children": []}) + request_mock.assert_called_once() + self.assertEqual(request_mock.call_args.args[0], b"GET") + self.assertEqual( + request_mock.call_args.args[1], + b"matrix-federation://remote.example/_matrix/federation/v1/" + b"hierarchy/%21room%3Atest?suggested_only=true", + ) + + headers = request_mock.call_args.kwargs["headers"] + authorization_headers = headers.getRawHeaders(b"Authorization") + self.assertIsNotNone(authorization_headers) + assert authorization_headers is not None + self.assertEqual(len(authorization_headers), 1) + self.assertIn(b'origin="test"', authorization_headers[0]) + self.assertIn(b'destination="remote.example"', authorization_headers[0]) + + def test_send_federation_http_request_rejects_unknown_method(self) -> None: + failure = self.get_failure( + self.module_api.send_federation_http_request( + method="PATCH", + remote_server_name="remote.example", + path="/_matrix/federation/v1/test", + ), + ValueError, + ) + + self.assertIn("GET, PUT, POST, or DELETE", str(failure.value)) + + @parameterized.expand( + [ + ( + "response", + HttpResponseException(404, "Not Found", b'{"errcode":"M_NOT_FOUND"}'), + FederationHttpResponseException, + { + "status_code": 404, + "msg": "Not Found", + "response_body": b'{"errcode":"M_NOT_FOUND"}', + }, + ), + ( + "backoff", + NotRetryingDestination(1000, 2000, "remote.example"), + FederationHttpNotRetryingDestinationException, + {}, + ), + ( + "denied", + FederationDeniedError("remote.example"), + FederationHttpDeniedException, + {}, + ), + ( + "send_failed", + RequestSendFailed(Exception("connection failed"), can_retry=True), + FederationHttpRequestSendFailedException, + {"can_retry": True}, + ), + ] + ) + def test_send_federation_http_request_translates_errors( + self, + _name: str, + internal_error: Exception, + public_error: type[Exception], + expected_attributes: dict[str, object], + ) -> None: + federation_http_client = self.hs.get_federation_http_client() + federation_http_client.get_json = AsyncMock( # type: ignore[method-assign] + side_effect=internal_error + ) + + failure = self.get_failure( + self.module_api.send_federation_http_request( + method="GET", + remote_server_name="remote.example", + path="/_matrix/federation/v1/test", + ), + public_error, + ) + + self.assertEqual( + failure.value.remote_server_name, # type: ignore[attr-defined] + "remote.example", + ) + self.assertIs(failure.value.__cause__, internal_error) + for attribute, expected_value in expected_attributes.items(): + self.assertEqual(getattr(failure.value, attribute), expected_value) + def test_send_local_online_presence_to(self) -> None: # Test sending local online presence to users from the main process _test_sending_local_online_presence_to_local_user(self, test_with_workers=False) From 60a3acda069f2d8eb74241bb12fb8a939668a6b4 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 22 Jul 2026 17:10:11 +0100 Subject: [PATCH 4/4] newsfile --- changelog.d/19992.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/19992.feature diff --git a/changelog.d/19992.feature b/changelog.d/19992.feature new file mode 100644 index 0000000000..81ec3459cf --- /dev/null +++ b/changelog.d/19992.feature @@ -0,0 +1 @@ +Add `ModuleApi.send_federation_http_request` to let modules make authenticated federation requests using the homeserver's signing key.