Client side atproto account migrator in your web browser, along with services for backups and adversarial migrations.
at next 228 lines 8.2 kB view raw
1use anyhow::{Context, anyhow}; 2use base64::Engine; 3use clap::{Parser, Subcommand}; 4use dotenvy::dotenv; 5use jacquard::url; 6use jacquard_common::xrpc::XrpcExt; 7use lexicon_types_crate::com_pdsmoover::admin::request_instance_backup::RequestInstanceBackup; 8use lexicon_types_crate::com_pdsmoover::admin::request_pds_backup::RequestPdsBackup; 9use lexicon_types_crate::com_pdsmoover::admin::request_repo_backup::RequestRepoBackup; 10use lexicon_types_crate::com_pdsmoover::admin::sign_up_pds::SignUpPds; 11use log; 12use reqwest::header; 13use reqwest::header::{HeaderMap, HeaderValue}; 14use std::env; 15 16fn init_logging() { 17 // Load .env if present 18 let _ = dotenv(); 19 20 // Initialize env_logger with default filter if RUST_LOG is not set 21 let env = env_logger::Env::default().filter_or("RUST_LOG", "info"); 22 let _ = env_logger::Builder::from_env(env).try_init(); 23} 24 25/// Admin CLI for pds_moover 26#[derive(Debug, Parser)] 27#[command(name = "admin_cli", version, about = "Administrative CLI for pds_moover", long_about = None)] 28struct Cli { 29 /// Admin password (optional). If not provided, the program will read the `admin_password` environment variable. 30 #[arg(short = 'p', long = "admin-password", global = true)] 31 admin_password: Option<String>, 32 33 /// PDS MOOver endpoint (optional). If not provided, the program will call the default endpoint at https://pdsmoover.com. 34 #[arg(short = 'm', long = "moover-host", global = true)] 35 pds_moover_host: Option<String>, 36 37 #[command(subcommand)] 38 command: Commands, 39} 40 41#[derive(Debug, Subcommand)] 42enum Commands { 43 /// PDS related administrative actions 44 Pds { 45 #[command(subcommand)] 46 action: PdsAction, 47 }, 48 /// Repo related administrative actions 49 Repo { 50 #[command(subcommand)] 51 action: RepoAction, 52 }, 53 /// Trigger an instance-wide backup job (no parameters) 54 RequestInstanceBackup, 55} 56 57#[derive(Debug, Subcommand)] 58enum PdsAction { 59 /// Sign up a PDS by hostname 60 Signup { 61 /// Hostname of the PDS to sign up 62 hostname: String, 63 }, 64 /// Request a backup for a PDS by hostname 65 RequestBackup { 66 /// Hostname of the PDS to back up 67 hostname: String, 68 }, 69 70 /// Remove a PDS by hostname (not yet implemented server-side) 71 Remove { 72 /// Hostname of the PDS to remove 73 hostname: String, 74 }, 75} 76 77#[derive(Debug, Subcommand)] 78enum RepoAction { 79 /// Request a backup for a specific repo DID 80 RequestBackup { 81 /// DID of the repo to back up 82 did: String, 83 }, 84} 85 86fn resolve_admin_password(opt: &Option<String>) -> anyhow::Result<String> { 87 if let Some(pw) = opt.as_ref() { 88 return Ok(pw.clone()); 89 } 90 match env::var("ADMIN_PASSWORD") { 91 Ok(val) if !val.is_empty() => Ok(val), 92 _ => Err(anyhow!( 93 "Admin password not provided. Pass --admin-password or set env var ADMIN_PASSWORD" 94 )), 95 } 96} 97 98fn build_basic_auth_header(admin_password: &str) -> HeaderValue { 99 // Build Basic base64("admin:<password>") per temporary spec 100 let creds = format!("admin:{}", admin_password); 101 let encoded = base64::engine::general_purpose::STANDARD.encode(creds.as_bytes()); 102 let value = format!("Basic {}", encoded); 103 // Safe unwrap: constructing from known ASCII 104 HeaderValue::from_str(&value).expect("valid basic auth header") 105} 106 107#[tokio::main] 108async fn main() -> anyhow::Result<()> { 109 init_logging(); 110 111 let cli = Cli::parse(); 112 let admin_password = 113 resolve_admin_password(&cli.admin_password).context("failed to resolve admin password")?; 114 115 let mut headers = HeaderMap::new(); 116 headers.insert( 117 header::AUTHORIZATION, 118 build_basic_auth_header(&admin_password), 119 ); 120 121 let http = reqwest::Client::builder() 122 .default_headers(headers) 123 .user_agent("PDS MOOver Admin cli/0.0.1") 124 .build()?; 125 126 let base = url::Url::parse( 127 cli.pds_moover_host 128 .as_deref() 129 // TODO: change this away from dev in prod 130 .unwrap_or("https://pdsmoover.com"), 131 )?; 132 133 match cli.command { 134 Commands::Pds { action } => match action { 135 PdsAction::Signup { hostname } => { 136 log::info!("Signing up PDS"); 137 138 let req = SignUpPds { 139 hostname: hostname.clone().into(), 140 extra_data: Default::default(), 141 }; 142 // Send the typed XRPC request 143 match http.xrpc(base.clone()).send(&req).await { 144 Ok(result) => { 145 if result.status().is_success() { 146 log::info!("Sign up request sent successfully for: {}", hostname); 147 } else { 148 let error = result.parse().unwrap_err(); 149 log::error!("Sign up request failed: {}", error); 150 } 151 } 152 Err(err) => { 153 log::error!("Sign up request failed: {}", err); 154 } 155 } 156 } 157 PdsAction::RequestBackup { hostname } => { 158 log::info!("Requesting PDS backup for host: {}", hostname); 159 let req = RequestPdsBackup { 160 hostname: hostname.clone().into(), 161 extra_data: Default::default(), 162 }; 163 164 match http.xrpc(base.clone()).send(&req).await { 165 Ok(result) => { 166 if result.status().is_success() { 167 log::info!( 168 "PDS backup request enqueued successfully for: {}", 169 hostname 170 ); 171 } else { 172 let error = result.parse().unwrap_err(); 173 log::error!("PDS backup request failed: {}", error); 174 } 175 } 176 Err(err) => { 177 log::error!("PDS backup request failed: {}", err); 178 } 179 } 180 } 181 PdsAction::Remove { hostname: _ } => { 182 log::info!("Removing PDS (not implemented yet)"); 183 // TODO: Implement call to backend API for removal when endpoint is available 184 } 185 }, 186 Commands::Repo { action } => match action { 187 RepoAction::RequestBackup { did } => { 188 log::info!("Requesting repo backup for DID: {}", did); 189 let req = RequestRepoBackup { 190 did: did.clone().into(), 191 extra_data: Default::default(), 192 }; 193 match http.xrpc(base).send(&req).await { 194 Ok(result) => { 195 if result.status().is_success() { 196 log::info!("Repo backup request enqueued successfully for: {}", did); 197 } else { 198 let error = result.parse().unwrap_err(); 199 log::error!("Repo backup request failed: {}", error); 200 } 201 } 202 Err(err) => { 203 log::error!("Repo backup request failed: {}", err); 204 } 205 } 206 } 207 }, 208 Commands::RequestInstanceBackup => { 209 log::info!("Requesting instance-wide backup start"); 210 let req = RequestInstanceBackup; 211 match http.xrpc(base.clone()).send(&req).await { 212 Ok(result) => { 213 if result.status().is_success() { 214 log::info!("Instance backup start enqueued successfully"); 215 } else { 216 let error = result.parse().unwrap_err(); 217 log::error!("Instance backup request failed: {}", error); 218 } 219 } 220 Err(err) => { 221 log::error!("Instance backup request failed: {}", err); 222 } 223 } 224 } 225 } 226 227 Ok(()) 228}