didbot_serve/relay.rs
1//! `com.atproto.sync.requestCrawl` and `notifyOfUpdate`, called outward.
2//!
3//! These are the two `com.atproto.*` methods this crate is a *client* of
4//! rather than a server for. A relay discovers a new personal data server by
5//! subscribing to its `subscribeRepos` — but nothing makes a relay dial an
6//! address it has never heard of. `requestCrawl` is the announcement: "I
7//! exist, my hostname is this, please open a subscription." Without ever
8//! sending it, `subscribeRepos` is a stream nobody is listening to, and the
9//! symptom is silence — no error, no refused write, just an account that
10//! never appears anywhere a relay indexes. `notifyOfUpdate` is the same
11//! request against a different, deprecated NSID — upstream's own schema
12//! says to use `requestCrawl` instead — kept here because a relay that
13//! dropped a subscription mid-stream is the one case its description names
14//! specifically, and an operator recovering from that gets to call the
15//! method upstream still names for it.
16//!
17//! # When either fires
18//!
19//! Neither fires on its own. Both are operator-triggered: naming a relay
20//! does not announce anything by itself, so an operator asks explicitly
21//! rather than a restart asking for them. There is no startup announce and
22//! no timer. This module itself has no opinion on when it is called; it
23//! only answers what happened when it was.
24//!
25//! # What a failure does
26//!
27//! Nothing to serving, because nothing on the request-serving path calls
28//! into this module at all. [`RelayClient::request_crawl`] and
29//! [`RelayClient::notify_of_update`] return a typed [`RelayCrawlError`] to
30//! their caller, distinguishing a relay that refused (`Refused`, including
31//! `HostBanned`) from one that could not be reached at all
32//! (`Unreachable`). There is no log-only failure path here: the operator
33//! who asked is the channel.
34//!
35//! # Which relays, and localhost
36//!
37//! Configurable — [`RelaySection`](didbot_config::sections::RelaySection) in
38//! `didbot-config`, or `--relay-hostname` on `didbot-pds` directly, once per
39//! relay — never hardcoded, because this project vendors nobody's relay address as a
40//! default. A development run against `*.localhost` has no relay reachable
41//! from it and no hostname a relay could ever resolve back to this machine,
42//! so `didbot-pds.rs` never wires `ANNOUNCE` up for a loopback zone even when
43//! a relay is configured; see that binary's own call site.
44
45use std::time::Duration;
46
47use serde::Serialize;
48use serde_json::Value;
49
50/// `com.atproto.sync.requestCrawl`'s NSID, for the URL this client builds
51/// and for anything that wants to name the method in a log line.
52pub const REQUEST_CRAWL_NSID: &str = "com.atproto.sync.requestCrawl";
53
54/// `com.atproto.sync.notifyOfUpdate`'s NSID. Deprecated upstream in favor of
55/// `requestCrawl` — same request shape, same effect, different name — kept
56/// here for the one case its own description names: recovering from a gap
57/// after a relay's subscription dropped.
58pub const NOTIFY_OF_UPDATE_NSID: &str = "com.atproto.sync.notifyOfUpdate";
59
60/// How long [`RelayClient::request_crawl`] and
61/// [`RelayClient::notify_of_update`] wait before giving up.
62///
63/// A relay this server cannot reach must not hang the operator command that
64/// calls either forever; a generous bound rather than a tight one, since an
65/// operator invoking this waits on it interactively and nothing here is
66/// latency-sensitive.
67pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
68
69/// How much of a non-success response body [`RelayCrawlError::Refused`]
70/// keeps: a relay answering with a large page must not flood the log this
71/// failure is reported to.
72///
73/// How much is *read* to get it is
74/// [`didbot_http::MAX_JSON_BODY`], the bound this
75/// workspace holds any body from a server it does not operate to. Past it the
76/// message says the body went unread, which a relay answering an endless
77/// error page earns.
78const MAX_ERROR_BODY: usize = 512;
79
80/// Body of both `requestCrawl`'s and `notifyOfUpdate`'s request — the two
81/// lexicons declare the identical `{"hostname": ..}` shape.
82#[derive(Debug, Clone, Serialize)]
83struct RequestCrawlBody {
84 hostname: String,
85}
86
87/// The JSON body a `requestCrawl` or `notifyOfUpdate` call sends, built in
88/// isolation from any network concern so a test can check it against either
89/// vendored lexicon's `input` schema without standing up a server.
90pub fn request_crawl_body(hostname: &str) -> Value {
91 serde_json::to_value(RequestCrawlBody {
92 hostname: hostname.to_owned(),
93 })
94 .expect("a struct of one string always serializes")
95}
96
97/// Why a relay did not end up crawling this server.
98///
99/// Both variants are returned to the caller rather than swallowed — see the
100/// module documentation for who that caller is and what it does with one.
101#[derive(Debug, thiserror::Error)]
102pub enum RelayCrawlError {
103 /// The request never completed: nothing listening, DNS, TLS, or the
104 /// timeout.
105 #[error("cannot reach relay {relay} at {url}: {source}")]
106 Unreachable {
107 /// The relay's configured base URL.
108 relay: String,
109 /// The full URL that was tried.
110 url: String,
111 /// The transport failure `reqwest` reported.
112 #[source]
113 source: reqwest::Error,
114 },
115 /// The relay answered, but not with success — including `HostBanned`,
116 /// the one error `requestCrawl`'s own document declares.
117 #[error("relay {relay} refused the crawl request: {status} {body}")]
118 Refused {
119 /// The relay's configured base URL.
120 relay: String,
121 /// The HTTP status the relay answered with.
122 status: u16,
123 /// The response body, truncated to `MAX_ERROR_BODY`.
124 body: String,
125 },
126}
127
128/// Calls `requestCrawl` or `notifyOfUpdate` against one relay.
129///
130/// A thin wrapper around [`reqwest::Client`] rather than a trait: the seam a
131/// test needs is which URL this client talks to, not a second implementation
132/// of what it does with the response, so [`RelayClient::new`] takes the base
133/// URL directly and a test points it at a throwaway local server instead of
134/// a real relay. See this module's own tests.
135#[derive(Debug, Clone)]
136pub struct RelayClient {
137 http: reqwest::Client,
138 /// The relay's base URL, without a trailing slash — `https://bsky.network`,
139 /// or `http://127.0.0.1:PORT` in a test.
140 base_url: String,
141}
142
143/// The relays an operator's announce reaches, and the hostname it announces.
144///
145/// Built once at startup from `--relay-hostname`/`[relay]` and held rather
146/// than acted on: naming a relay does not announce anything by itself.
147#[derive(Debug, Clone)]
148pub struct RelayTarget {
149 /// One client per relay, in the order they were named.
150 pub relays: Vec<RelayClient>,
151 /// This server's own hostname — what gets announced, not the relay's.
152 pub hostname: String,
153}
154
155impl RelayTarget {
156 /// Announces [`Self::hostname`] to every relay at once, over
157 /// `requestCrawl`, or `notifyOfUpdate` when `resume` is set.
158 ///
159 /// Answers one outcome per relay, each beside that relay's base URL, in
160 /// the order the relays were named. One relay failing does not stop the
161 /// others being asked.
162 pub async fn announce(&self, resume: bool) -> Vec<(String, Result<(), RelayCrawlError>)> {
163 let mut calls = tokio::task::JoinSet::new();
164 for (index, client) in self.relays.iter().cloned().enumerate() {
165 let hostname = self.hostname.clone();
166 calls.spawn(async move {
167 let outcome = if resume {
168 client.notify_of_update(&hostname).await
169 } else {
170 client.request_crawl(&hostname).await
171 };
172 (index, client.base_url, outcome)
173 });
174 }
175 let mut outcomes = calls.join_all().await;
176 outcomes.sort_by_key(|(index, _, _)| *index);
177 outcomes
178 .into_iter()
179 .map(|(_, relay, outcome)| (relay, outcome))
180 .collect()
181 }
182}
183
184impl RelayClient {
185 /// Builds a client pointed at `base_url`.
186 ///
187 /// Falls back to a default-configured [`reqwest::Client`] if one cannot
188 /// be built with the timeout applied: a one-shot announcement degrading
189 /// is preferable to a startup path that refuses to construct.
190 pub fn new(base_url: impl Into<String>) -> Self {
191 let http = didbot_http::builder()
192 .timeout(REQUEST_TIMEOUT)
193 .build()
194 .unwrap_or_default();
195 Self {
196 http,
197 base_url: base_url.into(),
198 }
199 }
200
201 /// Asks this client's relay to crawl `hostname`, over `requestCrawl`.
202 pub async fn request_crawl(&self, hostname: &str) -> Result<(), RelayCrawlError> {
203 self.call(REQUEST_CRAWL_NSID, hostname).await
204 }
205
206 /// Asks this client's relay to resume crawling `hostname`, over the
207 /// deprecated `notifyOfUpdate` — the method upstream's own schema names
208 /// for recovering from a gap after a dropped subscription; see this
209 /// module's doc.
210 pub async fn notify_of_update(&self, hostname: &str) -> Result<(), RelayCrawlError> {
211 self.call(NOTIFY_OF_UPDATE_NSID, hostname).await
212 }
213
214 /// Shared body of [`Self::request_crawl`] and [`Self::notify_of_update`]:
215 /// both lexicons take the identical `{"hostname": ..}` input and report
216 /// success or failure identically, so only the NSID in the URL differs.
217 async fn call(&self, nsid: &str, hostname: &str) -> Result<(), RelayCrawlError> {
218 let url = format!("{}/xrpc/{nsid}", self.base_url);
219 let body = request_crawl_body(hostname);
220 let response = self
221 .http
222 .post(&url)
223 .json(&body)
224 .send()
225 .await
226 .map_err(|source| RelayCrawlError::Unreachable {
227 relay: self.base_url.clone(),
228 url: url.clone(),
229 source,
230 })?;
231 let status = response.status();
232 if !status.is_success() {
233 // Bounded on the way in, then truncated. The truncation is what
234 // keeps the log readable; the bound is what decides how much of a
235 // relay's answer this process ever holds, and a relay is a server
236 // nobody here operates.
237 let body = match didbot_http::read_bounded(response, didbot_http::MAX_JSON_BODY).await {
238 Ok(bytes) => String::from_utf8_lossy(&bytes)
239 .chars()
240 .take(MAX_ERROR_BODY)
241 .collect(),
242 Err(err) => format!("<unread: {err}>"),
243 };
244 return Err(RelayCrawlError::Refused {
245 relay: self.base_url.clone(),
246 status: status.as_u16(),
247 body,
248 });
249 }
250 Ok(())
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use axum::extract::State;
257 use axum::http::StatusCode;
258 use axum::routing::post;
259 use axum::{Json, Router};
260 use tokio::net::TcpListener;
261
262 use super::*;
263
264 /// Every request body the fake relay below received, shared with the
265 /// test that started it.
266 type Received = std::sync::Arc<std::sync::Mutex<Vec<Value>>>;
267
268 /// A fake relay's fixed state: the response it always answers, and
269 /// where it records what it was sent.
270 type FakeRelayState = ((StatusCode, Value), Received);
271
272 /// Starts a fake relay on loopback that always answers `outcome`,
273 /// recording every request body it received.
274 async fn fake_relay(outcome: (StatusCode, Value)) -> (String, Received) {
275 let received: Received = Default::default();
276 let state: FakeRelayState = (outcome, received.clone());
277 let handler = move |State((outcome, received)): State<FakeRelayState>,
278 Json(body): Json<Value>| async move {
279 received.lock().expect("poisoned").push(body);
280 (outcome.0, Json(outcome.1))
281 };
282 // Both NSIDs answered by the same handler: `request_crawl` and
283 // `notify_of_update` are exercised against one fixture rather than
284 // two, since the two lexicons differ only in which path is called.
285 let app = Router::new()
286 .route(&format!("/xrpc/{REQUEST_CRAWL_NSID}"), post(handler))
287 .route(&format!("/xrpc/{NOTIFY_OF_UPDATE_NSID}"), post(handler))
288 .with_state(state);
289 let listener = TcpListener::bind("127.0.0.1:0")
290 .await
291 .expect("loopback binds");
292 let addr = listener.local_addr().expect("bound listener has an addr");
293 tokio::spawn(async move {
294 axum::serve(listener, app).await.expect("fake relay serves");
295 });
296 (format!("http://{addr}"), received)
297 }
298
299 #[test]
300 fn the_request_body_is_exactly_what_the_lexicon_requires() {
301 let body = request_crawl_body("pds.example.com");
302 assert_eq!(body, serde_json::json!({"hostname": "pds.example.com"}));
303 }
304
305 #[tokio::test]
306 async fn a_relay_that_accepts_is_reported_ok() {
307 let (base_url, received) = fake_relay((StatusCode::OK, serde_json::json!({}))).await;
308 let client = RelayClient::new(base_url);
309 client
310 .request_crawl("pds.example.com")
311 .await
312 .expect("the fake relay answers success");
313 assert_eq!(
314 received.lock().expect("poisoned").as_slice(),
315 [serde_json::json!({"hostname": "pds.example.com"})]
316 );
317 }
318
319 /// Every named relay is asked, one refusing or unreachable does not stop
320 /// the rest, and the outcomes come back in the order the relays were
321 /// named.
322 #[tokio::test]
323 async fn an_announce_asks_every_relay_and_reports_each() {
324 let (accepting, accepted) = fake_relay((StatusCode::OK, serde_json::json!({}))).await;
325 let (refusing, refused) = fake_relay((
326 StatusCode::FORBIDDEN,
327 serde_json::json!({"error": "HostBanned"}),
328 ))
329 .await;
330 // Bound and dropped, so nothing listens there.
331 let gone = TcpListener::bind("127.0.0.1:0")
332 .await
333 .expect("loopback binds")
334 .local_addr()
335 .expect("bound listener has an addr");
336 let unreachable = format!("http://{gone}");
337 let target = RelayTarget {
338 relays: [&refusing, &unreachable, &accepting]
339 .into_iter()
340 .map(|base_url| RelayClient::new(base_url.clone()))
341 .collect(),
342 hostname: "pds.example.com".to_owned(),
343 };
344
345 let outcomes = target.announce(false).await;
346
347 let relays: Vec<&str> = outcomes.iter().map(|(relay, _)| relay.as_str()).collect();
348 assert_eq!(relays, [&refusing, &unreachable, &accepting]);
349 assert!(matches!(
350 outcomes[0].1,
351 Err(RelayCrawlError::Refused { status: 403, .. })
352 ));
353 assert!(matches!(
354 outcomes[1].1,
355 Err(RelayCrawlError::Unreachable { .. })
356 ));
357 assert!(outcomes[2].1.is_ok());
358 let body = serde_json::json!({"hostname": "pds.example.com"});
359 assert_eq!(
360 accepted.lock().expect("poisoned").as_slice(),
361 std::slice::from_ref(&body)
362 );
363 assert_eq!(refused.lock().expect("poisoned").as_slice(), [body]);
364 }
365
366 #[tokio::test]
367 async fn a_relay_that_refuses_is_reported_and_named() {
368 let (base_url, _received) = fake_relay((
369 StatusCode::FORBIDDEN,
370 serde_json::json!({"error": "HostBanned", "message": "no"}),
371 ))
372 .await;
373 let client = RelayClient::new(base_url);
374 let err = client
375 .request_crawl("pds.example.com")
376 .await
377 .expect_err("a 403 is a refusal");
378 match err {
379 RelayCrawlError::Refused { status, body, .. } => {
380 assert_eq!(status, 403);
381 assert!(body.contains("HostBanned"), "body was {body}");
382 }
383 other => panic!("expected Refused, got {other:?}"),
384 }
385 }
386
387 /// A response body that declares no length and runs well past the bound
388 /// a relay's answer is read against.
389 struct Oversized {
390 remaining: usize,
391 }
392
393 impl futures_core::Stream for Oversized {
394 type Item = Result<bytes::Bytes, std::convert::Infallible>;
395
396 fn poll_next(
397 mut self: std::pin::Pin<&mut Self>,
398 _cx: &mut std::task::Context<'_>,
399 ) -> std::task::Poll<Option<Self::Item>> {
400 const CHUNK: usize = 64 * 1024;
401 if self.remaining == 0 {
402 return std::task::Poll::Ready(None);
403 }
404 let take = self.remaining.min(CHUNK);
405 self.remaining -= take;
406 std::task::Poll::Ready(Some(Ok(bytes::Bytes::from(vec![b'x'; take]))))
407 }
408 }
409
410 /// The refusal is still reported, and the page it came with is not held
411 /// in memory to report it. A relay is a server nobody here operates, and
412 /// the only bound on a `text()` of its error body is how long the request
413 /// may take.
414 #[tokio::test]
415 async fn a_refusal_with_an_outsized_body_is_reported_without_reading_it() {
416 let app = Router::new().route(
417 &format!("/xrpc/{REQUEST_CRAWL_NSID}"),
418 post(|| async {
419 (
420 StatusCode::SERVICE_UNAVAILABLE,
421 axum::body::Body::from_stream(Oversized {
422 remaining: didbot_http::MAX_JSON_BODY * 2,
423 }),
424 )
425 }),
426 );
427 let listener = TcpListener::bind("127.0.0.1:0")
428 .await
429 .expect("loopback binds");
430 let addr = listener.local_addr().expect("bound listener has an addr");
431 tokio::spawn(async move {
432 axum::serve(listener, app).await.expect("fake relay serves");
433 });
434
435 let client = RelayClient::new(format!("http://{addr}"));
436 let err = client
437 .request_crawl("pds.example.com")
438 .await
439 .expect_err("a 503 is a refusal");
440 match err {
441 RelayCrawlError::Refused { status, body, .. } => {
442 assert_eq!(status, 503);
443 assert!(
444 !body.contains("xxxx"),
445 "a body past the bound was read anyway: {body}"
446 );
447 assert!(body.contains("exceeds"), "body was {body}");
448 }
449 other => panic!("expected Refused, got {other:?}"),
450 }
451 }
452
453 #[tokio::test]
454 async fn an_unreachable_relay_is_reported_rather_than_hanging_or_panicking() {
455 // A bound-then-dropped listener's port has nothing behind it, so the
456 // connection is refused immediately rather than this test waiting
457 // out the real timeout.
458 let listener = TcpListener::bind("127.0.0.1:0")
459 .await
460 .expect("loopback binds");
461 let addr = listener.local_addr().expect("bound listener has an addr");
462 drop(listener);
463
464 let client = RelayClient::new(format!("http://{addr}"));
465 let err = client
466 .request_crawl("pds.example.com")
467 .await
468 .expect_err("nothing is listening");
469 assert!(matches!(err, RelayCrawlError::Unreachable { .. }));
470 }
471
472 /// A relay that accepts the connection and never answers costs a call
473 /// exactly [`REQUEST_TIMEOUT`], then `Unreachable`.
474 #[tokio::test(start_paused = true)]
475 async fn a_relay_that_never_answers_is_unreachable_after_the_request_timeout() {
476 let addr = didbot_http::test_support::hung_server();
477 let client = RelayClient::new(format!("http://{addr}"));
478 let started = tokio::time::Instant::now();
479 let err =
480 tokio::time::timeout(REQUEST_TIMEOUT * 2, client.request_crawl("pds.example.com"))
481 .await
482 .expect("the client's own timeout fires first")
483 .expect_err("nothing ever answers");
484 assert!(
485 matches!(&err, RelayCrawlError::Unreachable { source, .. } if source.is_timeout()),
486 "{err:?}"
487 );
488 assert_eq!(started.elapsed(), REQUEST_TIMEOUT);
489 }
490
491 /// `notify_of_update` is a thin wrapper differing from `request_crawl`
492 /// only in which NSID it posts to — this checks it actually reaches the
493 /// deprecated path and not `requestCrawl`'s, and still reports success.
494 #[tokio::test]
495 async fn notify_of_update_reaches_its_own_nsid_and_is_reported_ok() {
496 let (base_url, received) = fake_relay((StatusCode::OK, serde_json::json!({}))).await;
497 let client = RelayClient::new(base_url);
498 client
499 .notify_of_update("pds.example.com")
500 .await
501 .expect("the fake relay answers success");
502 assert_eq!(
503 received.lock().expect("poisoned").as_slice(),
504 [serde_json::json!({"hostname": "pds.example.com"})]
505 );
506 }
507
508 /// A relay refusing `notifyOfUpdate` reports the same `Refused` shape
509 /// `request_crawl` does — both go through [`RelayClient::call`].
510 #[tokio::test]
511 async fn notify_of_update_refusal_is_reported_and_named() {
512 let (base_url, _received) = fake_relay((
513 StatusCode::FORBIDDEN,
514 serde_json::json!({"error": "HostBanned", "message": "no"}),
515 ))
516 .await;
517 let client = RelayClient::new(base_url);
518 let err = client
519 .notify_of_update("pds.example.com")
520 .await
521 .expect_err("a 403 is a refusal");
522 assert!(matches!(err, RelayCrawlError::Refused { status: 403, .. }));
523 }
524}