forked from
baileytownsend.dev/pds-moover
Client side atproto account migrator in your web browser, along with services for backups and adversarial migrations.
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}