Merge branch 'anoa/modules_signed_requests' into anoa/additional_room_state_and_new_module_api

This commit is contained in:
Andrew Morgan
2026-07-23 11:10:03 +01:00
6 changed files with 414 additions and 2 deletions
+1
View File
@@ -0,0 +1 @@
Add `ModuleApi.send_federation_http_request` to let modules make authenticated federation requests using the homeserver's signing key.
+39
View File
@@ -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
+113 -1
View File
@@ -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
+10
View File
@@ -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",
]
+58
View File
@@ -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:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
"""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
+193 -1
View File
@@ -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)