1mod event;
2mod head;
3mod ping;
4mod state;
5#[cfg(test)]
6mod tests;
7mod version;
8
9use std::{
10 borrow::Cow,
11 cmp::Ordering,
12 collections::BTreeMap,
13 fmt::{Result as FmtResult, Write as _},
14 num::NonZeroUsize,
15 time::Duration,
16};
17
18use clap::{ArgAction, Args, Subcommand, ValueEnum};
19use futures::StreamExt;
20use ruma::{OwnedEventId, OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId};
21use tuwunel_core::{
22 Err, Result, implement,
23 utils::{
24 stream::ReadyExt,
25 string::{markdown_cell, plural},
26 time::Elapsed,
27 },
28};
29use tuwunel_service::federation::{
30 PeerBackoff,
31 feds::{Fault, Opts, Outcome, Record},
32};
33
34use self::version::Field;
35use crate::{Context, admin_command_dispatch};
36
37pub(super) type Backoffs = BTreeMap<OwnedServerName, PeerBackoff>;
38
39#[admin_command_dispatch(handler_prefix = "feds")]
44#[derive(Debug, Subcommand)]
45pub(crate) enum FedsCommand {
46 Version {
48 room: OwnedRoomOrAliasId,
49
50 #[arg(long = "field", value_enum)]
52 fields: Vec<Field>,
53
54 #[arg(long, group = "version_list")]
56 list: bool,
57
58 #[arg(long, group = "version_list")]
60 list_all: bool,
61
62 #[arg(long, group = "version_list")]
64 list_errors: bool,
65
66 #[arg(long, value_enum, default_value_t, requires = "version_list")]
68 sort: Sort,
69
70 #[command(flatten)]
71 sweep: SweepArgs,
72 },
73
74 Ping {
79 room: OwnedRoomOrAliasId,
80
81 #[arg(long, group = "ping_list")]
83 list: bool,
84
85 #[arg(long, group = "ping_list")]
87 list_all: bool,
88
89 #[arg(long, group = "ping_list")]
91 list_errors: bool,
92
93 #[arg(long, value_enum, default_value_t, requires = "ping_list")]
95 sort: Sort,
96
97 #[command(flatten)]
98 sweep: SweepArgs,
99 },
100
101 Event {
106 event_id: OwnedEventId,
107
108 room: Option<OwnedRoomOrAliasId>,
112
113 #[arg(long, default_value_t = true, action = ArgAction::Set)]
117 verify_hash: bool,
118
119 #[arg(long, default_value_t = true, action = ArgAction::Set)]
123 verify_signature: bool,
124
125 #[command(flatten)]
126 sweep: SweepArgs,
127 },
128
129 State {
131 room: OwnedRoomOrAliasId,
132
133 #[arg(long)]
136 at: Option<OwnedEventId>,
137
138 #[arg(long)]
140 auth_chain: bool,
141
142 #[arg(long)]
144 full: bool,
145
146 #[command(flatten)]
147 sweep: SweepArgs,
148 },
149
150 Head {
155 room: OwnedRoomOrAliasId,
156
157 #[arg(long)]
159 probe_user: Option<OwnedUserId>,
160
161 #[command(flatten)]
162 sweep: SweepArgs,
163 },
164}
165
166#[derive(Clone, Copy, Debug, Args)]
171pub(crate) struct SweepArgs {
172 #[arg(long)]
174 width: Option<NonZeroUsize>,
175
176 #[arg(long, default_value_t = 10)]
178 timeout: u64,
179
180 #[arg(long, default_value_t = 120)]
182 budget: u64,
183
184 #[arg(long)]
186 no_loopback: bool,
187
188 #[arg(long)]
190 yes_i_want_to_do_this: bool,
191}
192
193#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
197pub(crate) enum Sort {
198 #[default]
200 Origin,
201
202 Elapsed,
204
205 Fault,
207}
208
209#[derive(Clone, Copy)]
210pub(super) enum ListMode {
211 None,
212 Successes,
213 All,
214 Errors,
215}
216
217pub(super) struct Prepared {
218 pub(super) room_id: OwnedRoomId,
219 pub(super) opts: Opts,
220}
221
222pub(super) async fn prepare(
223 context: &Context<'_>,
224 room: &OwnedRoomOrAliasId,
225 sweep: SweepArgs,
226 default_width: NonZeroUsize,
227) -> Result<Prepared> {
228 if !context.services.server.config.allow_federation {
229 return Err!("Federation is disabled on this homeserver.");
230 }
231
232 let room_id = context.services.alias.maybe_resolve(room).await?;
233 let destinations = context
234 .services
235 .state_cache
236 .room_servers(&room_id)
237 .ready_filter(|server| {
238 !sweep.no_loopback || !context.services.globals.server_is_ours(server)
239 })
240 .count()
241 .await;
242
243 let destination_limit = context
244 .services
245 .server
246 .config
247 .feds_destination_limit;
248
249 if destinations > destination_limit && !sweep.yes_i_want_to_do_this {
250 return Err!(
251 "Room has {destinations} feds destinations, exceeding the safety cap of \
252 {destination_limit}. Pass the confirmation flag to continue."
253 );
254 }
255
256 writeln!(context, "Querying {destinations} servers in {room_id}.\n").await?;
257
258 let opts = Opts {
259 width: Some(sweep.width.unwrap_or(default_width)),
260 timeout: Some(Duration::from_secs(sweep.timeout)),
261 sweep_deadline: Some(Duration::from_secs(sweep.budget)),
262 exclude_self: sweep.no_loopback,
263 record: Record::Observe,
264 };
265
266 Ok(Prepared { room_id, opts })
267}
268
269#[implement(ListMode)]
270pub(super) fn new(list: bool, list_all: bool, list_errors: bool) -> Self {
271 match (list, list_all, list_errors) {
272 | (true, false, false) => Self::Successes,
273 | (false, true, false) => Self::All,
274 | (false, false, true) => Self::Errors,
275 | _ => Self::None,
276 }
277}
278
279#[implement(ListMode)]
280pub(super) fn includes<T>(self, outcome: &Outcome<T>) -> bool {
281 match self {
282 | Self::None => false,
283 | Self::Successes => outcome.result.is_ok(),
284 | Self::All => true,
285 | Self::Errors => outcome.result.is_err(),
286 }
287}
288
289pub(super) async fn partition_backoffs<T>(
294 context: &Context<'_>,
295 prepared: &Prepared,
296 backoffs: &Backoffs,
297 now: u64,
298) -> (Vec<OwnedServerName>, Vec<Outcome<T>>) {
299 context
300 .services
301 .state_cache
302 .room_servers(&prepared.room_id)
303 .ready_filter(|server| {
304 !prepared.opts.exclude_self || !context.services.globals.server_is_ours(server)
305 })
306 .map(ToOwned::to_owned)
307 .ready_fold((Vec::new(), Vec::new()), |(mut eligible, mut outcomes), origin| {
308 match backoffs
309 .get(&origin)
310 .and_then(|backoff| backoff_fault(backoff, now))
311 {
312 | None => eligible.push(origin),
313 | Some(fault) => outcomes.push(Outcome {
314 origin,
315 elapsed: Duration::ZERO,
316 result: Err(fault),
317 }),
318 }
319
320 (eligible, outcomes)
321 })
322 .await
323}
324
325pub(super) fn backoff_fault(backoff: &PeerBackoff, now: u64) -> Option<Fault> {
330 retry_after(backoff, now).map(|retry| Fault::Backoff {
331 class: backoff.class,
332 age: Duration::from_secs(now.saturating_sub(backoff.oldest_secs)),
333 retry,
334 })
335}
336
337pub(super) fn retry_after(backoff: &PeerBackoff, now: u64) -> Option<Duration> {
342 let retry_at = backoff
343 .anchor_secs
344 .saturating_add(backoff.delay_secs);
345
346 retry_at
347 .gt(&now)
348 .then(|| Duration::from_secs(retry_at.saturating_sub(now)))
349}
350
351pub(super) fn sorted<T>(
352 mut outcomes: Vec<Outcome<T>>,
353 sort: Sort,
354 fault_key: impl Fn(&Outcome<T>) -> Cow<'static, str>,
355) -> Vec<Outcome<T>> {
356 outcomes.sort_by(|left, right| left.origin.cmp(&right.origin));
358 match sort {
359 | Sort::Origin => {},
360 | Sort::Elapsed => outcomes.sort_by_key(|outcome| outcome.elapsed),
361 | Sort::Fault => outcomes.sort_by_cached_key(fault_key),
362 }
363
364 outcomes
365}
366
367pub(super) fn fault_message(fault: &Fault) -> Cow<'static, str> {
368 match fault {
369 | Fault::Elapsed => Cow::Borrowed("request deadline exceeded"),
370 | Fault::NotAttempted => Cow::Borrowed("sweep budget exhausted before dispatch"),
371 | Fault::Backoff { class, age, retry } => Cow::Owned(format!(
372 "peer backoff ({class:?}, age {}, retry {})",
373 Elapsed::from(*age),
374 Elapsed::from(*retry),
375 )),
376 | Fault::Error(error) => Cow::Owned(format!("{:?}: {}", error.kind(), error.message())),
377 }
378}
379
380pub(super) fn count_results<T>(outcomes: &[Outcome<T>]) -> usize {
381 outcomes
382 .iter()
383 .filter(|outcome| outcome.result.is_ok())
384 .count()
385}
386
387pub(super) fn render_totals(
388 output: &mut String,
389 results: usize,
390 duration: Duration,
391) -> FmtResult {
392 let noun = plural(results, "result", "results");
393
394 writeln!(output, "\n{results} {noun} in {}.", Elapsed::from(duration))
395}
396
397pub(super) fn write_elapsed_cell<T>(output: &mut String, outcome: &Outcome<T>) -> FmtResult {
402 if matches!(&outcome.result, Err(Fault::NotAttempted | Fault::Backoff { .. })) {
403 write!(output, " |")
404 } else {
405 write!(output, " {} |", Elapsed::from(outcome.elapsed))
406 }
407}
408
409pub(super) fn write_cell(output: &mut String, value: &str) -> FmtResult {
410 if value.is_empty() {
411 write!(output, " |")
412 } else {
413 write!(output, " {value} |")
414 }
415}
416
417pub(super) fn sorted_event_id_difference(
418 remote: &[OwnedEventId],
419 local: &[OwnedEventId],
420) -> Cow<'static, str> {
421 let mut remote = remote.iter().peekable();
422 let mut local = local.iter().peekable();
423 let mut added = 0_usize;
424 let mut missing = 0_usize;
425
426 while let (Some(remote_id), Some(local_id)) = (remote.peek(), local.peek()) {
427 match remote_id.cmp(local_id) {
428 | Ordering::Less => {
429 added = added.saturating_add(1);
430 _ = remote.next();
431 },
432 | Ordering::Greater => {
433 missing = missing.saturating_add(1);
434 _ = local.next();
435 },
436 | Ordering::Equal => {
437 _ = remote.next();
438 _ = local.next();
439 },
440 }
441 }
442
443 added = added.saturating_add(remote.count());
444 missing = missing.saturating_add(local.count());
445
446 match (added, missing) {
447 | (0, 0) => Cow::Borrowed("="),
448 | _ => Cow::Owned(format!("+{added}/-{missing}")),
449 }
450}