From a356f964ac46477abb93adf4bacebf4f4c9c824c Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Fri, 30 Oct 2026 10:16:16 +0100 Subject: [PATCH] Remember the server a local user knocked through (MSC4233) When a local user knocks on a room this server is not in, the knock goes through a server the client named in `?server_name`, and we then forget it. Room IDs are not routable and `/leave` takes no `via`, so nothing later can reach the room again. MSC4233 asks the knocking server to remember that server. `send_knock` now returns the server that answered alongside the response, and `do_knock` records it in the new `local_knock_via_servers` table with the knock's event ID. There is one row per (user, room); a new knock on the same room overwrites it. Lookups key on the knock event ID too, so a row left by an earlier knock never vouches for a later one. The row is written after the knock is persisted, outside its transaction. A crash between the two leaves a knock with no remembered route, which lands on the fallback MSC4233 already specifies: generate the leave locally. Rows go with the room on purge. The `msc4233_enabled` flag is added here and read by the next two commits; the tracking issue number is still a placeholder. Co-authored-by: Quentin Gliech Co-Authored-By: Claude Fable 5.1 --- changelog.d/20444.feature | 1 + synapse/config/experimental.py | 5 ++ synapse/federation/federation_client.py | 27 +++++++---- synapse/handlers/federation.py | 17 ++++++- .../storage/databases/main/purge_events.py | 1 + synapse/storage/databases/main/roommember.py | 48 +++++++++++++++++++ synapse/storage/schema/__init__.py | 2 + .../delta/95/03_local_knock_via_servers.sql | 33 +++++++++++++ 8 files changed, 124 insertions(+), 10 deletions(-) create mode 100644 changelog.d/20444.feature create mode 100644 synapse/storage/schema/main/delta/95/03_local_knock_via_servers.sql 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) +);