diff --git a/changelog.d/19314.feature b/changelog.d/19314.feature new file mode 100644 index 00000000000..fd2893c577d --- /dev/null +++ b/changelog.d/19314.feature @@ -0,0 +1 @@ +Add experimental support for the [MSC4370](https://github.com/matrix-org/matrix-spec-proposals/pull/4370) Federation API `GET /extremities` endpoint. \ No newline at end of file diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py index dc5e096791a..76ca9b46c7e 100644 --- a/synapse/config/experimental.py +++ b/synapse/config/experimental.py @@ -3,6 +3,7 @@ # # Copyright 2021 The Matrix.org Foundation C.I.C. # Copyright (C) 2023 New Vector, Ltd +# Copyright (C) 2025 Element Creations 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 @@ -533,6 +534,9 @@ def read_config( "msc4108_delegation_endpoint", None ) + # MSC4370: Get extremities federation endpoint + self.msc4370_enabled = experimental.get("msc4370_enabled", False) + auth_delegated = self.msc3861.enabled or ( config.get("matrix_authentication_service") or {} ).get("enabled", False) diff --git a/synapse/federation/federation_server.py b/synapse/federation/federation_server.py index b909f1e5956..6c6b8a53b18 100644 --- a/synapse/federation/federation_server.py +++ b/synapse/federation/federation_server.py @@ -4,6 +4,7 @@ # Copyright 2019-2021 Matrix.org Federation C.I.C # Copyright 2015, 2016 OpenMarket Ltd # Copyright (C) 2023 New Vector, Ltd +# Copyright (C) 2025 Element Creations 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 @@ -683,6 +684,16 @@ async def on_query_request( resp = await self.registry.on_query(query_type, args) return 200, resp + async def on_get_extremities_request(self, origin: str, room_id: str) -> JsonDict: + origin_host, _ = parse_server_name(origin) + await self.check_server_matches_acl(origin_host, room_id) + + await self._event_auth_handler.assert_host_in_room(room_id, origin) + + extremities = await self.store.get_forward_extremities_for_room(room_id) + prev_event_ids = [e[0] for e in extremities] + return {"prev_events": prev_event_ids} + async def on_make_join_request( self, origin: str, room_id: str, user_id: str, supported_versions: list[str] ) -> dict[str, Any]: diff --git a/synapse/federation/transport/server/__init__.py b/synapse/federation/transport/server/__init__.py index 6d92d005238..0eff49cf73c 100644 --- a/synapse/federation/transport/server/__init__.py +++ b/synapse/federation/transport/server/__init__.py @@ -4,6 +4,7 @@ # Copyright 2020 Sorunome # Copyright 2014-2021 The Matrix.org Foundation C.I.C. # Copyright (C) 2023 New Vector, Ltd +# Copyright (C) 2025 Element Creations 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 @@ -33,6 +34,7 @@ FederationMediaDownloadServlet, FederationMediaThumbnailServlet, FederationUnstableClientKeysClaimServlet, + FederationUnstableGetExtremitiesServlet, ) from synapse.http.server import HttpServer, JsonResource from synapse.http.servlet import ( @@ -326,6 +328,12 @@ def register_servlets( if not hs.config.media.can_load_media_repo: continue + if ( + servletclass == FederationUnstableGetExtremitiesServlet + and not hs.config.experimental.msc4370_enabled + ): + continue + servletclass( hs=hs, authenticator=authenticator, diff --git a/synapse/federation/transport/server/federation.py b/synapse/federation/transport/server/federation.py index a7c297c0b76..d783e6da518 100644 --- a/synapse/federation/transport/server/federation.py +++ b/synapse/federation/transport/server/federation.py @@ -1,8 +1,9 @@ # # This file is licensed under the Affero General Public License (AGPL) version 3. # -# Copyright 2021 The Matrix.org Foundation C.I.C. +# Copyright 2021 The Matrix.org Foundation C.I.C. # Copyright (C) 2023 New Vector, Ltd +# Copyright (C) 2025 Element Creations 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 @@ -273,6 +274,22 @@ async def on_GET( return await self.handler.on_query_request(query_type, args) +class FederationUnstableGetExtremitiesServlet(BaseFederationServerServlet): + PREFIX = FEDERATION_UNSTABLE_PREFIX + "/org.matrix.msc4370" + PATH = "/extremities/(?P[^/]*)" + CATEGORY = "Federation requests" + + async def on_GET( + self, + origin: str, + content: Literal[None], + query: dict[bytes, list[bytes]], + room_id: str, + ) -> tuple[int, JsonDict]: + result = await self.handler.on_get_extremities_request(origin, room_id) + return 200, result + + class FederationMakeJoinServlet(BaseFederationServerServlet): PATH = "/make_join/(?P[^/]*)/(?P[^/]*)" CATEGORY = "Federation requests" @@ -884,6 +901,7 @@ async def on_GET( FederationBackfillServlet, FederationTimestampLookupServlet, FederationQueryServlet, + FederationUnstableGetExtremitiesServlet, FederationMakeJoinServlet, FederationMakeLeaveServlet, FederationEventServlet, diff --git a/tests/federation/test_federation_server.py b/tests/federation/test_federation_server.py index c4491d5b3c9..5e6c06d09e4 100644 --- a/tests/federation/test_federation_server.py +++ b/tests/federation/test_federation_server.py @@ -324,6 +324,148 @@ def test_needs_to_be_in_room(self) -> None: self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN") +class UnstableGetExtremitiesTests(unittest.FederatingHomeserverTestCase): + servlets = [ + admin.register_servlets, + room.register_servlets, + login.register_servlets, + ] + + def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: + super().prepare(reactor, clock, hs) + self._storage_controllers = hs.get_storage_controllers() + + def _make_endpoint_path(self, room_id: str) -> str: + return f"/_matrix/federation/unstable/org.matrix.msc4370/extremities/{room_id}" + + def _remote_join(self, room_id: str, room_version: str) -> None: + # Note: other tests ensure the called endpoints in this function return useful + # and proper data. + + # make_join first + joining_user = "@misspiggy:" + self.OTHER_SERVER_NAME + channel = self.make_signed_federation_request( + "GET", + f"/_matrix/federation/v1/make_join/{room_id}/{joining_user}?ver={room_version}", + ) + self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body) + join_result = channel.json_body + + # Sign/populate the join + join_event_dict = join_result["event"] + self.add_hashes_and_signatures_from_other_server( + join_event_dict, + KNOWN_ROOM_VERSIONS[room_version], + ) + if room_version in ["1", "2"]: + add_hashes_and_signatures( + KNOWN_ROOM_VERSIONS[room_version], + join_event_dict, + signature_name=self.hs.hostname, + signing_key=self.hs.signing_key, + ) + + # Send the join + channel = self.make_signed_federation_request( + "PUT", + f"/_matrix/federation/v2/send_join/{room_id}/x", + content=join_event_dict, + ) + + # Check that things went okay so the test doesn't become a total train wreck + self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body) + r = self.get_success(self._storage_controllers.state.get_current_state(room_id)) + self.assertEqual(r[("m.room.member", joining_user)].membership, "join") + + def _test_get_extremities_common(self, room_version: str) -> None: + # Create a room to test with + creator_user_id = self.register_user("kermit", "test") + tok = self.login("kermit", "test") + room_id = self.helper.create_room_as( + room_creator=creator_user_id, + tok=tok, + room_version=room_version, + extra_content={ + # Public preset uses `shared` history visibility, but makes joins + # easier in our tests. + # https://spec.matrix.org/v1.16/client-server-api/#post_matrixclientv3createroom + "preset": "public_chat" + }, + ) + + # At this stage we should fail to get the extremities because we're not joined + # and therefore can't see the events (`shared` history visibility). + channel = self.make_signed_federation_request( + "GET", self._make_endpoint_path(room_id) + ) + self.assertEqual(channel.code, HTTPStatus.FORBIDDEN, channel.json_body) + self.assertEqual(channel.json_body["error"], "Host not in room.") + self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN") + + # Now join the room and try again + # Note: we're expecting a linear room DAG, so there should be just one extremity + self._remote_join(room_id, room_version) + channel = self.make_signed_federation_request( + "GET", self._make_endpoint_path(room_id) + ) + self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body) + self.assertEqual( + channel.json_body["prev_events"], + [ + self.get_success( + self._storage_controllers.main.get_forward_extremities_for_room( + room_id + ) + )[0][0] + ], + ) + + # ACL the calling server and try again. This should cause an error getting extremities. + self.helper.send_state( + room_id, + "m.room.server_acl", + { + "allow": ["*"], + "allow_ip_literals": False, + "deny": [self.OTHER_SERVER_NAME], + }, + tok=tok, + expect_code=HTTPStatus.OK, + ) + channel = self.make_signed_federation_request( + "GET", self._make_endpoint_path(room_id) + ) + self.assertEqual(channel.code, HTTPStatus.FORBIDDEN, channel.json_body) + self.assertEqual(channel.json_body["error"], "Server is banned from room") + self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN") + + @parameterized.expand([(k,) for k in KNOWN_ROOM_VERSIONS.keys()]) + @override_config( + {"use_frozen_dicts": True, "experimental_features": {"msc4370_enabled": True}} + ) + def test_get_extremities_with_frozen_dicts(self, room_version: str) -> None: + """Test GET /extremities with USE_FROZEN_DICTS=True""" + self._test_get_extremities_common(room_version) + + @parameterized.expand([(k,) for k in KNOWN_ROOM_VERSIONS.keys()]) + @override_config( + {"use_frozen_dicts": False, "experimental_features": {"msc4370_enabled": True}} + ) + def test_get_extremities_without_frozen_dicts(self, room_version: str) -> None: + """Test GET /extremities with USE_FROZEN_DICTS=True""" + self._test_get_extremities_common(room_version) + + # note the lack of config-setting stuff on this test. + def test_get_extremities_unstable_not_enabled(self) -> None: + """Test that GET /extremities returns M_UNRECOGNIZED when MSC4370 is not enabled""" + # We shouldn't even have to create a room - the endpoint should just fail. + channel = self.make_signed_federation_request( + "GET", self._make_endpoint_path("!room:example.org") + ) + self.assertEqual(channel.code, HTTPStatus.NOT_FOUND, channel.json_body) + self.assertEqual(channel.json_body["errcode"], "M_UNRECOGNIZED") + + class SendJoinFederationTests(unittest.FederatingHomeserverTestCase): servlets = [ admin.register_servlets,