diff --git a/changelog.d/20444.feature b/changelog.d/20444.feature new file mode 100644 index 0000000000..eadc15f5d0 --- /dev/null +++ b/changelog.d/20444.feature @@ -0,0 +1 @@ +Add experimental support for [MSC4233](https://github.com/matrix-org/matrix-spec-proposals/pull/4233), so that a user who knocks on a remote room can withdraw the knock over federation, and is told when the room rejects it. diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py index c338f065d4..fcd846f662 100644 --- a/synapse/config/experimental.py +++ b/synapse/config/experimental.py @@ -263,6 +263,11 @@ class ExperimentalConfig(Config): # TODO: Remove the flag and the legacy rules after 2026-12-01. self.msc4210_enabled: bool = experimental.get("msc4210_enabled", True) + # MSC4233: Remembering which server a user knocked through, so that + # knocks can be rescinded and denied over federation. + # Tracked in: https://github.com/element-hq/synapse/issues/NNNNN + self.msc4233_enabled: bool = experimental.get("msc4233_enabled", False) + # MSC4076: Add `disable_badge_count`` to pusher configuration self.msc4076_enabled: bool = experimental.get("msc4076_enabled", False) diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index 214f88e0e1..d97b04e027 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -136,6 +136,15 @@ class SendJoinResult: state_dag: list[EventBase] | None +@attr.s(slots=True, frozen=True, auto_attribs=True) +class SendKnockResult: + # The response body from the remote server, of the form + # {"knock_room_state": [, ...]}. + response: JsonDict + # The server which fulfilled the knock (i.e. answered our /send_knock). + origin: str + + class FederationClient(FederationBase): def __init__(self, hs: "HomeServer"): super().__init__(hs) @@ -1588,7 +1597,9 @@ class FederationClient(FederationBase): # content. return resp[1] - async def send_knock(self, destinations: list[str], pdu: EventBase) -> JsonDict: + async def send_knock( + self, destinations: list[str], pdu: EventBase + ) -> "SendKnockResult": """Attempts to send a knock event to a given list of servers. Iterates through the list until one attempt succeeds. @@ -1601,20 +1612,18 @@ class FederationClient(FederationBase): pdu: The event to be sent. Returns: - The remote homeserver return some state from the room. The response - dictionary is in the form: - - {"knock_room_state": [, ...]} - - The list of state events may be empty. + A SendKnockResult holding the remote homeserver's response (some + state from the room, possibly empty) and the name of the server + that fulfilled the knock. Raises: SynapseError: If the chosen remote server returns a 3xx/4xx code. RuntimeError: If no servers were reachable. """ - async def send_request(destination: str) -> JsonDict: - return await self._do_send_knock(destination, pdu) + async def send_request(destination: str) -> "SendKnockResult": + response = await self._do_send_knock(destination, pdu) + return SendKnockResult(response=response, origin=destination) return await self._try_destination_list( "send_knock", destinations, send_request diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index fab47968fa..e817e4f020 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -982,7 +982,8 @@ class FederationHandler: # Send the signed event back to the room, and potentially receive some # further information about the room in the form of partial state events - knock_response = await self.federation_client.send_knock(target_hosts, event) + send_knock_result = await self.federation_client.send_knock(target_hosts, event) + knock_response = send_knock_result.response # Store any stripped room state events in the "unsigned" key of the event. # This is a bit of a hack and is cribbing off of invites. Basically we @@ -1040,6 +1041,20 @@ class FederationHandler: stream_id = await self._federation_event_handler.persist_events_and_notify( event.room_id, [(event, context)] ) + + # Remember which server fulfilled the knock, so that we can route a + # rescission of the knock through it later, and know which server to + # trust for an out-of-band retraction of the knock (MSC4233). A crash + # between persisting the knock and this write leaves the knock without + # a remembered route, which MSC4233 already covers: we fall back to + # generating the leave locally. + await self.store.store_local_knock_via_server( + user_id=knockee, + room_id=event.room_id, + via_server=send_knock_result.origin, + knock_event_id=event.event_id, + ) + return event.event_id, stream_id async def _handle_queued_pdus( diff --git a/synapse/storage/databases/main/purge_events.py b/synapse/storage/databases/main/purge_events.py index e8f60ea755..b777aa1e21 100644 --- a/synapse/storage/databases/main/purge_events.py +++ b/synapse/storage/databases/main/purge_events.py @@ -95,6 +95,7 @@ purge_room_tables_with_room_id_column = ( "appservice_room_list", "e2e_room_keys", "event_push_summary", + "local_knock_via_servers", "pusher_throttle", "room_account_data", "room_tags", diff --git a/synapse/storage/databases/main/roommember.py b/synapse/storage/databases/main/roommember.py index 72ac8c7ed2..b6a02a0927 100644 --- a/synapse/storage/databases/main/roommember.py +++ b/synapse/storage/databases/main/roommember.py @@ -686,6 +686,54 @@ class RoomMemberWorkerStore(EventsWorkerStore, CacheInvalidationWorkerStore): return results + async def store_local_knock_via_server( + self, user_id: str, room_id: str, via_server: str, knock_event_id: str + ) -> None: + """Record the remote server that fulfilled a local user's knock on a + remote room, so that a later rescission can be routed through it + (MSC4233). Overwrites any previous record for the user/room pair. + + Args: + user_id: The ID of the knocking (local) user. + room_id: The ID of the room that was knocked on. + via_server: The remote server that answered our /send_knock. + knock_event_id: The event ID of the knock membership event. + """ + await self.db_pool.simple_upsert( + table="local_knock_via_servers", + keyvalues={"user_id": user_id, "room_id": room_id}, + values={"via_server": via_server, "knock_event_id": knock_event_id}, + desc="store_local_knock_via_server", + ) + + async def get_local_knock_via_server( + self, user_id: str, room_id: str, knock_event_id: str + ) -> str | None: + """Retrieve the remote server that fulfilled a local user's knock on a + remote room, if we have a record of one (MSC4233). + + Args: + user_id: The ID of the knocking (local) user. + room_id: The ID of the room that was knocked on. + knock_event_id: The event ID of the knock the caller is acting on. + A record left over from an earlier knock on the same room does + not vouch for it. + + Returns: + The server name, or None if no route is recorded for that knock. + """ + return await self.db_pool.simple_select_one_onecol( + table="local_knock_via_servers", + keyvalues={ + "user_id": user_id, + "room_id": room_id, + "knock_event_id": knock_event_id, + }, + retcol="via_server", + allow_none=True, + desc="get_local_knock_via_server", + ) + async def get_users_server_still_shares_room_with( self, user_ids: Collection[str] ) -> set[str]: diff --git a/synapse/storage/schema/__init__.py b/synapse/storage/schema/__init__.py index 7e736808cb..e1e380fcdd 100644 --- a/synapse/storage/schema/__init__.py +++ b/synapse/storage/schema/__init__.py @@ -184,6 +184,8 @@ Changes in SCHEMA_VERSION = 95 sent from a different user. - `event_edges.is_state` is no longer read from (it has not been written to since SCHEMA_VERSION 72). - Add `server_announcement_rooms` table. + - MSC4233: Add the `local_knock_via_servers` table, recording which server a + local user's knock was fulfilled through. """ diff --git a/synapse/storage/schema/main/delta/95/03_local_knock_via_servers.sql b/synapse/storage/schema/main/delta/95/03_local_knock_via_servers.sql new file mode 100644 index 0000000000..d3985dbbb0 --- /dev/null +++ b/synapse/storage/schema/main/delta/95/03_local_knock_via_servers.sql @@ -0,0 +1,33 @@ +-- +-- 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: +-- . + +-- Records the remote server that fulfilled a local user's knock on a remote +-- room (i.e. the server that answered our /send_knock request), so that we can +-- route a subsequent rescission of the knock through the same server, and +-- so that we know which server to trust for out-of-band retractions of the +-- knock (MSC4233). +-- +-- At most one row per local user and knocked room: a later knock on the same +-- room overwrites the previous one. Rows are not deleted when the knock ends, +-- so the table grows with the number of rooms local users have ever knocked +-- on; MSC4233 only requires the record to survive as long as the knock is the +-- user's membership. +CREATE TABLE local_knock_via_servers ( + user_id TEXT NOT NULL, + room_id TEXT NOT NULL, + -- The server the knock was fulfilled through. + via_server TEXT NOT NULL, + -- The event ID of the knock membership event. + knock_event_id TEXT NOT NULL, + PRIMARY KEY (user_id, room_id) +);