CLI tool for migrating PDS
1use atrium_api::{
2 agent::{
3 atp_agent::{store::MemorySessionStore, AtpAgent},
4 Agent,
5 },
6 app::bsky::actor::{get_preferences, put_preferences},
7 com::atproto::{
8 identity::sign_plc_operation,
9 server::{create_account, deactivate_account, get_service_auth},
10 sync::{get_blob, get_repo, list_blobs},
11 },
12 types::string::{Did, Handle, Nsid},
13};
14use atrium_common::resolver::Resolver;
15use atrium_crypto::keypair::{Did as _, Export, Secp256k1Keypair};
16use atrium_identity::{
17 did::{CommonDidResolver, CommonDidResolverConfig},
18 handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig, DnsTxtResolver},
19 identity_resolver::{IdentityResolver, IdentityResolverConfig},
20};
21use atrium_xrpc_client::reqwest::ReqwestClient;
22use hickory_resolver::TokioResolver;
23use std::{
24 io::{self, Write},
25 sync::Arc,
26};
27
28mod jwt;
29
30struct HickoryDnsTxtResolver {
31 resolver: TokioResolver,
32}
33
34impl Default for HickoryDnsTxtResolver {
35 fn default() -> Self {
36 Self {
37 resolver: TokioResolver::builder_tokio().unwrap().build(),
38 }
39 }
40}
41
42impl DnsTxtResolver for HickoryDnsTxtResolver {
43 async fn resolve(
44 &self,
45 query: &str,
46 ) -> core::result::Result<Vec<String>, Box<dyn std::error::Error + Send + Sync + 'static>> {
47 Ok(self
48 .resolver
49 .txt_lookup(query)
50 .await?
51 .iter()
52 .map(|txt| txt.to_string())
53 .collect())
54 }
55}
56
57fn readln(message: Option<impl Into<String>>) -> std::io::Result<Arc<str>> {
58 if let Some(message) = message {
59 print!("{}", message.into());
60 io::stdout().flush()?;
61 }
62 let mut buffer = String::new();
63 io::stdin().read_line(&mut buffer)?;
64 Ok(buffer.trim().into())
65}
66
67#[tokio::main]
68async fn main() {
69 println!("Please log in to your current PDS. Authenticated access is needed throughout the migration process");
70 let identifier = match readln(Some("Identifier (handle or did): ")) {
71 Ok(string) => string,
72 Err(err) => {
73 println!("Could not read username due to error: {err}");
74 return;
75 }
76 };
77 let password = match readln(Some("Password: ")) {
78 Ok(string) => string.trim().to_string(),
79 Err(err) => {
80 println!("Could not read password due to error: {err}");
81 return;
82 }
83 };
84
85 let identity_resolver = IdentityResolver::new(IdentityResolverConfig {
86 did_resolver: CommonDidResolver::new(CommonDidResolverConfig {
87 plc_directory_url: String::from("https://plc.directory"),
88 http_client: ReqwestClient::new("").into(),
89 }),
90 handle_resolver: AtprotoHandleResolver::new(AtprotoHandleResolverConfig {
91 dns_txt_resolver: HickoryDnsTxtResolver::default(),
92 http_client: ReqwestClient::new("").into(),
93 }),
94 });
95 let identity = match identity_resolver.resolve(identifier.as_ref()).await {
96 Ok(identity) => identity,
97 Err(err) => {
98 println!("Could not resolve identity from identifier {identifier} due to error: {err}");
99 return;
100 }
101 };
102
103 let current_agent = AtpAgent::new(
104 ReqwestClient::new(&identity.pds),
105 MemorySessionStore::default(),
106 );
107 if let Err(err) = current_agent.login(identifier, password).await {
108 println!(
109 "Failed to log in to your account on your current PDS at {} due to error: {err}",
110 &identity.pds
111 );
112 return;
113 };
114 println!("Log in at {} was successful!", &identity.pds);
115 println!();
116
117 // Create new account
118 let new_pds_url = match readln(Some(
119 "Please type in the URL of the PDS you want to migrate to: ",
120 )) {
121 Ok(string) => string,
122 Err(err) => {
123 println!("Could not read the URL of your new PDS due to error: {err}");
124 return;
125 }
126 };
127 println!("Creating an account on your new PDS ...");
128 let new_agent = AtpAgent::new(
129 ReqwestClient::new(&new_pds_url),
130 MemorySessionStore::default(),
131 );
132 println!("Now the details you want for your new account");
133 let email = match readln(Some("Email address: ")) {
134 Ok(string) => string,
135 Err(err) => {
136 println!("Could not read your email due to error: {err}");
137 return;
138 }
139 };
140 let handle = match Handle::new(
141 match readln(Some("Handle: ")) {
142 Ok(string) => string,
143 Err(err) => {
144 println!("Could not read your handle due to error: {err}");
145 return;
146 }
147 }
148 .to_string(),
149 ) {
150 Ok(handle) => handle,
151 Err(err) => {
152 println!("Handle wasn't accepted because: {err}");
153 return;
154 }
155 };
156 let password = match readln(Some(
157 "Please type in the password you want to use on your new PDS: ",
158 )) {
159 Ok(string) => string,
160 Err(err) => {
161 println!("Could not read your password due to error: {err}");
162 return;
163 }
164 };
165 let invite_code = match readln(Some(
166 "Invite code (leave empty if your new PDS doesn't require one): ",
167 )) {
168 Ok(string) => {
169 if string.is_empty() {
170 None
171 } else {
172 Some(string.to_string())
173 }
174 }
175 Err(err) => {
176 println!("Could not read your invite code due to error: {err}");
177 return;
178 }
179 };
180
181 let describe_res = match new_agent.api.com.atproto.server.describe_server().await {
182 Ok(response) => response,
183 Err(err) => {
184 println!("com.atproto.server.describeServer at new PDS failed due to error: {err}");
185 return;
186 }
187 };
188 let new_pds_did = &describe_res.did;
189 let service_jwt_res = match current_agent
190 .api
191 .com
192 .atproto
193 .server
194 .get_service_auth(
195 get_service_auth::ParametersData {
196 aud: new_pds_did.clone(),
197 lxm: Some(Nsid::new(create_account::NSID.to_string()).unwrap()),
198 exp: None,
199 }
200 .into(),
201 )
202 .await
203 {
204 Ok(response) => response,
205 Err(err) => {
206 println!("com.atproto.server.getServiceAuth at current PDS failed due to error: {err}");
207 return;
208 }
209 };
210
211 let new_jwt_agent = Agent::new(jwt::JwtSessionManager::new(
212 Did::new(identity.did.clone()).unwrap(),
213 service_jwt_res.token.clone(),
214 &new_pds_url,
215 ));
216 match new_jwt_agent
217 .api
218 .com
219 .atproto
220 .server
221 .create_account(
222 create_account::InputData {
223 did: current_agent.did().await,
224 email: Some(email.to_string()),
225 handle: handle.clone(),
226 invite_code,
227 password: Some(password.to_string()),
228 plc_op: None,
229 recovery_key: None,
230 verification_code: None,
231 verification_phone: None,
232 }
233 .into(),
234 )
235 .await
236 {
237 Ok(_) => (),
238 Err(err) => {
239 println!("com.atproto.server.createAccount at new PDS failed due to error: {err}");
240 return;
241 }
242 }
243 if let Err(err) = new_agent.login(handle.clone(), password).await {
244 println!("Failed to log in to your account on your new PDS due to error: {err}");
245 return;
246 };
247 println!("Successfully created account on your new PDS!");
248 println!();
249
250 // Migrate data
251 println!("Migrating your data");
252
253 let car = match current_agent
254 .api
255 .com
256 .atproto
257 .sync
258 .get_repo(
259 get_repo::ParametersData {
260 did: current_agent.did().await.unwrap(),
261 since: None,
262 }
263 .into(),
264 )
265 .await
266 {
267 Ok(response) => response,
268 Err(err) => {
269 println!("com.atproto.sync.getRepo at current PDS failed due to error: {err}");
270 return;
271 }
272 };
273 println!("Repository downloaded from old PDS. Importing to new PDS.");
274
275 match new_agent.api.com.atproto.repo.import_repo(car).await {
276 Ok(_) => (),
277 Err(err) => {
278 println!("com.atproto.repo.importRepo at new PDS failed due to error: {err}");
279 return;
280 }
281 }
282 println!("Repository successfully migrated");
283
284 let mut listed_blobs = match current_agent
285 .api
286 .com
287 .atproto
288 .sync
289 .list_blobs(
290 list_blobs::ParametersData {
291 cursor: None,
292 did: current_agent.did().await.unwrap(),
293 limit: None,
294 since: None,
295 }
296 .into(),
297 )
298 .await
299 {
300 Ok(response) => response,
301 Err(err) => {
302 println!("com.atproto.sync.listBlobs at old PDS failed due to error: {err}");
303 return;
304 }
305 };
306
307 for cid in listed_blobs.cids.iter() {
308 let blob = match current_agent
309 .api
310 .com
311 .atproto
312 .sync
313 .get_blob(
314 get_blob::ParametersData {
315 cid: cid.to_owned(),
316 did: current_agent.did().await.unwrap(),
317 }
318 .into(),
319 )
320 .await
321 {
322 Ok(response) => response,
323 Err(err) => {
324 println!("com.atproto.sync.getBlob at current PDS failed due to error: {err}");
325 return;
326 }
327 };
328
329 match new_agent.api.com.atproto.repo.upload_blob(blob).await {
330 Ok(_) => {
331 println!("Blob with CID {:?} migrated", cid)
332 }
333 Err(err) => {
334 println!("com.atproto.repo.uploadBlob at new PDS failed due to error: {err}");
335 return;
336 }
337 };
338 }
339
340 let mut cursor = listed_blobs.cursor.clone();
341 while cursor.is_some() {
342 listed_blobs = match current_agent
343 .api
344 .com
345 .atproto
346 .sync
347 .list_blobs(
348 list_blobs::ParametersData {
349 cursor: cursor.clone(),
350 did: current_agent.did().await.unwrap(),
351 limit: None,
352 since: None,
353 }
354 .into(),
355 )
356 .await
357 {
358 Ok(response) => response,
359 Err(err) => {
360 println!("com.atproto.sync.listBlobs at old PDS failed due to error: {err}");
361 return;
362 }
363 };
364
365 for cid in listed_blobs.cids.iter() {
366 let blob = match current_agent
367 .api
368 .com
369 .atproto
370 .sync
371 .get_blob(
372 get_blob::ParametersData {
373 cid: cid.to_owned(),
374 did: current_agent.did().await.unwrap(),
375 }
376 .into(),
377 )
378 .await
379 {
380 Ok(response) => response,
381 Err(err) => {
382 println!("com.atproto.sync.getBlob at current PDS failed due to error: {err}");
383 return;
384 }
385 };
386
387 match new_agent.api.com.atproto.repo.upload_blob(blob).await {
388 Ok(_) => {
389 println!("Blob with CID {:?} migrated", cid)
390 }
391 Err(err) => {
392 println!("com.atproto.repo.uploadBlob at new PDS failed due to error: {err}");
393 return;
394 }
395 };
396 }
397 cursor = listed_blobs.cursor.clone();
398 }
399 println!("Blobs successfully migrated!");
400
401 let prefs = match current_agent
402 .api
403 .app
404 .bsky
405 .actor
406 .get_preferences(get_preferences::ParametersData {}.into())
407 .await
408 {
409 Ok(response) => response,
410 Err(err) => {
411 println!("app.bsky.actor.getPreferences at current PDS failed due to error: {err}");
412 return;
413 }
414 };
415
416 match new_agent
417 .api
418 .app
419 .bsky
420 .actor
421 .put_preferences(
422 put_preferences::InputData {
423 preferences: prefs.preferences.clone(),
424 }
425 .into(),
426 )
427 .await
428 {
429 Ok(_) => (),
430 Err(err) => {
431 println!("app.bsky.actor.putPreferences at new PDS failed due to error: {err}");
432 return;
433 }
434 }
435 println!("Preferences successfully migrated!");
436
437 // Update identity
438 println!("Migrating you identity (DID document) ...");
439
440 let pds_credentials = match new_agent
441 .api
442 .com
443 .atproto
444 .identity
445 .get_recommended_did_credentials()
446 .await
447 {
448 Ok(response) => response,
449 Err(err) => {
450 println!("com.atproto.identity.getRecommendedDidCredentials at new PDS failed due to error: {err}");
451 return;
452 }
453 };
454
455 match Did::new(identity.did.clone()).unwrap().method() {
456 "plc" => {
457 println!(
458 "did:plc detected! Creating a recovery key and updating your DID document ..."
459 );
460 let recovery_keypair = Secp256k1Keypair::create(&mut rand::thread_rng());
461 let private_key = hex::encode(recovery_keypair.export());
462 let mut recovery_keys = vec![recovery_keypair.did()];
463 if let Some(keys) = pds_credentials.rotation_keys.clone() {
464 recovery_keys.extend(keys);
465 }
466
467 println!("PLC operations are potentially destructive therefore you will need to complete an email challenge with your current PDS");
468 if let Err(err) = current_agent
469 .api
470 .com
471 .atproto
472 .identity
473 .request_plc_operation_signature()
474 .await
475 {
476 println!("com.atproto.identity.requestPlcOperationSignature at current PDS failed due to error: {err}")
477 };
478 let challenge_token = match readln(Some(
479 "Challenge email sent. Please provide the token you where sent over email here",
480 )) {
481 Ok(token) => token,
482 Err(err) => {
483 println!("Could not read token due to error: {err}");
484 return;
485 }
486 };
487 println!("Your private recovery key is {private_key}. Please store this in a secure location!!");
488 if let Err(err) = readln(Some("Press enter once you've saved the key securely")) {
489 println!("Could not handle enter due to error: {err}");
490 return;
491 }
492
493 match current_agent
494 .api
495 .com
496 .atproto
497 .identity
498 .sign_plc_operation(
499 sign_plc_operation::InputData {
500 also_known_as: pds_credentials.also_known_as.clone(),
501 rotation_keys: Some(recovery_keys),
502 services: pds_credentials.services.clone(),
503 token: Some(challenge_token.to_string()),
504 verification_methods: pds_credentials.verification_methods.clone(),
505 }
506 .into(),
507 )
508 .await
509 {
510 Ok(response) => response,
511 Err(err) => {
512 println!("com.atproto.identity.signPlcOperation at current PDS failed due to error: {err}");
513 return;
514 }
515 };
516 println!("DID document successfully updated!");
517 }
518 "web" => {
519 let did = identity.did;
520 println!("did:web detected! Please manually update your DID document to match these values: {pds_credentials:#?}");
521 if let Err(err) = readln(Some("Press enter once you've updated your DID document")) {
522 println!("Could not handle enter due to error: {err}");
523 return;
524 }
525 let mut valid_document = match identity_resolver.resolve(did.as_str()).await {
526 Ok(response) => response.pds == new_pds_url.to_string(),
527 Err(err) => {
528 println!("Couldn't resolve DID {did} due to error: {err}");
529 return;
530 }
531 };
532
533 while !valid_document {
534 println!("DID document not updated or updated incorretly! Needed PDS configuration: {new_pds_url}");
535 if let Err(err) = readln(Some("Press enter once you've updated your DID document"))
536 {
537 println!("Could not handle enter due to error: {err}");
538 return;
539 }
540 valid_document = match identity_resolver.resolve(did.as_str()).await {
541 Ok(response) => response.pds == new_pds_url.to_string(),
542 Err(err) => {
543 println!("Couldn't resolve DID {did} due to error: {err}");
544 return;
545 }
546 };
547 }
548 }
549 _ => {
550 println!("Unknown and invalid DID method found. This should not be possible!");
551 return;
552 }
553 }
554 println!("Identity migrated successfully!");
555
556 // Finalise migration
557 if let Err(err) = new_agent.api.com.atproto.server.activate_account().await {
558 println!("com.atproto.server.activateAccount at new PDS failed due to error: {err}")
559 };
560 if let Err(err) = current_agent
561 .api
562 .com
563 .atproto
564 .server
565 .deactivate_account(deactivate_account::InputData { delete_after: None }.into())
566 .await
567 {
568 println!("com.atproto.server.activateAccount at current PDS failed due to error: {err}")
569 };
570
571 println!("The account migration was successful!");
572 println!("The account on your old PDS has been deactivated. Please make sure everything works before fully deleting it in case you need to go back");
573}