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 <quenting@element.io>

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
authored and Quentin Gliech committed 2026-10-30 10:20:36 +01:00
1 parent ca28da9663
commit a356f964ac
8 files changed
+124 -10

No files matched your search

+1
View File
@@ -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.
+5
View File
@@ -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)
+18 -9
View File
@@ -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": [<state event dict>, ...]}.
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": [<state event dict>, ...]}
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
+16 -1
View File
@@ -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(
@@ -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",
@@ -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]:
+2
View File
@@ -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.
"""
@@ -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:
-- <https://www.gnu.org/licenses/agpl-3.0.html>.
-- 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)
);