Skip to main content

tuwunel_api/client/admin/federation/
list_destinations.rs

1use std::cmp::Ordering;
2
3use axum::extract::State;
4use futures::StreamExt;
5use ruma::{ServerName, api::Direction};
6use synapse_admin_api::federation::{
7	Destination,
8	list_destinations::v1::{DestinationSortOrder, Request, Response},
9};
10use tuwunel_core::{
11	Err, Result,
12	utils::{
13		ReadyExt,
14		math::{ruma_from_usize, usize_from_ruma},
15	},
16};
17
18use super::destination_from_backoff;
19use crate::{Ruma, client::admin::require_admin};
20
21/// # `GET /_synapse/admin/v1/federation/destinations`
22pub(crate) async fn admin_list_destinations_route(
23	State(services): State<crate::State>,
24	body: Ruma<Request>,
25) -> Result<Response> {
26	require_admin(&services, body.sender_user()).await?;
27
28	let default_order = DestinationSortOrder::Destination;
29	let order_by = body.order_by.as_ref().unwrap_or(&default_order);
30
31	if !matches!(
32		order_by,
33		DestinationSortOrder::Destination
34			| DestinationSortOrder::RetryLastTs
35			| DestinationSortOrder::RetryInterval
36			| DestinationSortOrder::FailureTs
37			| DestinationSortOrder::LastSuccessfulStreamOrdering
38	) {
39		return Err!(Request(InvalidParam("Unknown order_by parameter")));
40	}
41
42	let dir = body.dir.unwrap_or(Direction::Forward);
43	let from = body.from.map_or(0, usize_from_ruma);
44	let limit = body.limit.map_or(100, usize_from_ruma);
45
46	let needle = body
47		.destination
48		.as_deref()
49		.filter(|needle| !needle.is_empty());
50
51	let mut backoffs = services.federation.peer_backoffs().await;
52
53	let own_server = services.globals.server_name();
54
55	// Self is never a federation destination; drop any stray self-row.
56	backoffs.remove(own_server);
57
58	let mut rows: Vec<Destination> = services
59		.state_cache
60		.servers()
61		.ready_filter(|server| *server != own_server && name_matches(server, needle))
62		.map(|server| destination_from_backoff(server.to_owned(), backoffs.remove(server)))
63		.collect()
64		.await;
65
66	rows.extend(
67		backoffs
68			.into_iter()
69			.filter(|(server, _)| name_matches(server, needle))
70			.map(|(server, backoff)| destination_from_backoff(server, Some(backoff))),
71	);
72
73	rows.sort_unstable_by(|a, b| {
74		let ordering = destination_ordering(order_by, a, b);
75
76		let ordering = match dir {
77			| Direction::Forward => ordering,
78			| Direction::Backward => ordering.reverse(),
79		};
80
81		ordering.then_with(|| a.destination.cmp(&b.destination))
82	});
83
84	let matched_count = rows.len();
85	let total = ruma_from_usize(matched_count);
86
87	let destinations: Vec<Destination> = rows.into_iter().skip(from).take(limit).collect();
88
89	let end = from.saturating_add(destinations.len());
90	let next_token = (end < matched_count).then(|| end.to_string());
91
92	Ok(Response { destinations, total, next_token })
93}
94
95/// Case-insensitive substring match of the server name, mirroring Synapse's
96/// `LIKE %needle%`. A `None` or empty needle matches every server.
97fn name_matches(server: &ServerName, needle: Option<&str>) -> bool {
98	let Some(needle) = needle.filter(|needle| !needle.is_empty()) else {
99		return true;
100	};
101
102	server
103		.as_bytes()
104		.windows(needle.len())
105		.any(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
106}
107
108/// The `Destination` and the doc-hidden `_Custom` variant both fall through to
109/// the server-name tiebreak.
110fn destination_ordering(
111	order_by: &DestinationSortOrder,
112	a: &Destination,
113	b: &Destination,
114) -> Ordering {
115	match order_by {
116		| DestinationSortOrder::RetryLastTs => a.retry_last_ts.cmp(&b.retry_last_ts),
117		| DestinationSortOrder::RetryInterval => a.retry_interval.cmp(&b.retry_interval),
118		| DestinationSortOrder::FailureTs => a.failure_ts.cmp(&b.failure_ts),
119		| DestinationSortOrder::LastSuccessfulStreamOrdering => a
120			.last_successful_stream_ordering
121			.cmp(&b.last_successful_stream_ordering),
122		| _ => a.destination.cmp(&b.destination),
123	}
124}
125
126#[cfg(test)]
127mod tests {
128	use std::cmp::Ordering;
129
130	use ruma::{UInt, server_name, uint};
131	use tuwunel_service::federation::{Classification, PeerBackoff};
132
133	use super::{
134		Destination, DestinationSortOrder, destination_from_backoff, destination_ordering,
135		name_matches,
136	};
137
138	#[test]
139	fn healthy_destination_is_all_zeros() {
140		let row = destination_from_backoff(server_name!("matrix.org").to_owned(), None);
141
142		assert_eq!(row.retry_last_ts, uint!(0));
143		assert_eq!(row.retry_interval, uint!(0));
144		assert_eq!(row.failure_ts, None);
145		assert_eq!(row.last_successful_stream_ordering, None);
146	}
147
148	#[test]
149	fn failing_destination_maps_seconds_to_millis() {
150		let backoff = PeerBackoff {
151			class: Classification::Transient,
152			anchor_secs: 10,
153			oldest_secs: 5,
154			delay_secs: 60,
155		};
156
157		let row = destination_from_backoff(server_name!("matrix.org").to_owned(), Some(backoff));
158
159		assert_eq!(row.retry_last_ts, uint!(10_000));
160		assert_eq!(row.retry_interval, uint!(60_000));
161		assert_eq!(row.failure_ts, Some(uint!(5_000)));
162		assert_eq!(row.last_successful_stream_ordering, None);
163	}
164
165	#[test]
166	fn millis_saturates() {
167		let backoff = PeerBackoff {
168			class: Classification::Transient,
169			anchor_secs: u64::MAX,
170			oldest_secs: 0,
171			delay_secs: 0,
172		};
173
174		let row = destination_from_backoff(server_name!("matrix.org").to_owned(), Some(backoff));
175
176		assert_eq!(row.retry_last_ts, UInt::MAX);
177	}
178
179	#[test]
180	fn name_matches_is_ascii_case_insensitive() {
181		let server = server_name!("matrix.org");
182
183		assert!(name_matches(server, Some("MATRIX")));
184		assert!(name_matches(server, Some("matrix")));
185		assert!(!name_matches(server_name!("example.com"), Some("matrix")));
186		assert!(name_matches(server, None));
187		assert!(name_matches(server, Some("")));
188		assert!(!name_matches(server, Some("matrix.organization")));
189	}
190
191	#[test]
192	fn ordering_selects_the_field() {
193		let low = Destination {
194			retry_last_ts: uint!(1),
195			retry_interval: uint!(9),
196			failure_ts: None,
197			..Destination::new(server_name!("a.example").to_owned())
198		};
199
200		let high = Destination {
201			retry_last_ts: uint!(2),
202			retry_interval: uint!(8),
203			failure_ts: Some(uint!(1)),
204			last_successful_stream_ordering: Some(uint!(1)),
205			..Destination::new(server_name!("b.example").to_owned())
206		};
207
208		assert_eq!(
209			destination_ordering(&DestinationSortOrder::RetryLastTs, &low, &high),
210			Ordering::Less
211		);
212		assert_eq!(
213			destination_ordering(&DestinationSortOrder::RetryInterval, &low, &high),
214			Ordering::Greater
215		);
216		assert_eq!(
217			destination_ordering(&DestinationSortOrder::FailureTs, &low, &high),
218			Ordering::Less
219		);
220		assert_eq!(
221			destination_ordering(
222				&DestinationSortOrder::LastSuccessfulStreamOrdering,
223				&low,
224				&high,
225			),
226			Ordering::Less
227		);
228		assert_eq!(
229			destination_ordering(&DestinationSortOrder::Destination, &low, &high),
230			Ordering::Less
231		);
232	}
233}