Skip to main content

didbot_serve/routes/
sync.rs

1use super::*;
2
3/// The slot a never-ending stream holds for as long as it is open, or `None`
4/// when this deployment is already holding as many as it will.
5///
6/// Keyed on the caller the per-address rate limiters are keyed on, so one
7/// caller cannot spend the whole total by itself; see
8/// [`crate::subscribers::SubscriberLimit`].
9pub(crate) fn subscriber_slot(
10    state: &AppState,
11    headers: &HeaderMap,
12    peer: Option<std::net::IpAddr>,
13    route: &str,
14) -> Option<crate::subscribers::SubscriberSlot> {
15    let key = caller_key(headers, peer, state.trust_forwarded_headers);
16    let slot = state.subscriber_limit.take(&key);
17    if slot.is_none() {
18        info!(
19            route,
20            held = state.subscriber_limit.held(),
21            "refusing a subscription: this server is already holding as many streams as it will"
22        );
23    }
24    slot
25}
26
27/// What a caller past the subscriber bound is told.
28///
29/// A retryable refusal rather than a silent hold: a consumer that cannot
30/// tell a full server from a quiet one backs off on neither.
31pub(crate) fn too_many_subscribers(route: &str) -> Response {
32    ApiError::rate_limited(format!(
33        "this server is already holding as many streams as it will; retry {route} shortly"
34    ))
35    .into_response()
36}
37
38/// `GET /xrpc/com.atproto.sync.getRepo`
39///
40/// The whole repository as a CARv1 file: a signed commit over a Merkle search
41/// tree over every record. This is the only answer this server gives that a
42/// reader does not have to take on trust — resolve the DID, take the key out
43/// of the document, check the signature.
44///
45/// `since` is a revision the caller last synced at, and the answer is then the
46/// blocks it does not already hold — the same commit at the root either way.
47/// A revision this deployment cannot place, because it never minted it or has
48/// since trimmed it out of that repository's trail, is answered with the whole
49/// repository rather than with an error: the caller asked what it is missing,
50/// and all of it is true. See `didbot_pds::history`.
51///
52/// It is answered with the whole repository *and said so*, in [`SINCE`]. A
53/// full export and a delta are both a CAR rooted at the current commit, so a
54/// caller comparing bytes cannot tell one from the other, and one that cannot
55/// tell cannot notice it is paying for a full export on every reconnect. The
56/// lexicon declares no error for this and its output encoding is a CAR, so a
57/// header is the only place the fact fits without this server answering
58/// something `com.atproto.sync.getRepo` does not define.
59pub(crate) async fn get_repo(
60    State(state): State<AppState>,
61    peer: crate::rate_limit::Peer,
62    headers: axum::http::HeaderMap,
63    query: Result<Query<GetRepoQuery>, QueryRejection>,
64) -> Response {
65    if let Err(refusal) = require_sync_budget(&state, &headers, peer) {
66        return refusal.into_response();
67    }
68    let Query(query) = match query {
69        Ok(query) => query,
70        Err(rejection) => return ApiError::bad_request(rejection.body_text()).into_response(),
71    };
72    let export = match sync_read(&state, "com.atproto.sync.getRepo", move |registry| {
73        registry.export_repo(&query.did, query.since.as_deref())
74    })
75    .await
76    {
77        Ok(export) => export,
78        Err(response) => return response,
79    };
80    match since_header(&export.delta) {
81        Some(value) => ([CAR, (SINCE, value)], export.car).into_response(),
82        None => ([CAR], export.car).into_response(),
83    }
84}
85
86/// What a `getRepo` answer says about the `since` it was asked for.
87///
88/// Absent when nothing was asked, `Diff` when the delta was answered, and
89/// `OutdatedCursor` when it could not be and the whole repository was sent
90/// instead. The two values are the firehose's own vocabulary — see
91/// `didbot_serve::subscribe::OUTDATED_CURSOR` — because a cursor this server
92/// can no longer place is the same condition on either endpoint, and a
93/// consumer that already switches on one name should not have to learn a
94/// second.
95pub(crate) const SINCE: header::HeaderName = header::HeaderName::from_static("x-didbot-since");
96
97/// The [`SINCE`] value for one answer, or `None` when no `since` was asked.
98pub(crate) fn since_header(delta: &Delta) -> Option<&'static str> {
99    match delta {
100        Delta::Whole => None,
101        Delta::Since(_) => Some("Diff"),
102        Delta::OutdatedCursor(_) => Some(crate::subscribe::OUTDATED_CURSOR),
103    }
104}
105
106/// The content type a repository, a proof or a bag of blocks goes out as.
107///
108/// A reader that dispatches on it — `goat repo ls` does not, but a browser
109/// does — must not be told this is JSON.
110pub(crate) const CAR: (header::HeaderName, &str) =
111    (header::CONTENT_TYPE, "application/vnd.ipld.car");
112
113/// Maps a failure onto the error name `com.atproto.sync.*` declares for it.
114///
115/// The names in those lexicons are the ones a generated client switches on,
116/// and they are not this server's own: a repository nobody minted is
117/// `RepoNotFound` here even though every other route calls the same condition
118/// `UnknownAccount`. The status codes stay this server's — 404 for a
119/// repository that is not there, 400 for a CID that names nothing — because
120/// the name is the normative half and a status that says the truth is better
121/// than one chosen to match a framework's default.
122pub(crate) fn sync_error(state: &AppState, err: &ProvisionError) -> ApiError {
123    match err {
124        ProvisionError::UnknownAccount { did } => repo_not_served(state, did),
125        ProvisionError::BlockNotFound { .. } => {
126            ApiError::new(StatusCode::BAD_REQUEST, "BlockNotFound", err.to_string())
127        }
128        ProvisionError::Blob(_) => crate::blobs::blob_error(err),
129        other => ApiError::from(other),
130    }
131}
132
133/// Why a `com.atproto.sync.*` route serves no repository for `did`.
134///
135/// Read through [`didbot_pds::HostedAccount::sync_status`], so a route names
136/// only a status `subscribeRepos` already announced. `deleted` has no error
137/// name in these lexicons, and a reserved or provisioning account has no
138/// announced status, so both are `RepoNotFound`. A suspended or deactivated
139/// repository is a 403: it is here, and withheld.
140pub(crate) fn repo_not_served(state: &AppState, did: &str) -> ApiError {
141    let status = state
142        .registry
143        .account(did)
144        .and_then(|account| account.sync_status())
145        .and_then(|wire| wire.status);
146    match status {
147        Some(didbot_pds::AccountStatus::Suspended) => ApiError::new(
148            StatusCode::FORBIDDEN,
149            "RepoSuspended",
150            format!("the repository for `{did}` is suspended"),
151        ),
152        Some(didbot_pds::AccountStatus::Deactivated) => ApiError::new(
153            StatusCode::FORBIDDEN,
154            "RepoDeactivated",
155            format!("the repository for `{did}` is deactivated"),
156        ),
157        Some(didbot_pds::AccountStatus::Deleted) | None => ApiError::new(
158            StatusCode::NOT_FOUND,
159            "RepoNotFound",
160            format!("no repository here for `{did}`"),
161        ),
162    }
163}
164
165/// `GET /xrpc/com.atproto.sync.getLatestCommit`
166///
167/// The commit CID and revision the repository is currently at, which is what
168/// a consumer compares its own position against. It costs what an export
169/// costs, because a derived repository has to be rebuilt to be named;
170/// what it saves the caller is the blocks.
171pub(crate) async fn get_latest_commit(
172    State(state): State<AppState>,
173    peer: crate::rate_limit::Peer,
174    headers: axum::http::HeaderMap,
175    query: Result<Query<DidRequest>, QueryRejection>,
176) -> Response {
177    if let Err(refusal) = require_sync_budget(&state, &headers, peer) {
178        return refusal.into_response();
179    }
180    let Query(query) = match query {
181        Ok(query) => query,
182        Err(rejection) => return ApiError::bad_request(rejection.body_text()).into_response(),
183    };
184    match sync_read(
185        &state,
186        "com.atproto.sync.getLatestCommit",
187        move |registry| registry.latest_commit(&query.did),
188    )
189    .await
190    {
191        Ok(head) => Json(json!({ "cid": head.head, "rev": head.rev })).into_response(),
192        Err(response) => response,
193    }
194}
195
196/// `GET /xrpc/com.atproto.sync.getRepoStatus`
197///
198/// What `com.atproto.sync.subscribeRepos`'s `#account` says about one
199/// account, on request, with the `rev` a caller compares a relay's copy
200/// against. The `active` and `status` pair is read through
201/// [`didbot_pds::HostedAccount::sync_status`], the mapping the stream
202/// announces from, so this route and the next frame say the same thing. A
203/// relay holding an older frame is brought level by the announcement a boot
204/// makes of every position that changed — see
205/// [`didbot_pds::Provisioner::announce_accounts`]. An
206/// account that mapping
207/// announces nothing for — reserved, or still provisioning — is
208/// `RepoNotFound` here for the same reason it is silent there: a relay was
209/// never told it exists, and this route must not be the first to say so.
210pub(crate) async fn get_repo_status(
211    State(state): State<AppState>,
212    peer: crate::rate_limit::Peer,
213    headers: axum::http::HeaderMap,
214    query: Result<Query<DidRequest>, QueryRejection>,
215) -> Response {
216    if let Err(refusal) = require_sync_budget(&state, &headers, peer) {
217        return refusal.into_response();
218    }
219    let Query(query) = match query {
220        Ok(query) => query,
221        Err(rejection) => return ApiError::bad_request(rejection.body_text()).into_response(),
222    };
223    let unknown = || ProvisionError::UnknownAccount {
224        did: query.did.clone(),
225    };
226    let Some(account) = state.registry.account(&query.did) else {
227        return sync_error(&state, &unknown()).into_response();
228    };
229    let Some(status) = account.sync_status() else {
230        return sync_error(&state, &unknown()).into_response();
231    };
232    let mut body = json!({ "did": account.did.as_str(), "active": status.active });
233    if let Some(reason) = status.status {
234        body["status"] = json!(reason.as_str());
235    }
236    if status.active {
237        let did = account.did.as_str().to_owned();
238        match sync_read(&state, "com.atproto.sync.getRepoStatus", move |registry| {
239            registry.latest_commit(&did)
240        })
241        .await
242        {
243            Ok(head) => body["rev"] = json!(head.rev),
244            Err(response) => return response,
245        }
246    }
247    debug!(did = %account.did, active = status.active, status = ?status.status, "reported a repository's status");
248    Json(body).into_response()
249}
250
251/// `GET /xrpc/com.atproto.sync.getBlocks`
252///
253/// Named blocks and nothing else, in a CAR file that declares no root:
254/// the caller asked for blocks by name rather than for something to walk, so
255/// there is nothing to start walking from. A CID this repository does not
256/// hold refuses the whole request rather than returning a short file, because
257/// a caller cannot tell a block that was left out from one it never asked for.
258pub(crate) async fn get_blocks(
259    State(state): State<AppState>,
260    peer: crate::rate_limit::Peer,
261    headers: axum::http::HeaderMap,
262    RawQuery(query): RawQuery,
263) -> Response {
264    if let Err(refusal) = require_sync_budget(&state, &headers, peer) {
265        return refusal.into_response();
266    }
267    let query = query.unwrap_or_default();
268    let Some(did) = repeated(&query, "did").into_iter().next() else {
269        return ApiError::bad_request("getBlocks needs a `did`").into_response();
270    };
271    let cids = repeated(&query, "cids");
272    if cids.is_empty() {
273        return ApiError::bad_request("getBlocks needs at least one `cids`").into_response();
274    }
275    match sync_read(&state, "com.atproto.sync.getBlocks", move |registry| {
276        registry.get_blocks(&did, &cids)
277    })
278    .await
279    {
280        Ok(car) => ([CAR], car).into_response(),
281        Err(response) => response,
282    }
283}
284
285/// `GET /xrpc/com.atproto.sync.getRecord`
286///
287/// The blocks that prove what this repository says about one record: the
288/// signed commit, the Merkle search tree nodes from its root down to where
289/// the record's key sits, and the record itself. A verifier checks the
290/// signature with the key in the account's DID document, then walks the nodes
291/// by hashing each one and following the link the previous one named.
292///
293/// # A record that is not there is a 200
294///
295/// The lexicon declares `RecordNotFound` and this never returns it. The
296/// method's own description is that it proves "the existence or non-existence
297/// of a record", and an error body cannot prove anything: the walk that ends
298/// at the node the key would have to be in, and does not find it, is the
299/// proof that it is not in the repository. So both answers are the same CAR
300/// file with the same root, and the difference between them is whether the
301/// last node names the key. That is what the reference implementation does
302/// too.
303pub(crate) async fn sync_get_record(
304    State(state): State<AppState>,
305    peer: crate::rate_limit::Peer,
306    headers: axum::http::HeaderMap,
307    query: Result<Query<SyncGetRecordQuery>, QueryRejection>,
308) -> Response {
309    if let Err(refusal) = require_sync_budget(&state, &headers, peer) {
310        return refusal.into_response();
311    }
312    let Query(query) = match query {
313        Ok(query) => query,
314        Err(rejection) => return ApiError::bad_request(rejection.body_text()).into_response(),
315    };
316    match sync_read(&state, "com.atproto.sync.getRecord", move |registry| {
317        registry.record_proof(&query.did, &query.collection, &query.rkey)
318    })
319    .await
320    {
321        Ok(car) => ([CAR], car).into_response(),
322        Err(response) => response,
323    }
324}
325
326/// `GET /xrpc/com.atproto.sync.listRepos`
327///
328/// Every repository this deployment hosts, in DID order, with the commit each
329/// one is at. The cursor is the last DID of the page: a position in a total
330/// order rather than an offset into a list that moves under the caller.
331pub(crate) async fn list_repos(
332    State(state): State<AppState>,
333    peer: crate::rate_limit::Peer,
334    headers: axum::http::HeaderMap,
335    query: Result<Query<ListReposQuery>, QueryRejection>,
336) -> Response {
337    if let Err(refusal) = require_sync_budget(&state, &headers, peer) {
338        return refusal.into_response();
339    }
340    // This route names every account this deployment holds, so it is the
341    // same decision `bot.did.listAccounts` is; see `auth::Disclosure`.
342    if let Err(err) =
343        auth::require_disclosure(state.disclosure.list_accounts, "com.atproto.sync.listRepos")
344    {
345        return err.into_response();
346    }
347    let Query(query) = match query {
348        Ok(query) => query,
349        Err(rejection) => return ApiError::bad_request(rejection.body_text()).into_response(),
350    };
351    let (repos, cursor) = match sync_read(&state, "com.atproto.sync.listRepos", move |registry| {
352        registry.list_repos(query.effective_limit(), query.cursor.as_deref())
353    })
354    .await
355    {
356        Ok(page) => page,
357        Err(response) => return response,
358    };
359    match cursor {
360        Some(cursor) => Json(json!({ "repos": repos, "cursor": cursor })).into_response(),
361        None => Json(json!({ "repos": repos })).into_response(),
362    }
363}
364
365/// `GET /xrpc/com.atproto.sync.subscribeRepos`
366///
367/// `Credential::Open`: the lexicon says "public and does not require auth",
368/// and a relay that had to authenticate could not crawl this server.
369///
370/// A WebSocket of framed DAG-CBOR, one `#commit` per commit this server makes,
371/// resumable from a `cursor`. This is the method a relay crawls — see
372/// [`crate::subscribe`].
373///
374/// The cursor is read here and the connection is opened either way: an error
375/// the lexicon declares is a frame on the socket followed by a close, not an
376/// HTTP status, because a client that asked for an upgrade is not reading for
377/// one.
378pub(crate) async fn subscribe_repos(
379    State(state): State<AppState>,
380    peer: Peer,
381    headers: HeaderMap,
382    upgrade: WebSocketUpgrade,
383    Query(params): Query<SubscribeParams>,
384) -> Response {
385    // Refused *before* the upgrade, deliberately, and this is the choice
386    // `plan/onboarding.md` asks to be made explicitly rather than fallen
387    // into. The alternative -- accept the upgrade and hold the connection
388    // open with no frames on it -- is indistinguishable, from a relay's
389    // side, from a healthy server with nothing to say: relays back off on a
390    // failed connect and do not back off on silence, so holding would
391    // produce a subscription that looks live, indexes nothing, and gives
392    // its operator no signal at all. Refusing pre-upgrade keeps the answer
393    // on the HTTP exchange, where a status code and a `Retry-After` can
394    // carry it.
395    if let Err(err) = require_lifecycle(
396        &state,
397        |p| p.serves_reads,
398        "com.atproto.sync.subscribeRepos",
399    ) {
400        return err.into_response();
401    }
402    // Refused pre-upgrade for the same reason, and under the same decision
403    // `bot.did.listAccounts` is: this stream announces each account as it
404    // appears and as it writes. See `auth::Disclosure`.
405    if let Err(err) = auth::require_disclosure(
406        state.disclosure.list_accounts,
407        "com.atproto.sync.subscribeRepos",
408    ) {
409        return err.into_response();
410    }
411    let Some(slot) = subscriber_slot(&state, &headers, peer.0, "com.atproto.sync.subscribeRepos")
412    else {
413        return too_many_subscribers("com.atproto.sync.subscribeRepos");
414    };
415    let cursor = params.cursor();
416    let exclude = params.exclusions();
417    let upgrade = upgrade
418        .max_message_size(MAX_CONSUMER_MESSAGE)
419        .max_frame_size(MAX_CONSUMER_MESSAGE);
420    // The slot rides into the pump, so it is given back when the socket
421    // closes rather than when this handler returns.
422    upgrade.on_upgrade(move |socket| pump_repos(socket, state.repos, cursor, exclude, slot))
423}
424
425/// The largest message, and the largest frame, a `subscribeRepos` consumer
426/// may send.
427///
428/// A consumer sends nothing this server reads: the incoming half is polled
429/// only to see the close. WebSocket control frames carry at most 125 bytes
430/// (RFC 6455 §5.5), and four kibibytes is thirty times that. axum's own
431/// defaults, 64 MiB a message and 16 MiB a frame, are what one consumer could
432/// otherwise make this server buffer on each slot [`subscriber_slot`] gives
433/// it. A consumer that sends more is disconnected.
434pub(crate) const MAX_CONSUMER_MESSAGE: usize = 4 * 1024;
435
436/// What a `subscribeRepos` connection asked for.
437#[derive(Debug, Default, serde::Deserialize)]
438#[serde(default, rename_all = "camelCase")]
439pub(crate) struct SubscribeParams {
440    /// The last sequence number the consumer applied.
441    ///
442    /// A string rather than an integer so that a cursor which is not one is
443    /// read as no cursor at all rather than rejected before the upgrade. It
444    /// reaches this server as a query parameter a human may well have typed,
445    /// and answering a WebSocket handshake with a `400` would leave them with
446    /// an HTTP status where they wanted a stream.
447    cursor: Option<String>,
448    /// Drop `#commit` frames on this connection.
449    exclude_commit: Option<String>,
450    /// Drop `#identity` frames on this connection.
451    exclude_identity: Option<String>,
452    /// Drop `#account` frames on this connection.
453    exclude_account: Option<String>,
454}
455
456impl SubscribeParams {
457    /// The cursor as the lexicon's integer, or `None` if there is not one.
458    fn cursor(&self) -> Option<i64> {
459        let cursor = self
460            .cursor
461            .as_deref()
462            .map(str::trim)
463            .filter(|c| !c.is_empty())?;
464        match cursor.parse::<i64>() {
465            Ok(seq) => Some(seq),
466            Err(_) => {
467                info!(
468                    cursor,
469                    "a subscribeRepos cursor was not an integer; streaming from now"
470                );
471                None
472            }
473        }
474    }
475
476    /// Which message types this connection asked not to be sent.
477    fn exclusions(&self) -> crate::subscribe::Exclusions {
478        crate::subscribe::Exclusions {
479            commit: flag(self.exclude_commit.as_deref()),
480            identity: flag(self.exclude_identity.as_deref()),
481            account: flag(self.exclude_account.as_deref()),
482        }
483    }
484}
485
486/// Reads a boolean query parameter.
487///
488/// Present-and-not-false is true, so `?excludeIdentity` on its own works the
489/// way somebody typing it expects. Anything unrecognisable reads as false,
490/// which is the safe direction for a delivery filter: the consumer is sent
491/// more than it asked for rather than less.
492pub(crate) fn flag(value: Option<&str>) -> bool {
493    match value.map(str::trim) {
494        None => false,
495        Some("false" | "0") => false,
496        Some(_) => true,
497    }
498}
499
500/// Writes one consumer's frames onto its socket until either end stops.
501///
502/// Reads are drained and discarded: this stream carries nothing from the
503/// consumer, but a socket whose incoming half is never polled never sees the
504/// close frame that says the consumer has gone, so the task would outlive it.
505///
506/// The end of a connection that began is written at `info`, with the cursor
507/// it asked for, why it ended, how long it lasted and how many frames it was
508/// sent. A consumer closed for falling behind is written at `warn`.
509pub(crate) async fn pump_repos(
510    socket: WebSocket,
511    repos: Repos,
512    cursor: Option<i64>,
513    exclude: crate::subscribe::Exclusions,
514    // Held, not read: dropped when this function returns, which is what
515    // gives the subscription's slot back. See `subscriber_slot`.
516    _slot: crate::subscribers::SubscriberSlot,
517) {
518    let mut stream = match repos.subscribe(cursor, exclude) {
519        Ok(stream) => stream,
520        Err(frame) => {
521            let mut socket = socket;
522            let _ = socket.send(Message::Binary(frame.into())).await;
523            // `Message::Close` rather than `SinkExt::close`, so this crate
524            // does not take a dependency on `futures-util` for one method.
525            let _ = socket.send(Message::Close(None)).await;
526            return;
527        }
528    };
529    let mut socket = socket;
530    let began = std::time::Instant::now();
531    let mut sent: u64 = 0;
532    let mut stopping = false;
533    let reason = loop {
534        tokio::select! {
535            // Nothing is expected on this half, and it is polled anyway: a
536            // socket whose incoming frames are never read never sees the
537            // close that says the consumer has gone, so this task would
538            // outlive it.
539            incoming = socket.recv() => match incoming {
540                None => break "the consumer hung up".to_owned(),
541                // A message past `MAX_CONSUMER_MESSAGE` lands here, as does a
542                // broken socket.
543                Some(Err(error)) => break format!("the consumer's socket failed: {error}"),
544                Some(Ok(_)) => {}
545            },
546            frame = stream.next() => {
547                let Some(bytes) = frame else {
548                    break if stream.lagged() > 0 {
549                        "the consumer fell further behind than the server buffers".to_owned()
550                    } else {
551                        stopping = true;
552                        "the server is closing the stream".to_owned()
553                    };
554                };
555                if let Err(error) = socket.send(Message::Binary(bytes)).await {
556                    break format!("a frame could not be sent: {error}");
557                }
558                sent += 1;
559            }
560        }
561    };
562    let lasted_secs = began.elapsed().as_secs();
563    if stream.lagged() > 0 {
564        warn!(
565            ?cursor,
566            reason,
567            lasted_secs,
568            sent,
569            lagged = stream.lagged(),
570            "subscribeRepos consumer disconnected"
571        );
572    } else {
573        info!(
574            ?cursor,
575            reason, lasted_secs, sent, "subscribeRepos consumer disconnected"
576        );
577    }
578    if stream.lagged() > 0 {
579        // The one place this server closes a connection on purpose. The
580        // lexicon names the case and the consumer has to know it has a gap;
581        // see `ReposStream::next`.
582        let _ = socket
583            .send(Message::Binary(
584                crate::subscribe::encode_error(
585                    "ConsumerTooSlow",
586                    "this connection fell further behind than the server buffers; \
587                     reconnect with the last cursor you applied",
588                )
589                .into(),
590            ))
591            .await;
592    }
593    // A stop says so with 1001, "going away", rather than a bare close, so a
594    // relay reads it as the server leaving and not as an error in its own
595    // connection. It reconnects with the cursor it holds.
596    let close = stopping.then(|| CloseFrame {
597        code: close_code::AWAY,
598        reason: "the server is stopping".into(),
599    });
600    let _ = socket.send(Message::Close(close)).await;
601}