-
Notifications
You must be signed in to change notification settings - Fork 564
Experimental federated user directory #19887
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
843d753
f90ee72
0d936e8
50f65b8
46a5168
4f69ad8
36102dc
4f51aa2
b341798
0dcf17b
118be0c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Pre-MSC implementation of a federated user directory. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -36,6 +36,7 @@ | |
| Optional, | ||
| Sequence, | ||
| TypeVar, | ||
| cast, | ||
| ) | ||
|
|
||
| import attr | ||
|
|
@@ -70,13 +71,21 @@ | |
| from synapse.http.types import QueryParams | ||
| from synapse.logging.opentracing import SynapseTags, log_kv, set_tag, tag_args, trace | ||
| from synapse.metrics import SERVER_NAME_LABEL | ||
| from synapse.types import JsonDict, StrCollection, UserID, get_domain_from_id | ||
| from synapse.metrics.background_process_metrics import wrap_as_background_process | ||
| from synapse.types import ( | ||
| JsonDict, | ||
| RemoteUserDirectoryEntry, | ||
| StrCollection, | ||
| UserID, | ||
| get_domain_from_id, | ||
| ) | ||
| from synapse.util.async_helpers import concurrently_execute | ||
| from synapse.util.caches.expiringcache import ExpiringCache | ||
| from synapse.util.duration import Duration | ||
| from synapse.util.retryutils import NotRetryingDestination | ||
|
|
||
| if TYPE_CHECKING: | ||
| from synapse.handlers.user_directory import UserDirectoryFederationHandler | ||
| from synapse.server import HomeServer | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
@@ -134,6 +143,9 @@ def __init__(self, hs: "HomeServer"): | |
| self._clock.looping_call(self._clear_tried_cache, Duration(minutes=1)) | ||
| self.state = hs.get_state_handler() | ||
| self.transport_layer = hs.get_federation_transport_client() | ||
| self.user_directory_search_timeout = ( | ||
| hs.config.experimental.bwi_federated_user_dir_federation_search_timeout | ||
| ) | ||
|
|
||
| self.server_name = hs.hostname | ||
| self.signing_key = hs.signing_key | ||
|
|
@@ -170,6 +182,19 @@ def __init__(self, hs: "HomeServer"): | |
| reset_expiry_on_get=False, | ||
| ) | ||
|
|
||
| # Periodically sync remote homeservers' user directories into our own, | ||
| # but only on the worker that runs background tasks. | ||
| if ( | ||
| hs.config.experimental.bwi_federated_user_dir_enabled | ||
| and hs.config.worker.run_background_tasks | ||
| ): | ||
| self._clock.looping_call( | ||
| self._sync_federated_user_directory, | ||
| Duration( | ||
| milliseconds=hs.config.experimental.bwi_federated_user_dir_sync_interval_ms | ||
| ), | ||
| ) | ||
|
|
||
| def _clear_tried_cache(self) -> None: | ||
| """Clear pdu_destination_tried cache""" | ||
| now = self._clock.time_msec() | ||
|
|
@@ -1916,6 +1941,145 @@ def filter_user_id(user_id: str) -> bool: | |
|
|
||
| return filtered_statuses, filtered_failures | ||
|
|
||
| async def user_directory_search( | ||
| self, | ||
| destination: str, | ||
| timeout: int, | ||
| ) -> JsonDict: | ||
| """Fetch the full user directory of a remote server. | ||
|
|
||
| Args: | ||
| destination: The server to query. | ||
| timeout: Timeout in milliseconds for the request. | ||
|
|
||
| Returns: | ||
| The results containing a list of users from the remote directory. | ||
| """ | ||
| try: | ||
| return await self.transport_layer.user_directory_search( | ||
| destination, timeout | ||
| ) | ||
| except (RequestSendFailed, HttpResponseException) as e: | ||
| # A failing or unreachable destination shouldn't break the sync. The | ||
| # endpoint is rate-limited, and the transport layer surfaces a 429 as | ||
| # a RequestSendFailed after exhausting retries, so log without a | ||
| # stack trace. | ||
| logger.warning( | ||
| "Failed to search remote user directory [destination=%s]: %s", | ||
| destination, | ||
| e, | ||
| ) | ||
| return {"results": []} | ||
| except Exception: | ||
| # Unexpected error; log with a stack trace for debugging. | ||
| logger.exception( | ||
| "Unexpected error searching remote user directory [destination=%s]", | ||
| destination, | ||
| ) | ||
| return {"results": []} | ||
|
|
||
| @staticmethod | ||
| def _parse_remote_user_directory_results( | ||
| response: JsonDict, | ||
| ) -> list[RemoteUserDirectoryEntry]: | ||
| """Parse a remote user directory search response into typed entries. | ||
|
|
||
| Malformed entries are skipped. This keeps the federation wire format | ||
| contained within the federation layer. | ||
| """ | ||
| entries: list[RemoteUserDirectoryEntry] = [] | ||
| for user in response.get("results", []): | ||
| if not isinstance(user, dict): | ||
| logger.debug( | ||
| "Skipping remote user directory entry that is not an object: %r", | ||
| user, | ||
| ) | ||
| continue | ||
|
|
||
| user_id = user.get("user_id") | ||
| if not isinstance(user_id, str): | ||
| logger.debug( | ||
| "Skipping remote user directory entry with missing or " | ||
| "non-string user_id: %r", | ||
| user, | ||
| ) | ||
| continue | ||
|
|
||
| display_name = user.get("display_name") | ||
| if not isinstance(display_name, str): | ||
| if display_name is not None: | ||
| logger.debug( | ||
| "Ignoring non-string display_name for remote user %s: %r", | ||
| user_id, | ||
| display_name, | ||
| ) | ||
| display_name = None | ||
|
|
||
| avatar_url = user.get("avatar_url") | ||
| if not isinstance(avatar_url, str): | ||
| if avatar_url is not None: | ||
| logger.debug( | ||
| "Ignoring non-string avatar_url for remote user %s: %r", | ||
| user_id, | ||
| avatar_url, | ||
| ) | ||
| avatar_url = None | ||
|
|
||
| entries.append( | ||
| RemoteUserDirectoryEntry( | ||
| user_id=user_id, | ||
| display_name=display_name, | ||
| avatar_url=avatar_url, | ||
| ) | ||
| ) | ||
|
|
||
| return entries | ||
|
|
||
| @wrap_as_background_process("federated_user_directory_sync") | ||
| async def _sync_federated_user_directory(self) -> None: | ||
| """Periodically fetch users from known homeservers' user directories | ||
| and hand them to the local user directory for storage. | ||
|
|
||
| This is the federation-side background job: it decides which servers to | ||
| contact and how to query them. Persisting the results is delegated to | ||
| the user directory handler, which knows nothing about federation. | ||
| """ | ||
| destinations = await self.store.get_known_destinations() | ||
| if not destinations: | ||
| logger.debug("ending federated user directory sync: no known destinations") | ||
| return | ||
|
|
||
| # De-duplicate by user id across destinations. | ||
| entries_by_user: dict[str, RemoteUserDirectoryEntry] = {} | ||
|
|
||
| for destination in destinations: | ||
| if self._is_mine_server_name(destination): | ||
| continue | ||
|
|
||
| response = await self.user_directory_search( | ||
| destination, | ||
| self.user_directory_search_timeout, | ||
| ) | ||
|
|
||
| for entry in self._parse_remote_user_directory_results(response): | ||
| entries_by_user[entry.user_id] = entry | ||
|
|
||
| if not entries_by_user: | ||
| logger.debug("Federated user directory sync found no remote users") | ||
| return | ||
|
|
||
| # This job is only scheduled when the feature is enabled, in which case | ||
| # the homeserver exposes the federation-aware handler variant. | ||
| handler = cast( | ||
| "UserDirectoryFederationHandler", self.hs.get_user_directory_handler() | ||
| ) | ||
| await handler.upsert_remote_users(list(entries_by_user.values())) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There doesn't appear to be any mechanism for deleting users who have been deactivated on the remote homeserver. Nor will ceasing federation with a homeserver ever remove those users from the local user directory (as they'll never leave the "fake" room).
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You are right, early state. |
||
|
|
||
| logger.debug( | ||
| "Federated user directory sync upserted %d remote users", | ||
| len(entries_by_user), | ||
| ) | ||
|
|
||
| async def federation_download_media( | ||
| self, | ||
| destination: str, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would help to log at the DEBUG level in these cases, to help debug why a certain user's attributes may not be appearing over federation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Implemented.