mod animation;
mod app;
mod askpass;
mod askpass_env;
mod cli;
mod clipboard;
mod connection;
mod containers;
mod demo;
mod demo_flag;
mod event;
mod file_browser;
mod fs_util;
mod handler;
mod history;
mod import;
mod logging;
mod mcp;
mod ping;
mod preferences;
mod providers;
mod quick_add;
mod snippet;
mod ssh_config;
mod ssh_context;
mod ssh_keys;
mod tui;
mod tunnel;
mod ui;
mod update;
mod vault_ssh;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::{Shell, generate};
use log::warn;
use app::App;
use event::{AppEvent, EventHandler};
use ssh_config::model::SshConfigFile;
#[derive(Parser)]
#[command(
name = "purple",
about = "Your SSH config is a mess. Purple fixes that.",
long_about = "Purple is a terminal SSH client for managing your 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)]
demo: bool,
#[arg(long, value_name = "SHELL")]
completions: Option<Shell>,
#[arg(long)]
theme: Option<String>,
#[arg(long)]
verbose: bool,
#[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,
},
Provider {
#[command(subcommand)]
command: ProviderCommands,
},
Tunnel {
#[command(subcommand)]
command: TunnelCommands,
},
Password {
#[command(subcommand)]
command: PasswordCommands,
},
Snippet {
#[command(subcommand)]
command: SnippetCommands,
},
Update,
Mcp,
Theme {
#[command(subcommand)]
command: ThemeCommands,
},
Vault {
#[command(subcommand)]
command: VaultCommands,
},
Logs {
#[arg(long)]
tail: bool,
#[arg(long)]
clear: bool,
},
}
#[derive(Subcommand)]
enum VaultCommands {
#[command(
long_about = "Sign one or more SSH certificates via the HashiCorp Vault SSH secrets engine.\n\n\
Prerequisites:\n\
- The `vault` CLI is installed and authenticated (run `vault login` or set VAULT_TOKEN)\n\
- VAULT_ADDR points at your Vault server\n\
- A role is configured on the host (Vault SSH role field in the host form) or\n \
on its provider (provider-level vault_role default)\n\
- The SSH secrets engine is enabled on Vault and your token has `update` capability\n \
on the role path\n\n\
Signed certificates are cached under ~/.purple/certs/<alias>-cert.pub and\n\
`CertificateFile` is wired into the SSH config automatically.\n\n\
Distinct from the Vault KV secrets engine used as a password source (`vault:`\n\
askpass prefix); see `purple password` for that."
)]
Sign {
alias: Option<String>,
#[arg(long)]
all: bool,
#[arg(long, value_name = "URL")]
vault_addr: Option<String>,
},
}
#[derive(Subcommand)]
enum ThemeCommands {
List,
Set {
name: String,
},
}
#[derive(Subcommand)]
#[allow(clippy::large_enum_variant)]
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)]
compartment: 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,
},
}
pub(crate) 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))
}
}
pub(crate) 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();
let is_cli_subcommand = cli.command.is_some() || cli.list || cli.connect.is_some();
logging::init(cli.verbose, is_cli_subcommand);
if let Some(ref name) = cli.theme {
if let Some(theme) = ui::theme::ThemeDef::find_builtin(name).or_else(|| {
ui::theme::ThemeDef::load_custom()
.into_iter()
.find(|t| t.name.eq_ignore_ascii_case(name))
}) {
ui::theme::set_theme(theme);
} else {
anyhow::bail!("Unknown theme: {}", name);
}
}
if let Some(shell) = cli.completions {
let mut cmd = Cli::command();
generate(shell, &mut cmd, "purple", &mut std::io::stdout());
return Ok(());
}
if cli.demo {
let app = demo::build_demo_app();
return run_tui(app);
}
if let Some(Commands::Provider { command }) = cli.command {
return cli::handle_provider_command(command);
}
if let Some(Commands::Update) = cli.command {
return update::self_update();
}
if let Some(Commands::Password { command }) = cli.command {
return cli::handle_password_command(command);
}
if let Some(Commands::Mcp) = cli.command {
let config_path = resolve_config_path(&cli.config)?;
return mcp::run(&config_path);
}
if let Some(Commands::Logs { tail, clear }) = cli.command {
return cli::handle_logs_command(tail, clear);
}
if let Some(Commands::Theme { command }) = cli.command {
return cli::handle_theme_command(command);
}
let config_path = resolve_config_path(&cli.config)?;
let mut config = SshConfigFile::parse(&config_path)?;
let repaired_groups = config.repair_absorbed_group_comments();
let orphaned_headers = config.remove_all_orphaned_group_headers();
{
let level_str = logging::level_name(cli.verbose);
let provider_config = providers::config::ProviderConfig::load();
let provider_names: Vec<String> = provider_config
.sections
.iter()
.map(|s| s.provider.clone())
.collect();
let askpass_sources: Vec<String> = config
.host_entries()
.iter()
.filter_map(|h| h.askpass.as_ref())
.map(|s| s.to_string())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
let vault_ssh_info = {
let has_host_level = config.host_entries().iter().any(|h| h.vault_ssh.is_some());
let has_provider_level = provider_config
.sections
.iter()
.any(|s| !s.vault_role.is_empty());
if has_host_level || has_provider_level {
let addr = config
.host_entries()
.iter()
.find_map(|h| h.vault_addr.clone())
.or_else(|| {
provider_config
.sections
.iter()
.find(|s| !s.vault_addr.is_empty())
.map(|s| s.vault_addr.clone())
})
.or_else(|| std::env::var("VAULT_ADDR").ok())
.unwrap_or_else(|| "not set".to_string());
Some(format!("enabled (addr={addr})"))
} else {
None
}
};
let ssh_version = logging::detect_ssh_version();
let term = std::env::var("TERM").unwrap_or_else(|_| "unset".to_string());
let colorterm = std::env::var("COLORTERM").unwrap_or_else(|_| "unset".to_string());
logging::write_banner(&logging::BannerInfo {
version: env!("CARGO_PKG_VERSION"),
config_path: &config_path.display().to_string(),
providers: &provider_names,
askpass_sources: &askpass_sources,
vault_ssh_info: vault_ssh_info.as_deref(),
ssh_version: &ssh_version,
term: &term,
colorterm: &colorterm,
level: &level_str,
});
}
match cli.command {
Some(Commands::Add { target, alias, key }) => {
return cli::handle_quick_add(config, &target, alias.as_deref(), key.as_deref());
}
Some(Commands::Import {
file,
known_hosts,
group,
}) => {
return cli::handle_import(config, file.as_deref(), known_hosts, group.as_deref());
}
Some(Commands::Sync {
provider,
dry_run,
remove,
}) => {
return cli::handle_sync(config, provider.as_deref(), dry_run, remove);
}
Some(Commands::Tunnel { command }) => {
return cli::handle_tunnel_command(config, command);
}
Some(Commands::Snippet { command }) => {
return cli::handle_snippet_command(config, command, &config_path);
}
Some(Commands::Vault {
command:
VaultCommands::Sign {
alias,
all,
vault_addr: cli_vault_addr,
},
}) => {
return cli::handle_vault_sign_command(config, alias, all, cli_vault_addr);
}
Some(Commands::Provider { .. })
| Some(Commands::Update)
| Some(Commands::Password { .. })
| Some(Commands::Mcp)
| Some(Commands::Theme { .. })
| Some(Commands::Logs { .. }) => unreachable!(),
None => {}
}
if let Some(alias) = cli.connect {
let provider_config = providers::config::ProviderConfig::load();
let entries = config.host_entries();
let host_entry = entries.iter().find(|h| h.alias == alias).cloned();
if let Some(ref host) = host_entry {
if let Some((msg, _is_error)) =
ensure_vault_ssh_if_needed(&alias, host, &provider_config, &mut config)
{
eprintln!("{}", msg);
}
}
let askpass = host_entry
.as_ref()
.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 host_opt = config
.host_entries()
.iter()
.find(|h| h.alias == *alias)
.cloned();
if let Some(host) = host_opt {
let provider_config = providers::config::ProviderConfig::load();
if let Some((msg, _is_error)) =
ensure_vault_ssh_if_needed(&host.alias, &host, &provider_config, &mut config)
{
eprintln!("{}", msg);
}
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);
if repaired_groups > 0 || orphaned_headers > 0 {
app.set_status(
format!(
"Repaired SSH config ({} absorbed, {} orphaned group headers).",
repaired_groups, orphaned_headers
),
false,
);
}
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);
if repaired_groups > 0 || orphaned_headers > 0 {
app.set_status(
format!(
"Repaired SSH config ({} absorbed, {} orphaned group headers).",
repaired_groups, orphaned_headers
),
false,
);
}
run_tui(app)
}
fn apply_saved_sort(app: &mut App) {
let saved = preferences::load_sort_mode();
let group = preferences::load_group_by();
app.sort_mode = saved;
app.group_by = group;
app.view_mode = preferences::load_view_mode();
if app.clear_stale_group_tag() {
if let Err(e) = preferences::save_group_by(&app.group_by) {
app.set_status(
format!("Group preference reset. (save failed: {})", e),
true,
);
}
}
if saved != app::SortMode::Original || !matches!(app.group_by, app::GroupBy::None) {
app.apply_sort();
app.select_first_host();
}
}
pub(crate) fn replace_spinner_frame(text: &str, new_frame: &str) -> Option<String> {
let starts_with_spinner = crate::animation::SPINNER_FRAMES
.iter()
.any(|f| text.starts_with(f));
if !starts_with_spinner {
return None;
}
text.split_once(' ')
.map(|(_, rest)| format!("{} {}", new_frame, rest))
}
pub(crate) fn format_vault_sign_summary(
signed: u32,
failed: u32,
skipped: u32,
first_error: Option<&str>,
) -> String {
let total = signed + failed + skipped;
let cert_word = if total == 1 {
"certificate"
} else {
"certificates"
};
if failed > 0 {
if let Some(err) = first_error {
if total == 1 {
return err.to_string();
}
format!(
"Signed {} of {} {}. {} failed: {}",
signed, total, cert_word, failed, err
)
} else {
format!(
"Signed {} of {} {}. {} failed",
signed, total, cert_word, failed
)
}
} else if skipped > 0 && signed == 0 {
format!(
"All {} {} already valid. Nothing to sign.",
total, cert_word
)
} else if skipped > 0 {
format!(
"Signed {} of {} {}. {} already valid.",
signed, total, cert_word, skipped
)
} else {
format!("Signed {} of {} {}.", signed, total, cert_word)
}
}
pub(crate) fn format_sync_diff(added: usize, updated: usize, stale: usize) -> String {
let diff_parts: Vec<String> = [(added, "+"), (updated, "~"), (stale, "-")]
.iter()
.filter(|(n, _)| *n > 0)
.map(|(n, prefix)| format!("{}{}", prefix, n))
.collect();
if diff_parts.is_empty() {
String::new()
} else {
format!(" ({})", diff_parts.join(" "))
}
}
pub(crate) fn set_sync_summary(app: &mut App) {
let still_syncing = !app.syncing_providers.is_empty();
let names = app.sync_done.join(", ");
if still_syncing {
app.set_background_status(format!("Synced: {}...", names), app.sync_had_errors);
} else {
app.set_background_status(format!("Synced: {}", names), app.sync_had_errors);
app.sync_done.clear();
app.sync_had_errors = false;
app::SyncRecord::save_all(&app.sync_history);
}
}
fn first_launch_init(purple_dir: &Path, config_path: &Path) -> Option<bool> {
if purple_dir.exists() {
return None;
}
if let Err(e) = std::fs::create_dir_all(purple_dir) {
warn!("[config] Failed to create ~/.purple directory: {e}");
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = std::fs::set_permissions(purple_dir, std::fs::Permissions::from_mode(0o700))
{
warn!("[config] Failed to set ~/.purple directory permissions: {e}");
}
}
let original_backup = purple_dir.join("config.original");
if config_path.exists() {
if let Err(e) = std::fs::copy(config_path, &original_backup) {
warn!(
"[config] Failed to backup SSH config to {}: {e}",
original_backup.display()
);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) =
std::fs::set_permissions(&original_backup, std::fs::Permissions::from_mode(0o600))
{
warn!("[config] Failed to set backup permissions: {e}");
}
}
}
Some(original_backup.exists())
}
fn run_tui(mut app: App) -> Result<()> {
if app.status.is_none() && !app.demo_mode {
if let Some(home) = dirs::home_dir() {
let purple_dir = home.join(".purple");
if let Some(has_backup) = first_launch_init(&purple_dir, &app.reload.config_path) {
let host_count = app.hosts.len();
let known_hosts_count = if host_count == 0 {
import::count_known_hosts_candidates()
} else {
0
};
app.known_hosts_count = known_hosts_count;
app.screen = app::Screen::Welcome {
has_backup,
host_count,
known_hosts_count,
};
}
}
}
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();
if !app.demo_mode {
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);
}
}
if app.ping.auto_ping {
let hosts_to_ping: Vec<(String, String, u16)> = app
.hosts
.iter()
.filter(|h| !h.hostname.is_empty() && h.proxy_jump.is_empty())
.map(|h| (h.alias.clone(), h.hostname.clone(), h.port))
.collect();
for h in &app.hosts {
if !h.proxy_jump.is_empty() {
app.ping
.status
.insert(h.alias.clone(), app::PingStatus::Skipped);
}
}
if !hosts_to_ping.is_empty() {
for (alias, _, _) in &hosts_to_ping {
app.ping
.status
.insert(alias.clone(), app::PingStatus::Checking);
}
ping::ping_all(&hosts_to_ping, events.sender(), app.ping.generation);
}
}
update::spawn_version_check(events_tx.clone());
}
let mut anim = animation::AnimationState::new();
while app.running {
anim.detect_transitions(&mut app);
terminal.draw(&mut app, &mut anim)?;
let vault_signing = app.vault.signing_cancel.is_some();
let event = if anim.is_animating(&app) {
events.next_timeout(std::time::Duration::from_millis(16))?
} else if anim.has_checking_hosts(&app) || vault_signing {
events.next_timeout(std::time::Duration::from_millis(80))?
} else {
Some(events.next()?)
};
match event {
Some(AppEvent::Key(key)) => {
handler::handle_key_event(&mut app, key, &events_tx)?;
}
Some(AppEvent::Tick) | None => {
handler::event_loop::handle_tick(
&mut app,
&mut anim,
vault_signing,
&mut last_config_check,
);
}
Some(AppEvent::PingResult {
alias,
rtt_ms,
generation,
}) => {
handler::event_loop::handle_ping_result(&mut app, alias, rtt_ms, generation);
}
Some(AppEvent::SyncProgress { provider, message }) => {
handler::event_loop::handle_sync_progress(&mut app, provider, message);
}
Some(AppEvent::SyncComplete { provider, hosts }) => {
handler::event_loop::handle_sync_complete(
&mut app,
provider,
hosts,
&mut last_config_check,
);
}
Some(AppEvent::SyncPartial {
provider,
hosts,
failures,
total,
}) => {
handler::event_loop::handle_sync_partial(
&mut app,
provider,
hosts,
failures,
total,
&mut last_config_check,
);
}
Some(AppEvent::SyncError { provider, message }) => {
handler::event_loop::handle_sync_error(
&mut app,
provider,
message,
&mut last_config_check,
);
}
Some(AppEvent::UpdateAvailable { version, headline }) => {
handler::event_loop::handle_update_available(&mut app, version, headline);
}
Some(AppEvent::FileBrowserListing {
alias,
path,
entries,
}) => {
handler::event_loop::handle_file_browser_listing(
&mut app,
alias,
path,
entries,
&mut terminal,
);
}
Some(AppEvent::ScpComplete {
alias,
success,
message,
}) => {
handler::event_loop::handle_scp_complete(
&mut app,
alias,
success,
message,
&events_tx,
&mut terminal,
);
}
Some(AppEvent::SnippetHostDone {
run_id,
alias,
stdout,
stderr,
exit_code,
}) => {
handler::event_loop::handle_snippet_host_done(
&mut app, run_id, alias, stdout, stderr, exit_code,
);
}
Some(AppEvent::SnippetProgress {
run_id,
completed,
total,
}) => {
handler::event_loop::handle_snippet_progress(&mut app, run_id, completed, total);
}
Some(AppEvent::SnippetAllDone { run_id }) => {
handler::event_loop::handle_snippet_all_done(&mut app, run_id);
}
Some(AppEvent::ContainerListing { alias, result }) => {
handler::event_loop::handle_container_listing(&mut app, alias, result);
}
Some(AppEvent::ContainerActionComplete {
alias,
action,
result,
}) => {
handler::event_loop::handle_container_action_complete(
&mut app, alias, action, result, &events_tx,
);
}
Some(AppEvent::VaultSignResult {
alias,
certificate_file: existing_cert_file,
success,
message,
}) => {
handler::event_loop::handle_vault_sign_result(
&mut app,
alias,
existing_cert_file,
success,
message,
);
}
Some(AppEvent::VaultSignProgress { alias, done, total }) => {
handler::event_loop::handle_vault_sign_progress(
&mut app,
alias,
done,
total,
anim.spinner_tick,
);
}
Some(AppEvent::VaultSignAllDone {
signed,
failed,
skipped,
cancelled,
aborted_message,
first_error,
}) => {
if handler::event_loop::handle_vault_sign_all_done(
&mut app,
signed,
failed,
skipped,
cancelled,
aborted_message,
first_error,
)
.is_break()
{
continue;
}
}
Some(AppEvent::CertCheckResult { alias, status }) => {
handler::event_loop::handle_cert_check_result(&mut app, alias, status);
}
Some(AppEvent::CertCheckError { alias, message }) => {
handler::event_loop::handle_cert_check_error(&mut app, alias, message);
}
Some(AppEvent::PollError) => {
app.running = false;
}
}
if let Some(selected) = app.selected_host() {
if vault_ssh::resolve_vault_role(
selected.vault_ssh.as_deref(),
selected.provider.as_deref(),
&app.provider_config,
)
.is_some()
{
let current_mtime =
vault_ssh::resolve_cert_path(&selected.alias, &selected.certificate_file)
.ok()
.and_then(|p| std::fs::metadata(&p).ok())
.and_then(|m| m.modified().ok());
let cache_stale = cache_entry_is_stale(
app.vault.cert_cache.get(&selected.alias),
current_mtime,
|t| t.elapsed().as_secs(),
);
let sign_in_flight = app
.vault
.sign_in_flight
.lock()
.map(|g| g.contains(&selected.alias))
.unwrap_or(false);
if cache_stale
&& !app.vault.cert_checks_in_flight.contains(&selected.alias)
&& !sign_in_flight
{
let alias = selected.alias.clone();
let cert_file = selected.certificate_file.clone();
app.vault.cert_checks_in_flight.insert(alias.clone());
let tx = events_tx.clone();
std::thread::spawn(move || {
let check_path = match vault_ssh::resolve_cert_path(&alias, &cert_file) {
Ok(p) => p,
Err(e) => {
let _ = tx.send(event::AppEvent::CertCheckError {
alias,
message: e.to_string(),
});
return;
}
};
let status = vault_ssh::check_cert_validity(&check_path);
let _ = tx.send(event::AppEvent::CertCheckResult { alias, status });
});
}
}
}
if let Some((alias, host_askpass)) = app.pending_connect.take() {
let vault_host = app.hosts.iter().find(|h| h.alias == alias).cloned();
let askpass = host_askpass.or_else(preferences::load_askpass_default);
let has_active_tunnel = app.active_tunnels.contains_key(&alias);
let use_tmux = connection::is_in_tmux() && askpass.is_none();
if use_tmux {
let vault_msg = if let Some(ref host) = vault_host {
let msg = ensure_vault_ssh_if_needed(
&alias,
host,
&app.provider_config,
&mut app.config,
);
if msg.is_some() {
app.reload_hosts();
app.refresh_cert_cache(&alias);
}
msg
} else {
None
};
match connection::connect_tmux_window(
&alias,
&app.reload.config_path,
has_active_tunnel,
) {
Ok(()) => {
if let Some((ref msg, is_error)) = vault_msg {
app.set_status(msg.clone(), is_error);
} else {
app.set_status(format!("Opened {} in new tmux window.", alias), false);
}
}
Err(e) => {
app.set_status(format!("tmux: {e}"), true);
}
}
} else {
events.pause();
terminal.exit()?;
let vault_msg = if let Some(ref host) = vault_host {
let msg = ensure_vault_ssh_if_needed(
&alias,
host,
&app.provider_config,
&mut app.config,
);
if msg.is_some() {
app.reload_hosts();
app.refresh_cert_cache(&alias);
}
msg
} else {
None
};
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 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 {
let reason = connection::stderr_summary(&cr.stderr_output);
let msg = if let Some(reason) = reason {
format!("SSH to {} failed. {}", alias, reason)
} else {
format!("SSH to {} exited with code {}.", alias, code)
};
app.set_status(msg, true);
}
} else if let Some((ref msg, is_error)) = vault_msg {
app.set_status(msg.clone(), is_error);
}
}
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();
}
}
app.flush_pending_vault_write();
if let Some(ref cancel) = app.vault.signing_cancel {
cancel.store(true, std::sync::atomic::Ordering::Relaxed);
}
if let Some(handle) = app.vault.sign_thread.take() {
let _ = handle.join();
}
for (_, mut tunnel) in app.active_tunnels.drain() {
let _ = tunnel.child.kill();
let _ = tunnel.child.wait();
}
terminal.exit()?;
Ok(())
}
pub(crate) fn current_cert_mtime(alias: &str, app: &app::App) -> Option<std::time::SystemTime> {
let host = app.hosts.iter().find(|h| h.alias == alias)?;
let cert_path = vault_ssh::resolve_cert_path(alias, &host.certificate_file).ok()?;
std::fs::metadata(&cert_path)
.ok()
.and_then(|m| m.modified().ok())
}
pub(crate) fn cache_entry_is_stale<F>(
entry: Option<&(
std::time::Instant,
vault_ssh::CertStatus,
Option<std::time::SystemTime>,
)>,
current_mtime: Option<std::time::SystemTime>,
elapsed_secs: F,
) -> bool
where
F: FnOnce(std::time::Instant) -> u64,
{
let Some((checked_at, status, cached_mtime)) = entry else {
return true;
};
if current_mtime != *cached_mtime {
return true;
}
let ttl = if matches!(status, vault_ssh::CertStatus::Invalid(_)) {
vault_ssh::CERT_ERROR_BACKOFF_SECS
} else {
vault_ssh::CERT_STATUS_CACHE_TTL_SECS
};
elapsed_secs(*checked_at) > ttl
}
pub(crate) fn ensure_vault_ssh_if_needed(
alias: &str,
host: &ssh_config::model::HostEntry,
provider_config: &providers::config::ProviderConfig,
config: &mut ssh_config::model::SshConfigFile,
) -> Option<(String, bool)> {
let role = vault_ssh::resolve_vault_role(
host.vault_ssh.as_deref(),
host.provider.as_deref(),
provider_config,
)?;
let pubkey = match vault_ssh::resolve_pubkey_path(&host.identity_file) {
Ok(p) => p,
Err(e) => return Some((format!("Vault SSH cert failed: {}", e), true)),
};
let check_path = vault_ssh::resolve_cert_path(alias, &host.certificate_file).ok()?;
let status = vault_ssh::check_cert_validity(&check_path);
if !vault_ssh::needs_renewal(&status) {
return None; }
let vault_addr = vault_ssh::resolve_vault_addr(
host.vault_addr.as_deref(),
host.provider.as_deref(),
provider_config,
);
match vault_ssh::ensure_cert(
&role,
&pubkey,
alias,
&host.certificate_file,
vault_addr.as_deref(),
) {
Ok(cert_path) => {
if should_write_certificate_file(&host.certificate_file) {
let cert_str = cert_path.to_string_lossy().to_string();
let updated = config.set_host_certificate_file(alias, &cert_str);
if !updated {
eprintln!(
"Warning: Signed cert for {} but host block is no longer in ssh config; CertificateFile not written (cert saved to {})",
alias,
cert_path.display()
);
} else if let Err(e) = config.write() {
eprintln!(
"Warning: Signed cert for {} but failed to update SSH config CertificateFile: {}",
alias, e
);
}
}
Some((format!("Signed SSH certificate for {}.", alias), false))
}
Err(e) => {
eprintln!("Warning: Vault SSH signing failed: {}", e);
Some((format!("Vault SSH signing failed: {}", e), true))
}
}
}
pub(crate) fn should_write_certificate_file(existing: &str) -> bool {
existing.trim().is_empty()
}
pub(crate) 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 cli::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
}
}
}
pub(crate) fn ensure_keychain_password(alias: &str, askpass: Option<&str>) {
if askpass != Some("keychain") {
return;
}
if askpass::keychain_has_password(alias) {
return;
}
let password =
match cli::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
),
}
}
#[cfg(test)]
#[path = "main_tests.rs"]
mod tests;