mirror of
https://github.com/element-hq/synapse.git
synced 2026-08-14 09:20:49 +00:00
Merge branch 'anoa/modules_signed_requests' into anoa/additional_room_state_and_new_module_api
This commit is contained in:
@@ -0,0 +1 @@
|
||||
Add `ModuleApi.send_federation_http_request` to let modules make authenticated federation requests using the homeserver's signing key.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user