mod app;
mod askpass;
mod clipboard;
mod connection;
mod event;
mod handler;
mod fs_util;
mod history;
mod import;
mod ping;
mod preferences;
mod providers;
mod quick_add;
mod snippet;
mod ssh_config;
mod ssh_keys;
mod tui;
mod tunnel;
mod ui;
mod update;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::{Shell, generate};
use app::App;
use event::{AppEvent, EventHandler};
use ssh_config::model::{HostEntry, SshConfigFile};
#[derive(Parser)]
#[command(
name = "purple",
about = "Your SSH config is a mess. Purple fixes that.",
long_about = "Purple is a fast, friendly TUI for managing your SSH hosts.\n\
Add, edit, delete and connect without opening a text editor.\n\n\
Life's too short for nano ~/.ssh/config.",
version
)]
struct Cli {
#[arg(value_name = "ALIAS")]
alias: Option<String>,
#[arg(short, long)]
connect: Option<String>,
#[arg(short, long)]
list: bool,
#[arg(long, default_value = "~/.ssh/config")]
config: String,
#[arg(long, value_name = "SHELL")]
completions: Option<Shell>,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Add {
target: String,
#[arg(short, long)]
alias: Option<String>,
#[arg(short, long)]
key: Option<String>,
},
Import {
file: Option<String>,
#[arg(long)]
known_hosts: bool,
#[arg(short, long)]
group: Option<String>,
},
Sync {
provider: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
remove: bool,
#[arg(long)]
reset_tags: bool,
},
Provider {
#[command(subcommand)]
command: ProviderCommands,
},
Tunnel {
#[command(subcommand)]
command: TunnelCommands,
},
Password {
#[command(subcommand)]
command: PasswordCommands,
},
Snippet {
#[command(subcommand)]
command: SnippetCommands,
},
Update,
}
#[derive(Subcommand)]
enum ProviderCommands {
Add {
provider: String,
#[arg(long)]
token: Option<String>,
#[arg(long)]
token_stdin: bool,
#[arg(long)]
prefix: Option<String>,
#[arg(long)]
user: Option<String>,
#[arg(long)]
key: Option<String>,
#[arg(long)]
url: Option<String>,
#[arg(long)]
profile: Option<String>,
#[arg(long)]
regions: Option<String>,
#[arg(long)]
project: Option<String>,
#[arg(long, conflicts_with = "verify_tls")]
no_verify_tls: bool,
#[arg(long, conflicts_with = "no_verify_tls")]
verify_tls: bool,
#[arg(long, conflicts_with = "no_auto_sync")]
auto_sync: bool,
#[arg(long, conflicts_with = "auto_sync")]
no_auto_sync: bool,
},
List,
Remove {
provider: String,
},
}
#[derive(Subcommand)]
enum TunnelCommands {
List {
alias: Option<String>,
},
Add {
alias: String,
forward: String,
},
Remove {
alias: String,
forward: String,
},
Start {
alias: String,
},
}
#[derive(Subcommand)]
enum PasswordCommands {
Set {
alias: String,
},
Remove {
alias: String,
},
}
#[derive(Subcommand)]
enum SnippetCommands {
List,
Add {
name: String,
command: String,
#[arg(long)]
description: Option<String>,
},
Remove {
name: String,
},
Run {
name: String,
alias: Option<String>,
#[arg(long)]
tag: Option<String>,
#[arg(long)]
all: bool,
#[arg(long)]
parallel: bool,
},
}
fn resolve_config_path(path: &str) -> Result<PathBuf> {
if let Some(rest) = path.strip_prefix("~/") {
let home = dirs::home_dir().context("Could not determine home directory")?;
Ok(home.join(rest))
} else {
Ok(PathBuf::from(path))
}
}
fn resolve_token(explicit: Option<String>, from_stdin: bool) -> Result<String> {
if let Some(t) = explicit {
return Ok(t);
}
if from_stdin {
let mut buf = String::new();
std::io::stdin().read_line(&mut buf)?;
return Ok(buf.trim().to_string());
}
if let Ok(t) = std::env::var("PURPLE_TOKEN") {
return Ok(t);
}
anyhow::bail!("No token provided. Use --token, --token-stdin, or set PURPLE_TOKEN env var.")
}
fn main() -> Result<()> {
if std::env::var("PURPLE_ASKPASS_MODE").is_ok() {
return askpass::handle();
}
ui::theme::init();
let cli = Cli::parse();
if let Some(shell) = cli.completions {
let mut cmd = Cli::command();
generate(shell, &mut cmd, "purple", &mut std::io::stdout());
return Ok(());
}
if let Some(Commands::Provider { command }) = cli.command {
return handle_provider_command(command);
}
if let Some(Commands::Update) = cli.command {
return update::self_update();
}
if let Some(Commands::Password { command }) = cli.command {
return handle_password_command(command);
}
let config_path = resolve_config_path(&cli.config)?;
let config = SshConfigFile::parse(&config_path)?;
match cli.command {
Some(Commands::Add { target, alias, key }) => {
return handle_quick_add(config, &target, alias.as_deref(), key.as_deref());
}
Some(Commands::Import {
file,
known_hosts,
group,
}) => {
return handle_import(config, file.as_deref(), known_hosts, group.as_deref());
}
Some(Commands::Sync {
provider,
dry_run,
remove,
reset_tags,
}) => {
return handle_sync(config, provider.as_deref(), dry_run, remove, reset_tags);
}
Some(Commands::Tunnel { command }) => {
return handle_tunnel_command(config, command);
}
Some(Commands::Snippet { command }) => {
return handle_snippet_command(config, command, &config_path);
}
Some(Commands::Provider { .. }) | Some(Commands::Update) | Some(Commands::Password { .. }) => unreachable!(),
None => {}
}
if let Some(alias) = cli.connect {
let askpass = config.host_entries().iter()
.find(|h| h.alias == alias)
.and_then(|h| h.askpass.clone())
.or_else(preferences::load_askpass_default);
let bw_session = ensure_bw_session(None, askpass.as_deref());
ensure_keychain_password(&alias, askpass.as_deref());
let result = connection::connect(&alias, &config_path, askpass.as_deref(), bw_session.as_deref(), false)?;
let code = result.status.code().unwrap_or(1);
if code != 255 {
history::ConnectionHistory::load().record(&alias);
}
askpass::cleanup_marker(&alias);
std::process::exit(code);
}
if cli.list {
let entries = config.host_entries();
if entries.is_empty() {
println!("No hosts configured. Run 'purple' to add some!");
} else {
for host in &entries {
let user = if host.user.is_empty() {
String::new()
} else {
format!("{}@", host.user)
};
let port = if host.port == 22 {
String::new()
} else {
format!(":{}", host.port)
};
println!("{:<20} {}{}{}", host.alias, user, host.hostname, port);
}
}
return Ok(());
}
if let Some(ref alias) = cli.alias {
let entries = config.host_entries();
if let Some(host) = entries.iter().find(|h| h.alias == *alias) {
let alias = host.alias.clone();
let askpass = host.askpass.clone()
.or_else(preferences::load_askpass_default);
let bw_session = ensure_bw_session(None, askpass.as_deref());
ensure_keychain_password(&alias, askpass.as_deref());
println!("Beaming you up to {}...\n", alias);
let result = connection::connect(&alias, &config_path, askpass.as_deref(), bw_session.as_deref(), false)?;
let code = result.status.code().unwrap_or(1);
if code != 255 {
history::ConnectionHistory::load().record(&alias);
}
askpass::cleanup_marker(&alias);
std::process::exit(code);
}
let mut app = App::new(config);
apply_saved_sort(&mut app);
app.start_search_with(alias);
if app.search.filtered_indices.is_empty() {
app.set_status(
format!("No exact match for '{}'. Here's what we found.", alias),
false,
);
}
return run_tui(app);
}
let mut app = App::new(config);
apply_saved_sort(&mut app);
run_tui(app)
}
fn apply_saved_sort(app: &mut App) {
let saved = preferences::load_sort_mode();
let group = preferences::load_group_by_provider();
app.sort_mode = saved;
app.group_by_provider = group;
app.view_mode = preferences::load_view_mode();
if saved != app::SortMode::Original || group {
app.apply_sort();
}
}
fn run_tui(mut app: App) -> Result<()> {
if app.status.is_none() && !app.hosts.is_empty() {
if let Some(home) = dirs::home_dir() {
let purple_dir = home.join(".purple");
if !purple_dir.exists() {
let _ = std::fs::create_dir_all(&purple_dir);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(
&purple_dir,
std::fs::Permissions::from_mode(0o700),
);
}
app.set_status("Welcome to purple. Press ? for the cheat sheet.", false);
}
}
}
let mut terminal = tui::Tui::new()?;
terminal.enter()?;
let events = EventHandler::new(250);
let events_tx = events.sender();
let mut last_config_check = std::time::Instant::now();
for section in app.provider_config.configured_providers().to_vec() {
if !section.auto_sync {
continue;
}
if !app.syncing_providers.contains_key(§ion.provider) {
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
app.syncing_providers.insert(section.provider.clone(), cancel.clone());
handler::spawn_provider_sync(§ion, events_tx.clone(), cancel);
}
}
update::spawn_version_check(events_tx.clone());
while app.running {
terminal.draw(&mut app)?;
match events.next()? {
AppEvent::Key(key) => handler::handle_key_event(&mut app, key, &events_tx)?,
AppEvent::Tick => {
app.tick_status();
if last_config_check.elapsed() >= std::time::Duration::from_secs(4) {
app.check_config_changed();
last_config_check = std::time::Instant::now();
}
let exited = app.poll_tunnels();
for (_alias, msg, is_error) in exited {
app.set_status(msg, is_error);
}
}
AppEvent::PingResult { alias, reachable } => {
let status = if reachable {
app::PingStatus::Reachable
} else {
app::PingStatus::Unreachable
};
app.ping_status.insert(alias, status);
}
AppEvent::SyncProgress { provider, message } => {
let name = providers::provider_display_name(&provider);
app.set_status(format!("{}: {}", name, message), false);
}
AppEvent::SyncComplete { provider, hosts } => {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let (msg, is_err, total) = app.apply_sync_result(&provider, hosts);
if is_err {
app.sync_history.insert(provider.clone(), app::SyncRecord {
timestamp: now,
message: msg.clone(),
is_error: true,
});
} else {
let label = if total == 1 { "server" } else { "servers" };
app.sync_history.insert(provider.clone(), app::SyncRecord {
timestamp: now,
message: format!("{} {}", total, label),
is_error: false,
});
}
app.set_status(msg, is_err);
app.syncing_providers.remove(&provider);
}
AppEvent::SyncPartial { provider, hosts, failures, total } => {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let display_name = providers::provider_display_name(provider.as_str());
let (msg, is_err, synced) = app.apply_sync_result(&provider, hosts);
if is_err {
app.sync_history.insert(provider.clone(), app::SyncRecord {
timestamp: now,
message: msg.clone(),
is_error: true,
});
app.set_status(msg, true);
} else {
let label = if synced == 1 { "server" } else { "servers" };
app.sync_history.insert(provider.clone(), app::SyncRecord {
timestamp: now,
message: format!("{} {} ({} of {} failed)", synced, label, failures, total),
is_error: true,
});
app.set_status(
format!("{}: {} synced, {} of {} failed to fetch.", display_name, synced, failures, total),
true,
);
}
app.syncing_providers.remove(&provider);
}
AppEvent::SyncError { provider, message } => {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let display_name = providers::provider_display_name(provider.as_str());
app.sync_history.insert(provider.clone(), app::SyncRecord {
timestamp: now,
message: message.clone(),
is_error: true,
});
app.set_status(
format!("{} sync failed: {}", display_name, message),
true,
);
app.syncing_providers.remove(&provider);
}
AppEvent::UpdateAvailable { version } => {
app.update_available = Some(version);
}
AppEvent::PollError => {
app.running = false;
}
}
if let Some((alias, host_askpass)) = app.pending_connect.take() {
let askpass = host_askpass.or_else(preferences::load_askpass_default);
events.pause();
terminal.exit()?;
if let Some(token) = ensure_bw_session(app.bw_session.as_deref(), askpass.as_deref()) {
app.bw_session = Some(token);
}
ensure_keychain_password(&alias, askpass.as_deref());
println!("Beaming you up to {}...\n", alias);
let has_active_tunnel = app.active_tunnels.contains_key(&alias);
let result = connection::connect(&alias, &app.reload.config_path, askpass.as_deref(), app.bw_session.as_deref(), has_active_tunnel);
println!();
match &result {
Ok(cr) => {
let code = cr.status.code().unwrap_or(1);
if code != 255 {
app.history.record(&alias);
}
if code != 0 {
if let Some((hostname, known_hosts_path)) = connection::parse_host_key_error(&cr.stderr_output) {
app.screen = app::Screen::ConfirmHostKeyReset {
alias: alias.clone(),
hostname,
known_hosts_path,
askpass,
};
} else {
app.set_status(
format!("SSH to {} exited with code {}.", alias, code),
true,
);
}
}
}
Err(e) => {
eprintln!("Connection failed: {}", e);
app.set_status(format!("Connection to {} failed.", alias), true);
}
}
askpass::cleanup_marker(&alias);
terminal.enter()?;
events.resume();
last_config_check = std::time::Instant::now();
app.config = SshConfigFile::parse(&app.reload.config_path)?;
app.reload_hosts();
app.update_last_modified();
}
if let Some((snip, aliases)) = app.pending_snippet.take() {
events.pause();
terminal.exit()?;
let multi = aliases.len() > 1;
for alias in &aliases {
let askpass = app.hosts.iter()
.find(|h| h.alias == *alias)
.and_then(|h| h.askpass.clone())
.or_else(preferences::load_askpass_default);
if let Some(token) = ensure_bw_session(app.bw_session.as_deref(), askpass.as_deref()) {
app.bw_session = Some(token);
}
ensure_keychain_password(alias, askpass.as_deref());
if multi {
println!("── {} ──", alias);
} else {
println!("Running '{}' on {}...\n", snip.name, alias);
}
let has_tunnel = app.active_tunnels.contains_key(alias);
match snippet::run_snippet(
alias,
&app.reload.config_path,
&snip.command,
askpass.as_deref(),
app.bw_session.as_deref(),
false,
has_tunnel,
) {
Ok(r) => {
if r.status.success() {
app.history.record(alias);
} else if multi {
eprintln!("Exited with code {}.", r.status.code().unwrap_or(1));
} else {
println!("\nExited with code {}.", r.status.code().unwrap_or(1));
}
}
Err(e) => eprintln!("[{}] Failed: {}", alias, e),
}
if multi {
println!();
}
}
if !multi {
println!("\nDone.");
} else {
println!("Done. Ran '{}' on {} hosts.", snip.name, aliases.len());
}
println!("\nPress Enter to continue...");
let _ = std::io::stdin().read_line(&mut String::new());
terminal.enter()?;
events.resume();
last_config_check = std::time::Instant::now();
app.config = SshConfigFile::parse(&app.reload.config_path)?;
app.reload_hosts();
app.update_last_modified();
}
}
for (_, mut tunnel) in app.active_tunnels.drain() {
let _ = tunnel.child.kill();
let _ = tunnel.child.wait();
}
terminal.exit()?;
Ok(())
}
fn handle_quick_add(
mut config: SshConfigFile,
target: &str,
alias: Option<&str>,
key: Option<&str>,
) -> Result<()> {
let parsed = quick_add::parse_target(target).map_err(|e| anyhow::anyhow!(e))?;
let alias_str = alias
.map(|a| a.to_string())
.unwrap_or_else(|| {
parsed
.hostname
.split('.')
.next()
.unwrap_or(&parsed.hostname)
.to_string()
});
if alias_str.trim().is_empty() {
eprintln!("Alias can't be empty. Use --alias to specify one.");
std::process::exit(1);
}
if alias_str.contains(char::is_whitespace) {
eprintln!("Alias can't contain whitespace. Use --alias to pick a simpler name.");
std::process::exit(1);
}
if ssh_config::model::is_host_pattern(&alias_str) {
eprintln!("Alias can't contain pattern characters. Use --alias to pick a different name.");
std::process::exit(1);
}
let key_val = key.unwrap_or("").to_string();
for (value, name) in [
(&alias_str, "Alias"),
(&parsed.hostname, "Hostname"),
(&parsed.user, "User"),
(&key_val, "Identity file"),
] {
if value.chars().any(|c| c.is_control()) {
eprintln!("{} contains control characters.", name);
std::process::exit(1);
}
}
if parsed.hostname.contains(char::is_whitespace) {
eprintln!("Hostname can't contain whitespace.");
std::process::exit(1);
}
if parsed.user.contains(char::is_whitespace) {
eprintln!("User can't contain whitespace.");
std::process::exit(1);
}
if config.has_host(&alias_str) {
eprintln!("'{}' already exists. Use --alias to pick a different name.", alias_str);
std::process::exit(1);
}
let entry = HostEntry {
alias: alias_str.clone(),
hostname: parsed.hostname,
user: parsed.user,
port: parsed.port,
identity_file: key_val,
..Default::default()
};
config.add_host(&entry);
config.write()?;
println!("Welcome aboard, {}!", alias_str);
Ok(())
}
fn handle_import(
mut config: SshConfigFile,
file: Option<&str>,
known_hosts: bool,
group: Option<&str>,
) -> Result<()> {
let result = if known_hosts {
import::import_from_known_hosts(&mut config, group)
} else if let Some(path) = file {
let resolved = resolve_config_path(path)?;
import::import_from_file(&mut config, &resolved, group)
} else {
eprintln!("Provide a file or use --known-hosts. Run 'purple import --help' for details.");
std::process::exit(1);
};
match result {
Ok((imported, skipped, parse_failures, read_errors)) => {
if imported > 0 {
config.write()?;
}
println!(
"Imported {} host{}, skipped {} duplicate{}.",
imported,
if imported == 1 { "" } else { "s" },
skipped,
if skipped == 1 { "" } else { "s" },
);
if parse_failures > 0 {
eprintln!(
"! {} line{} could not be parsed (invalid format).",
parse_failures,
if parse_failures == 1 { "" } else { "s" },
);
}
if read_errors > 0 {
eprintln!(
"! {} line{} could not be read (encoding error).",
read_errors,
if read_errors == 1 { "" } else { "s" },
);
}
Ok(())
}
Err(e) => {
eprintln!("{}", e);
std::process::exit(1);
}
}
}
fn handle_sync(
mut config: SshConfigFile,
provider_name: Option<&str>,
dry_run: bool,
remove: bool,
reset_tags: bool,
) -> Result<()> {
let provider_config = providers::config::ProviderConfig::load();
let sections: Vec<&providers::config::ProviderSection> = if let Some(name) = provider_name {
if providers::get_provider(name).is_none() {
eprintln!(
"Never heard of '{}'. Try: digitalocean, vultr, linode, hetzner, upcloud, proxmox, aws, scaleway, gcp.",
name
);
std::process::exit(1);
}
match provider_config.section(name) {
Some(s) => vec![s],
None => {
eprintln!(
"No configuration for {}. Run 'purple provider add {}' first.",
name, name
);
std::process::exit(1);
}
}
} else {
let configured = provider_config.configured_providers();
if configured.is_empty() {
eprintln!("No providers configured. Run 'purple provider add' to set one up.");
std::process::exit(1);
}
configured.iter().collect()
};
let mut any_changes = false;
let mut any_failures = false;
let mut any_hard_failures = false;
for section in §ions {
let provider = match providers::get_provider_with_config(§ion.provider, section) {
Some(p) => p,
None => {
eprintln!(
"Skipping unknown provider '{}'. Try: digitalocean, vultr, linode, hetzner, upcloud, proxmox, aws, scaleway, gcp.",
section.provider
);
any_failures = true;
continue;
}
};
let display_name = providers::provider_display_name(section.provider.as_str());
let is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
print!("Syncing {}... ", display_name);
let _ = std::io::Write::flush(&mut std::io::stdout());
let last_summary = std::cell::RefCell::new(String::new());
let progress = |msg: &str| {
*last_summary.borrow_mut() = msg.to_string();
if is_tty {
print!("\x1b[2K\rSyncing {}... {}", display_name, msg);
let _ = std::io::Write::flush(&mut std::io::stdout());
}
};
let fetch_result = provider.fetch_hosts_with_progress(§ion.token, &std::sync::atomic::AtomicBool::new(false), &progress);
let summary = last_summary.into_inner();
if is_tty {
if summary.is_empty() {
print!("\x1b[2K\rSyncing {}... ", display_name);
} else {
println!("\x1b[2K\rSyncing {}... {}", display_name, summary);
}
let _ = std::io::Write::flush(&mut std::io::stdout());
} else if !summary.is_empty() {
println!("{}", summary);
}
let (hosts, suppress_remove) = match fetch_result {
Ok(hosts) => (hosts, false),
Err(providers::ProviderError::PartialResult { hosts, failures, total }) => {
println!(
"{} servers found ({} of {} failed to fetch).",
hosts.len(), failures, total
);
if remove {
eprintln!("! {}: skipping --remove due to partial failures.", display_name);
}
any_failures = true;
(hosts, true)
}
Err(e) => {
println!("failed.");
eprintln!("! {}: {}", display_name, e);
any_failures = true;
any_hard_failures = true;
continue;
}
};
if !suppress_remove {
println!("{} servers found.", hosts.len());
}
let effective_remove = remove && !suppress_remove;
let result = providers::sync::sync_provider_with_options(
&mut config, &*provider, &hosts, section, effective_remove, dry_run, reset_tags,
);
let prefix = if dry_run { " Would have: " } else { " " };
println!(
"{}Added {}, updated {}, unchanged {}.",
prefix, result.added, result.updated, result.unchanged
);
if result.removed > 0 {
println!(" Removed {}.", result.removed);
}
if result.added > 0 || result.updated > 0 || result.removed > 0 {
any_changes = true;
}
}
if any_changes && !dry_run {
if any_hard_failures {
eprintln!("! Skipping config write due to sync failures. Fix the errors and re-run.");
} else {
config.write()?;
}
}
if any_failures {
std::process::exit(1);
}
Ok(())
}
fn handle_provider_command(command: ProviderCommands) -> Result<()> {
match command {
ProviderCommands::Add {
provider,
token,
token_stdin,
mut prefix,
mut user,
mut key,
url,
mut profile,
mut regions,
mut project,
no_verify_tls,
verify_tls,
auto_sync,
no_auto_sync,
} => {
let p = match providers::get_provider(&provider) {
Some(p) => p,
None => {
eprintln!(
"Never heard of '{}'. Try: digitalocean, vultr, linode, hetzner, upcloud, proxmox, aws, scaleway, gcp.",
provider
);
std::process::exit(1);
}
};
let mut token = token;
let mut url = url;
let mut no_verify_tls = no_verify_tls;
let mut verify_tls = verify_tls;
if provider != "proxmox" {
if url.is_some() {
eprintln!("Warning: --url is only used by the Proxmox provider. Ignoring.");
url = None;
}
if no_verify_tls {
eprintln!("Warning: --no-verify-tls is only used by the Proxmox provider. Ignoring.");
no_verify_tls = false;
}
if verify_tls {
eprintln!("Warning: --verify-tls is only used by the Proxmox provider. Ignoring.");
verify_tls = false;
}
}
if provider != "aws" && profile.is_some() {
eprintln!("Warning: --profile is only used by the AWS provider. Ignoring.");
profile = None;
}
if !matches!(provider.as_str(), "aws" | "scaleway" | "gcp") && regions.is_some() {
eprintln!("Warning: --regions is only used by the AWS, Scaleway and GCP providers. Ignoring.");
regions = None;
}
if provider != "gcp" && project.is_some() {
eprintln!("Warning: --project is only used by the GCP provider. Ignoring.");
project = None;
}
let existing_section = providers::config::ProviderConfig::load()
.section(&provider)
.cloned();
if let Some(ref existing) = existing_section {
if provider == "proxmox" && url.is_none() && !existing.url.is_empty() {
url = Some(existing.url.clone());
}
if token.is_none() && !token_stdin && std::env::var("PURPLE_TOKEN").is_err() && !existing.token.is_empty() {
token = Some(existing.token.clone());
}
if prefix.is_none() {
prefix = Some(existing.alias_prefix.clone());
}
if user.is_none() {
user = Some(existing.user.clone());
}
if key.is_none() && !existing.identity_file.is_empty() {
key = Some(existing.identity_file.clone());
}
if !no_verify_tls && !verify_tls && !existing.verify_tls {
no_verify_tls = true;
}
if provider == "aws"
&& profile.is_none() && !existing.profile.is_empty() {
profile = Some(existing.profile.clone());
}
if matches!(provider.as_str(), "aws" | "scaleway" | "gcp")
&& regions.is_none() && !existing.regions.is_empty() {
regions = Some(existing.regions.clone());
}
if provider == "gcp" && project.is_none() && !existing.project.is_empty() {
project = Some(existing.project.clone());
}
}
if provider == "proxmox" {
if url.is_none() || url.as_deref().unwrap_or("").trim().is_empty() {
eprintln!("Proxmox requires --url (e.g. --url https://pve.example.com:8006).");
std::process::exit(1);
}
let u = url.as_deref().unwrap();
if !u.to_ascii_lowercase().starts_with("https://") {
eprintln!("URL must start with https://. For self-signed certificates use --no-verify-tls.");
std::process::exit(1);
}
}
let aws_has_profile = provider == "aws" && profile.as_deref().is_some_and(|p| !p.trim().is_empty());
let token = if aws_has_profile && token.is_none() && !token_stdin && std::env::var("PURPLE_TOKEN").is_err() {
String::new()
} else {
match resolve_token(token, token_stdin) {
Ok(t) => t,
Err(e) => {
eprintln!("{}", e);
std::process::exit(1);
}
}
};
if token.trim().is_empty() && !aws_has_profile {
if provider == "gcp" {
eprintln!("Token can't be empty. Provide a service account JSON key file path or access token.");
} else {
eprintln!(
"Token can't be empty. Grab one from your {} dashboard.",
providers::provider_display_name(&provider)
);
}
std::process::exit(1);
}
let alias_prefix = prefix.unwrap_or_else(|| p.short_label().to_string());
if ssh_config::model::is_host_pattern(&alias_prefix) {
eprintln!("Alias prefix can't contain spaces or pattern characters (*, ?, [, !).");
std::process::exit(1);
}
let user = user.unwrap_or_else(|| "root".to_string());
let identity_file = key.unwrap_or_default();
let url_value = url.clone().unwrap_or_default();
let profile_value = profile.clone().unwrap_or_default();
let regions_value = regions.clone().unwrap_or_default();
let project_value = project.clone().unwrap_or_default();
for (value, name) in [
(&url_value, "URL"),
(&token, "Token"),
(&alias_prefix, "Alias prefix"),
(&user, "User"),
(&identity_file, "Identity file"),
(&profile_value, "Profile"),
(&project_value, "Project"),
(®ions_value, "Regions"),
] {
if value.chars().any(|c| c.is_control()) {
eprintln!("{} contains control characters.", name);
std::process::exit(1);
}
}
if user.contains(char::is_whitespace) {
eprintln!("User can't contain whitespace.");
std::process::exit(1);
}
let resolved_auto_sync = if auto_sync {
true
} else if no_auto_sync {
false
} else if let Some(ref existing) = existing_section {
existing.auto_sync
} else {
!matches!(provider.as_str(), "proxmox")
};
let resolved_profile = profile.unwrap_or_default();
let resolved_regions = regions.unwrap_or_default();
let resolved_project = project.unwrap_or_default();
if provider == "aws" && resolved_regions.trim().is_empty() {
eprintln!("AWS requires --regions (e.g. --regions us-east-1,eu-west-1).");
std::process::exit(1);
}
if provider == "scaleway" && resolved_regions.trim().is_empty() {
eprintln!("Scaleway requires --regions with one or more zones (e.g. --regions fr-par-1,nl-ams-1).");
std::process::exit(1);
}
if provider == "gcp" && resolved_project.trim().is_empty() {
eprintln!("GCP requires --project (e.g. --project my-gcp-project-id).");
std::process::exit(1);
}
let section = providers::config::ProviderSection {
provider: provider.clone(),
token,
alias_prefix,
user,
identity_file,
url: url.unwrap_or_default(),
verify_tls: !no_verify_tls,
auto_sync: resolved_auto_sync,
profile: resolved_profile,
regions: resolved_regions,
project: resolved_project,
};
let mut config = providers::config::ProviderConfig::load();
config.set_section(section);
config
.save()
.map_err(|e| anyhow::anyhow!("Failed to save: {}", e))?;
println!("Saved {} configuration.", provider);
Ok(())
}
ProviderCommands::List => {
let config = providers::config::ProviderConfig::load();
let sections = config.configured_providers();
if sections.is_empty() {
println!("No providers configured. Run 'purple provider add' to set one up.");
} else {
for s in sections {
let display_name = providers::provider_display_name(s.provider.as_str());
println!(
" {:<16} {}-*{:>8}",
display_name, s.alias_prefix, s.user
);
}
}
Ok(())
}
ProviderCommands::Remove { provider } => {
let mut config = providers::config::ProviderConfig::load();
if config.section(&provider).is_none() {
eprintln!("No configuration for '{}'. Nothing to remove.", provider);
std::process::exit(1);
}
config.remove_section(&provider);
config
.save()
.map_err(|e| anyhow::anyhow!("Failed to save: {}", e))?;
println!("Removed {} configuration.", provider);
Ok(())
}
}
}
fn handle_tunnel_command(mut config: SshConfigFile, command: TunnelCommands) -> Result<()> {
match command {
TunnelCommands::List { alias } => {
if let Some(alias) = alias {
if !config.has_host(&alias) {
eprintln!("No host '{}' found.", alias);
std::process::exit(1);
}
let rules = config.find_tunnel_directives(&alias);
if rules.is_empty() {
println!("No tunnels configured for {}.", alias);
} else {
println!("Tunnels for {}:", alias);
for rule in &rules {
println!(" {}", rule.display());
}
}
} else {
let entries = config.host_entries();
let with_tunnels: Vec<_> = entries.iter().filter(|e| e.tunnel_count > 0).collect();
if with_tunnels.is_empty() {
println!("No tunnels configured.");
} else {
for (i, host) in with_tunnels.iter().enumerate() {
if i > 0 {
println!();
}
println!("{}:", host.alias);
for rule in config.find_tunnel_directives(&host.alias) {
println!(" {}", rule.display());
}
}
}
}
Ok(())
}
TunnelCommands::Add { alias, forward } => {
if !config.has_host(&alias) {
eprintln!("No host '{}' found.", alias);
std::process::exit(1);
}
if config.is_included_host(&alias) {
eprintln!("Host '{}' is from an included file and cannot be modified.", alias);
std::process::exit(1);
}
let rule = tunnel::TunnelRule::from_cli_spec(&forward).unwrap_or_else(|e| {
eprintln!("{}", e);
std::process::exit(1);
});
let key = rule.tunnel_type.directive_key();
let value = rule.to_directive_value();
if config.has_forward(&alias, key, &value) {
eprintln!("Forward {} already exists on {}.", forward, alias);
std::process::exit(1);
}
config.add_forward(&alias, key, &value);
if let Err(e) = config.write() {
eprintln!("Failed to save config: {}", e);
std::process::exit(1);
}
println!("Added {} to {}.", forward, alias);
Ok(())
}
TunnelCommands::Remove { alias, forward } => {
if !config.has_host(&alias) {
eprintln!("No host '{}' found.", alias);
std::process::exit(1);
}
if config.is_included_host(&alias) {
eprintln!("Host '{}' is from an included file and cannot be modified.", alias);
std::process::exit(1);
}
let rule = tunnel::TunnelRule::from_cli_spec(&forward).unwrap_or_else(|e| {
eprintln!("{}", e);
std::process::exit(1);
});
let key = rule.tunnel_type.directive_key();
let value = rule.to_directive_value();
let removed = config.remove_forward(&alias, key, &value);
if !removed {
eprintln!("No matching forward {} found on {}.", forward, alias);
std::process::exit(1);
}
if let Err(e) = config.write() {
eprintln!("Failed to save config: {}", e);
std::process::exit(1);
}
println!("Removed {} from {}.", forward, alias);
Ok(())
}
TunnelCommands::Start { alias } => {
if !config.has_host(&alias) {
eprintln!("No host '{}' found.", alias);
std::process::exit(1);
}
let tunnels = config.find_tunnel_directives(&alias);
if tunnels.is_empty() {
eprintln!("No forwarding directives configured for '{}'.", alias);
std::process::exit(1);
}
println!("Starting tunnel for {}... (Ctrl+C to stop)", alias);
let status = std::process::Command::new("ssh")
.arg("-F")
.arg(&config.path)
.arg("-N")
.arg("--")
.arg(&alias)
.status()
.map_err(|e| anyhow::anyhow!("Failed to start ssh: {}", e))?;
let code = status.code().unwrap_or(1);
std::process::exit(code);
}
}
}
fn prompt_hidden_input(prompt: &str) -> Result<Option<String>> {
eprint!("{}", prompt);
crossterm::terminal::enable_raw_mode()?;
let mut input = String::new();
loop {
if let crossterm::event::Event::Key(key) = crossterm::event::read()? {
match key.code {
crossterm::event::KeyCode::Enter => break,
crossterm::event::KeyCode::Char(c) => {
input.push(c);
eprint!("*");
}
crossterm::event::KeyCode::Backspace => {
if input.pop().is_some() {
eprint!("\x08 \x08");
}
}
crossterm::event::KeyCode::Esc => {
crossterm::terminal::disable_raw_mode()?;
eprintln!();
return Ok(None);
}
_ => {}
}
}
}
crossterm::terminal::disable_raw_mode()?;
eprintln!();
Ok(Some(input))
}
fn ensure_bw_session(existing: Option<&str>, askpass: Option<&str>) -> Option<String> {
let askpass = askpass?;
if !askpass.starts_with("bw:") || existing.is_some() {
return None;
}
let status = askpass::bw_vault_status();
match status {
askpass::BwStatus::Unlocked => {
None
}
askpass::BwStatus::NotInstalled => {
eprintln!("Bitwarden CLI (bw) not found. SSH will prompt for password.");
None
}
askpass::BwStatus::NotAuthenticated => {
eprintln!("Bitwarden vault not logged in. Run 'bw login' first.");
None
}
askpass::BwStatus::Locked => {
for attempt in 0..2 {
let password = match prompt_hidden_input("Bitwarden master password: ") {
Ok(Some(p)) if !p.is_empty() => p,
Ok(Some(_)) => {
eprintln!("Empty password. SSH will prompt for password.");
return None;
}
Ok(None) => {
return None;
}
Err(e) => {
eprintln!("Failed to read password: {}", e);
return None;
}
};
match askpass::bw_unlock(&password) {
Ok(token) => return Some(token),
Err(e) => {
if attempt == 0 {
eprintln!("Unlock failed: {}. Try again.", e);
} else {
eprintln!("Unlock failed: {}. SSH will prompt for password.", e);
}
}
}
}
None
}
}
}
fn ensure_keychain_password(alias: &str, askpass: Option<&str>) {
if askpass != Some("keychain") {
return;
}
if askpass::keychain_has_password(alias) {
return;
}
let password = match prompt_hidden_input(&format!("Password for {} (stored in keychain): ", alias)) {
Ok(Some(p)) if !p.is_empty() => p,
Ok(Some(_)) => {
eprintln!("Empty password. SSH will prompt for password.");
return;
}
Ok(None) => return, Err(_) => return,
};
match askpass::store_in_keychain(alias, &password) {
Ok(()) => eprintln!("Password stored in keychain."),
Err(e) => eprintln!("Failed to store in keychain: {}. SSH will prompt for password.", e),
}
}
fn handle_password_command(command: PasswordCommands) -> Result<()> {
match command {
PasswordCommands::Set { alias } => {
let password = match prompt_hidden_input(&format!("Password for {}: ", alias))? {
Some(p) if !p.is_empty() => p,
Some(_) => {
eprintln!("Password can't be empty.");
std::process::exit(1);
}
None => {
eprintln!("Cancelled.");
std::process::exit(1);
}
};
askpass::store_in_keychain(&alias, &password)?;
println!("Password stored for {}. Set 'keychain' as password source to use it.", alias);
Ok(())
}
PasswordCommands::Remove { alias } => {
askpass::remove_from_keychain(&alias)?;
println!("Password removed for {}.", alias);
Ok(())
}
}
}
fn handle_snippet_command(config: SshConfigFile, command: SnippetCommands, config_path: &Path) -> Result<()> {
match command {
SnippetCommands::List => {
let store = snippet::SnippetStore::load();
if store.snippets.is_empty() {
println!("No snippets configured. Use 'purple snippet add' to create one.");
} else {
for s in &store.snippets {
if s.description.is_empty() {
println!(" {} {}", s.name, s.command);
} else {
println!(" {} {} ({})", s.name, s.command, s.description);
}
}
}
Ok(())
}
SnippetCommands::Add {
name,
command,
description,
} => {
if let Err(e) = snippet::validate_name(&name) {
eprintln!("{}", e);
std::process::exit(1);
}
if let Err(e) = snippet::validate_command(&command) {
eprintln!("{}", e);
std::process::exit(1);
}
if let Some(ref desc) = description {
if desc.contains(|c: char| c.is_control()) {
eprintln!("Description contains control characters.");
std::process::exit(1);
}
}
let mut store = snippet::SnippetStore::load();
let is_update = store.get(&name).is_some();
store.set(snippet::Snippet {
name: name.clone(),
command,
description: description.unwrap_or_default(),
});
store.save()?;
if is_update {
println!("Updated snippet '{}'.", name);
} else {
println!("Added snippet '{}'.", name);
}
Ok(())
}
SnippetCommands::Remove { name } => {
let mut store = snippet::SnippetStore::load();
if store.get(&name).is_none() {
eprintln!("No snippet '{}' found.", name);
std::process::exit(1);
}
store.remove(&name);
store.save()?;
println!("Removed snippet '{}'.", name);
Ok(())
}
SnippetCommands::Run {
name,
alias,
tag,
all,
parallel,
} => {
let store = snippet::SnippetStore::load();
let snip = match store.get(&name) {
Some(s) => s.clone(),
None => {
eprintln!("No snippet '{}' found.", name);
std::process::exit(1);
}
};
let entries = config.host_entries();
let targets: Vec<&HostEntry> = if let Some(ref alias) = alias {
match entries.iter().find(|h| h.alias == *alias) {
Some(h) => vec![h],
None => {
eprintln!("No host '{}' found.", alias);
std::process::exit(1);
}
}
} else if let Some(ref tag_filter) = tag {
let matched: Vec<_> = entries
.iter()
.filter(|h| h.tags.iter().any(|t| t == tag_filter))
.collect();
if matched.is_empty() {
eprintln!("No hosts found with tag '{}'.", tag_filter);
std::process::exit(1);
}
matched
} else if all {
entries.iter().collect()
} else {
eprintln!("Specify a host alias, --tag or --all.");
std::process::exit(1);
};
if targets.len() == 1 {
let host = targets[0];
let askpass = host.askpass.clone().or_else(preferences::load_askpass_default);
let bw_session = ensure_bw_session(None, askpass.as_deref());
ensure_keychain_password(&host.alias, askpass.as_deref());
match snippet::run_snippet(
&host.alias,
config_path,
&snip.command,
askpass.as_deref(),
bw_session.as_deref(),
false,
false,
) {
Ok(r) => {
if !r.status.success() {
std::process::exit(r.status.code().unwrap_or(1));
}
}
Err(e) => {
eprintln!("Failed: {}", e);
std::process::exit(1);
}
}
} else if parallel {
use std::sync::mpsc;
use std::thread;
let (tx, rx) = mpsc::channel();
let max_concurrent: usize = 20;
let (slot_tx, slot_rx) = mpsc::channel();
for _ in 0..max_concurrent {
let _ = slot_tx.send(());
}
let config_path = config_path.to_path_buf();
let any_bw = targets.iter().any(|h| {
let askpass = h.askpass.clone().or_else(preferences::load_askpass_default);
askpass.as_deref().unwrap_or("").starts_with("bw:")
});
let bw_session = if any_bw {
let bw_askpass = targets.iter()
.find_map(|h| h.askpass.as_ref().filter(|a| a.starts_with("bw:")))
.cloned()
.or_else(preferences::load_askpass_default);
ensure_bw_session(None, bw_askpass.as_deref())
} else {
None
};
let targets_info: Vec<_> = targets
.iter()
.map(|h| {
let askpass = h.askpass.clone().or_else(preferences::load_askpass_default);
ensure_keychain_password(&h.alias, askpass.as_deref());
(h.alias.clone(), askpass)
})
.collect();
let command = snip.command.clone();
thread::spawn(move || {
for (alias, askpass) in targets_info {
let _ = slot_rx.recv();
let slot_tx = slot_tx.clone();
let tx = tx.clone();
let config_path = config_path.clone();
let command = command.clone();
let bw_session = bw_session.clone();
thread::spawn(move || {
let result = snippet::run_snippet(
&alias,
&config_path,
&command,
askpass.as_deref(),
bw_session.as_deref(),
true,
false,
);
let _ = tx.send((alias, result));
let _ = slot_tx.send(());
});
}
});
let host_count = targets.len();
for _ in 0..host_count {
if let Ok((alias, result)) = rx.recv() {
match result {
Ok(r) => {
for line in r.stdout.lines() {
println!("[{}] {}", alias, line);
}
for line in r.stderr.lines() {
eprintln!("[{}] {}", alias, line);
}
}
Err(e) => eprintln!("[{}] Failed: {}", alias, e),
}
}
}
} else {
let mut bw_session: Option<String> = None;
for host in &targets {
let askpass = host.askpass.clone().or_else(preferences::load_askpass_default);
if let Some(token) = ensure_bw_session(bw_session.as_deref(), askpass.as_deref()) {
bw_session = Some(token);
}
ensure_keychain_password(&host.alias, askpass.as_deref());
println!("── {} ──", host.alias);
match snippet::run_snippet(
&host.alias,
config_path,
&snip.command,
askpass.as_deref(),
bw_session.as_deref(),
false,
false,
) {
Ok(r) => {
if !r.status.success() {
eprintln!(
"Exited with code {}.",
r.status.code().unwrap_or(1)
);
}
}
Err(e) => eprintln!("[{}] Failed: {}", host.alias, e),
}
println!();
}
}
Ok(())
}
}
}