mod cli;
mod config;
mod error;
mod interactive;
mod platform;
mod progress;
mod proton_pass;
mod rclone;
mod ssh;
mod teleport;
use anyhow::Result;
use clap::Parser;
use std::collections::HashSet;
use cli::Args;
use config::Config;
use error::ErrorCollector;
use interactive::{ExportMode, InteractiveAction, PurgeMode};
use proton_pass::ProtonPass;
use rclone::RcloneEntry;
use ssh::SshManager;
use teleport::Teleport;
fn main() {
if let Err(e) = run() {
eprintln!("Error: {:#}", e);
std::process::exit(1);
}
}
fn run() -> Result<()> {
let args = Args::parse();
if !args.has_flags() {
if interactive::is_interactive() {
return run_interactive_mode();
} else {
eprintln!("No arguments provided and not running in an interactive terminal.");
eprintln!();
eprintln!("Usage: pass-ssh-unpack [OPTIONS]");
eprintln!();
eprintln!("Quick examples:");
eprintln!(" pass-ssh-unpack --vault Personal # Export from a vault");
eprintln!(" pass-ssh-unpack --from-tsh --vault Teleport # Import from Teleport");
eprintln!(" pass-ssh-unpack --help # Show all options");
eprintln!();
eprintln!("For interactive mode, run in a standard terminal (bash/zsh).");
return Ok(());
}
}
if args.from_tsh {
return handle_from_tsh(&args);
}
run_export(&args)
}
fn run_export(args: &Args) -> Result<()> {
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 || do_rclone {
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_all_items(vault) {
Ok(items) => items,
Err(e) => {
errors.add(&format!("Failed to list items in vault '{}'", vault), e);
pb_log(" (error listing items)");
pb_log("");
if let Some(ref pb) = vault_pb {
pb.set_position(i as u64 + 1);
}
continue;
}
};
if items.is_empty() {
pb_log(" (no items)");
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;
}
let is_teleport_only = item.host.is_none() && item.ssh.is_some();
if is_teleport_only && !do_rclone {
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();
}
if do_ssh {
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
}
fn handle_from_tsh(args: &Args) -> Result<()> {
let dry_run = args.dry_run;
let quiet = args.quiet;
let log = |msg: &str| {
if !quiet {
println!("{}", msg);
}
};
if args.vault.len() != 1 {
anyhow::bail!("--from-tsh requires exactly one --vault (-v) argument");
}
let vault_name = &args.vault[0];
if args.ssh || args.rclone || args.purge || args.full {
anyhow::bail!("--from-tsh cannot be used with --ssh, --rclone, --purge, or --full");
}
if dry_run {
log("[DRY RUN] No changes will be made");
log("");
}
if which::which("tsh").is_err() {
anyhow::bail!("tsh not found. Install Teleport CLI first.");
}
let spinner = if !quiet {
Some(progress::spinner("Checking Teleport login..."))
} else {
None
};
let teleport = Teleport::new();
let status = match teleport.get_status() {
Ok(s) => {
if let Some(sp) = spinner {
sp.finish_and_clear();
}
s
}
Err(e) => {
if let Some(sp) = spinner {
sp.finish_and_clear();
}
return Err(e);
}
};
log(&format!(
"Logged in to {} as {}",
status.cluster, status.username
));
log("");
let proxy = teleport.get_proxy(&status)?;
let spinner = if !quiet {
Some(progress::spinner("Fetching Teleport nodes..."))
} else {
None
};
let nodes = teleport.list_nodes()?;
if let Some(sp) = spinner {
sp.finish_and_clear();
}
let item_patterns = &args.item;
let filtered_nodes: Vec<_> = nodes
.iter()
.filter(|n| matches_any_pattern(n, item_patterns))
.collect();
if filtered_nodes.is_empty() {
log("No nodes matched the specified patterns.");
return Ok(());
}
log(&format!(
"Found {} node(s) to process",
filtered_nodes.len()
));
log("");
let proton_pass = ProtonPass::new();
if !proton_pass.vault_exists(vault_name)? {
if dry_run {
log(&format!("[DRY RUN] Would create vault: {}", vault_name));
} else {
let spinner = if !quiet {
Some(progress::spinner(&format!(
"Creating vault '{}'...",
vault_name
)))
} else {
None
};
proton_pass.create_vault(vault_name)?;
if let Some(sp) = spinner {
sp.finish_and_clear();
}
log(&format!("Created vault: {}", vault_name));
}
}
let existing_titles: HashSet<String> = proton_pass
.list_item_titles(vault_name)
.unwrap_or_default()
.into_iter()
.collect();
let pb = if !quiet {
Some(progress::node_progress_bar(filtered_nodes.len() as u64))
} else {
None
};
let mut created = 0;
let mut skipped = 0;
for (i, hostname) in filtered_nodes.iter().enumerate() {
if existing_titles.contains(*hostname) {
if let Some(ref pb) = pb {
pb.println(format!(" {}: skipped (already exists)", hostname));
}
skipped += 1;
} else {
let server_command = if args.no_scan {
"/usr/lib/openssh/sftp-server".to_string()
} else {
if let Some(ref pb) = pb {
pb.set_message(format!("Finding Subsystem for {}...", hostname));
}
let result = teleport
.get_subsystem(hostname)
.unwrap_or_else(|_| "/usr/lib/openssh/sftp-server".to_string());
if let Some(ref pb) = pb {
pb.set_message("");
}
result
};
let ssh_command = format!("tsh ssh --proxy={} {}", proxy, hostname);
if dry_run {
if let Some(ref pb) = pb {
pb.println(format!(" {}: [DRY RUN] would create", hostname));
pb.println(format!(" SSH: {}", ssh_command));
pb.println(format!(" Server Command: {}", server_command));
}
} else {
if let Some(ref pb) = pb {
pb.set_message(format!("Creating {}...", hostname));
}
proton_pass.create_tsh_item(vault_name, hostname, &ssh_command, &server_command)?;
if let Some(ref pb) = pb {
pb.set_message("");
pb.println(format!(" {}: created", hostname));
}
}
created += 1;
}
if let Some(ref pb) = pb {
pb.set_position(i as u64 + 1);
}
}
if let Some(pb) = pb {
pb.finish_and_clear();
}
log("");
if dry_run {
log(&format!(
"[DRY RUN] Would add {} Teleport node(s) to vault \"{}\" ({} already exist)",
created, vault_name, skipped
));
} else {
log(&format!(
"Done! Added {} Teleport node(s) to vault \"{}\" ({} skipped)",
created, vault_name, skipped
));
}
Ok(())
}
fn run_interactive_mode() -> Result<()> {
loop {
match interactive::run_interactive()? {
InteractiveAction::Cancelled => {
println!();
println!("Thanks for using pass-ssh-unpack!");
return Ok(());
}
InteractiveAction::ViewedStatus => {
continue;
}
InteractiveAction::ImportTeleport {
vault,
item_pattern,
scan_remotes,
dry_run,
} => {
println!();
let mut args = Args::parse_from(["pass-ssh-unpack"]);
args.from_tsh = true;
args.vault = vec![vault];
args.no_scan = !scan_remotes;
args.dry_run = dry_run;
if let Some(pattern) = item_pattern {
args.item = vec![pattern];
}
if let Err(e) = handle_from_tsh(&args) {
eprintln!("Error: {:#}", e);
}
println!();
}
InteractiveAction::ExportLocal {
mode,
vaults,
item_pattern,
full,
dry_run,
} => {
println!();
let mut args = Args::parse_from(["pass-ssh-unpack"]);
args.dry_run = dry_run;
args.full = full;
args.vault = vaults;
match mode {
ExportMode::SshOnly => args.ssh = true,
ExportMode::RcloneOnly => args.rclone = true,
ExportMode::Both => {}
}
if let Some(pattern) = item_pattern {
args.item = vec![pattern];
}
if let Err(e) = run_export(&args) {
eprintln!("Error: {:#}", e);
}
println!();
}
InteractiveAction::Purge { mode, dry_run } => {
println!();
let mut args = Args::parse_from(["pass-ssh-unpack"]);
args.purge = true;
args.dry_run = dry_run;
match mode {
PurgeMode::SshOnly => args.ssh = true,
PurgeMode::RcloneOnly => args.rclone = true,
PurgeMode::Both => {}
}
if let Err(e) = run_export(&args) {
eprintln!("Error: {:#}", e);
}
println!();
}
}
}
}