CLI tool for migrating PDS
at main 18 kB view raw
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}