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}