mod cli;
mod config;
mod error;
mod platform;
mod progress;
mod proton_pass;
mod rclone;
mod ssh;
use anyhow::Result;
use clap::Parser;
use cli::Args;
use config::Config;
use error::ErrorCollector;
use proton_pass::ProtonPass;
use rclone::RcloneEntry;
use ssh::SshManager;
fn main() {
if let Err(e) = run() {
eprintln!("Error: {:#}", e);
std::process::exit(1);
}
}
fn run() -> Result<()> {
let args = Args::parse();
let mut errors = ErrorCollector::new();
let dry_run = args.dry_run;
let config_path = args.config.clone().unwrap_or_else(Config::default_path);
let mut config = Config::load_or_create(&args.config)?;
if let Some(ref output_dir) = args.output_dir {
config.ssh_output_dir = output_dir.to_string_lossy().to_string();
}
if let Some(sync_public_key) = args.sync_public_key {
config.sync_public_key = sync_public_key;
}
if let Some(ref password_path) = args.rclone_password_path {
config.rclone.password_path = password_path.clone();
}
if args.always_encrypt {
config.rclone.always_encrypt = true;
}
let do_ssh = !args.rclone; let do_rclone = !args.ssh && config.rclone.enabled;
let log = |msg: &str| {
if !args.quiet {
println!("{}", msg);
}
};
if config_path.exists() {
let missing = config::check_missing_options(&config_path);
if !missing.is_empty() && !args.quiet {
eprintln!(
"Warning: Your config is missing new options: {}",
missing.join(", ")
);
eprintln!(
" Consider regenerating with: rm {:?} && pass-ssh-unpack",
config_path
);
eprintln!();
}
}
if dry_run {
log("[DRY RUN] No changes will be made");
log("");
}
check_dependencies()?;
if args.purge {
return handle_purge(&config, dry_run, args.quiet, do_ssh, do_rclone);
}
if do_ssh {
log("Extracting SSH keys from Proton Pass...");
} else {
log("Syncing rclone remotes only...");
}
log("");
let current_hostname = platform::get_hostname();
let ssh_output_dir = config.expanded_ssh_output_dir();
let mut ssh_manager =
SshManager::new(&ssh_output_dir, args.full, dry_run, config.sync_public_key)?;
let proton_pass = ProtonPass::new();
let spinner = if !args.quiet {
Some(progress::spinner("Loading vaults..."))
} else {
None
};
let all_vaults = proton_pass.list_vaults()?;
if let Some(sp) = spinner {
sp.finish_and_clear();
}
let vault_patterns = if args.vault.is_empty() {
&config.default_vaults
} else {
&args.vault
};
let vaults_to_process = filter_by_patterns(&all_vaults, vault_patterns);
if vaults_to_process.is_empty() && !vault_patterns.is_empty() {
log("Warning: No vaults matched the specified patterns");
}
let item_patterns = if args.item.is_empty() {
&config.default_items
} else {
&args.item
};
let mut rclone_entries: Vec<RcloneEntry> = Vec::new();
if do_ssh {
let vault_pb = if !args.quiet && !vaults_to_process.is_empty() {
Some(progress::vault_progress_bar(vaults_to_process.len() as u64))
} else {
None
};
let pb_log = |msg: &str| {
if !args.quiet {
if let Some(ref pb) = vault_pb {
pb.println(msg);
} else {
println!("{}", msg);
}
}
};
for (i, vault) in vaults_to_process.iter().enumerate() {
pb_log(&format!("[{}]", vault));
let items = match proton_pass.list_ssh_keys(vault) {
Ok(items) => items,
Err(e) => {
errors.add(&format!("Failed to list SSH keys in vault '{}'", vault), e);
pb_log(" (error listing keys)");
pb_log("");
if let Some(ref pb) = vault_pb {
pb.set_position(i as u64 + 1);
}
continue;
}
};
if items.is_empty() {
pb_log(" (no SSH keys)");
pb_log("");
if let Some(ref pb) = vault_pb {
pb.set_position(i as u64 + 1);
}
continue;
}
for item in items {
if !matches_any_pattern(&item.title, item_patterns) {
continue;
}
if let Some(suffix) = item.title.split('/').next_back() {
if item.title.contains('/') {
let suffix_lower = suffix.to_lowercase();
if suffix_lower != current_hostname.to_lowercase() {
pb_log(&format!(
" Skipping: {} (not for this machine)",
item.title
));
continue;
}
}
}
pb_log(&format!(" Processing: {}", item.title));
match ssh_manager.process_item(&proton_pass, vault, &item, &pb_log) {
Ok(entry) => {
if let Some(rclone_entry) = entry {
rclone_entries.push(rclone_entry);
}
}
Err(e) => {
errors.add(&format!("Failed to process '{}'", item.title), e);
}
}
}
pb_log("");
if let Some(ref pb) = vault_pb {
pb.set_position(i as u64 + 1);
}
}
if let Some(pb) = vault_pb {
pb.finish_and_clear();
}
log("Generating SSH config...");
let (primary_count, alias_count) = ssh_manager.write_config()?;
log("");
log(&format!(
"Done! Generated config has {} hosts and {} aliases.",
primary_count, alias_count
));
log(&format!(
"SSH config written to: {}",
ssh_manager.config_path().display()
));
}
if do_rclone {
if let Err(e) =
rclone::sync_remotes(&rclone_entries, &config, args.full, dry_run, args.quiet)
{
errors.add("Rclone sync", e);
}
}
errors.report();
if errors.has_errors() {
std::process::exit(1);
}
Ok(())
}
fn check_dependencies() -> Result<()> {
use anyhow::bail;
if which::which("pass-cli").is_err() {
bail!("pass-cli not found. Install Proton Pass CLI first.");
}
let spinner = progress::spinner("Checking Proton Pass login...");
let output = std::process::Command::new("pass-cli")
.arg("info")
.output()?;
spinner.finish_and_clear();
if !output.status.success() {
eprintln!("Not logged into Proton Pass. Launching login...");
eprintln!();
let login_status = std::process::Command::new("pass-cli")
.arg("login")
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status()?;
if !login_status.success() {
bail!("Failed to login to Proton Pass. Please run 'pass-cli login' manually.");
}
eprintln!();
}
if which::which("ssh-keygen").is_err() {
bail!("ssh-keygen not found. Install OpenSSH first.");
}
Ok(())
}
fn handle_purge(
config: &Config,
dry_run: bool,
quiet: bool,
do_ssh: bool,
do_rclone: bool,
) -> Result<()> {
if !quiet {
println!("Purging managed resources...");
}
if do_ssh {
let ssh_dir = config.expanded_ssh_output_dir();
if ssh_dir.exists() {
if dry_run {
if !quiet {
println!(" Would remove {}", ssh_dir.display());
}
} else {
std::fs::remove_dir_all(&ssh_dir)?;
if !quiet {
println!(" Removed {}", ssh_dir.display());
}
}
} else if !quiet {
println!(" {} does not exist", ssh_dir.display());
}
}
if do_rclone {
rclone::purge_managed_remotes(config, dry_run, quiet)?;
}
if !quiet {
println!("Done.");
}
Ok(())
}
fn filter_by_patterns(items: &[String], patterns: &[String]) -> Vec<String> {
if patterns.is_empty() {
return items.to_vec();
}
items
.iter()
.filter(|item| matches_any_pattern(item, patterns))
.cloned()
.collect()
}
fn matches_any_pattern(item: &str, patterns: &[String]) -> bool {
if patterns.is_empty() {
return true;
}
for pattern in patterns {
if let Ok(glob_pattern) = glob::Pattern::new(pattern) {
if glob_pattern.matches(item) {
return true;
}
}
}
false
}