Skip to main content

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}