use anyhow::{Context, Result};
use bmux_config::{BmuxConfig, IrohSshAuthorizedKey};
use git_sshripped_recipient::fetch_github_user_keys;
use git_sshripped_ssh_agent::{fingerprint_for_public_key_line, list_agent_keys};
use git_sshripped_ssh_identity::default_public_key_candidates;
use std::collections::BTreeMap;
use std::io::{self, IsTerminal};
#[derive(Debug, Clone)]
struct KeyEntry {
fingerprint: String,
public_key: String,
source: String,
}
pub(super) fn run_access_status() -> Result<u8> {
let config = BmuxConfig::load()?;
let access = &config.connections.iroh_ssh_access;
let status = if access.enabled {
"enabled"
} else {
"disabled"
};
println!("iroh ssh access: {status}");
println!("authorized keys: {}", access.allowlist.len());
if access.enabled && access.allowlist.is_empty() {
println!("warning: access is enabled but allowlist is empty");
println!("fix: bmux access add --agent (or add keys via --key-file/--github-user)");
}
Ok(0)
}
pub(super) fn run_access_list(as_json: bool) -> Result<u8> {
let config = BmuxConfig::load()?;
let access = &config.connections.iroh_ssh_access;
let mut entries = access
.allowlist
.iter()
.map(|(fingerprint, key)| {
serde_json::json!({
"fingerprint": fingerprint,
"label": key.label,
"added_at_unix": key.added_at_unix,
"public_key": key.public_key,
})
})
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
left["fingerprint"]
.as_str()
.unwrap_or_default()
.cmp(right["fingerprint"].as_str().unwrap_or_default())
});
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"enabled": access.enabled,
"keys": entries,
}))
.context("failed serializing access list")?
);
return Ok(0);
}
println!(
"iroh ssh access: {}",
if access.enabled {
"enabled"
} else {
"disabled"
}
);
if entries.is_empty() {
println!("no authorized SSH keys");
return Ok(0);
}
for entry in entries {
let fingerprint = entry["fingerprint"].as_str().unwrap_or("-");
let label = entry["label"].as_str().unwrap_or("-");
println!("- {fingerprint} ({label})");
}
Ok(0)
}
pub(super) fn run_access_enable() -> Result<u8> {
let mut config = BmuxConfig::load()?;
if config.connections.iroh_ssh_access.allowlist.is_empty() {
anyhow::bail!(
"cannot enable iroh SSH access without at least one key; run `bmux access add ...` first"
);
}
config.connections.iroh_ssh_access.enabled = true;
config.save()?;
println!("iroh ssh access enabled");
println!("Tip: add a backup key to avoid lockout (`bmux access add ...`).");
Ok(0)
}
pub(super) fn run_access_disable() -> Result<u8> {
let mut config = BmuxConfig::load()?;
config.connections.iroh_ssh_access.enabled = false;
config.save()?;
println!("iroh ssh access disabled");
Ok(0)
}
pub(super) fn run_access_remove(fingerprint: &str, yes: bool) -> Result<u8> {
let mut config = BmuxConfig::load()?;
let access = &mut config.connections.iroh_ssh_access;
if !access.allowlist.contains_key(fingerprint) {
anyhow::bail!("SSH key not found in allowlist: {fingerprint}");
}
let removing_last = access.enabled && access.allowlist.len() == 1;
if removing_last && !yes {
anyhow::bail!(
"removing the last key while SSH access is enabled can lock you out; add a backup key or rerun with --yes"
);
}
access.allowlist.remove(fingerprint);
if access.enabled && access.allowlist.is_empty() {
access.enabled = false;
println!("removed last key; iroh SSH access was disabled to prevent lockout");
} else {
println!("removed SSH key: {fingerprint}");
}
config.save()?;
Ok(0)
}
pub(super) fn run_access_add(
agent: bool,
key_files: &[String],
public_keys: &[String],
github_users: &[String],
) -> Result<u8> {
let mut config = BmuxConfig::load()?;
let mut entries = collect_key_entries(agent, key_files, public_keys, github_users)?;
if entries.is_empty() {
if io::stdin().is_terminal() {
let candidates = discover_local_candidates()?;
entries = prompt_select_candidates(&candidates)?;
}
if entries.is_empty() {
anyhow::bail!(
"no keys selected; provide --agent/--key-file/--public-key/--github-user or run in interactive mode"
);
}
}
let inserted =
merge_allowlist_entries(&mut config.connections.iroh_ssh_access.allowlist, &entries);
config.save()?;
println!("added {inserted} SSH key(s) to iroh allowlist");
Ok(0)
}
pub(super) fn run_access_init(
agent: bool,
key_files: &[String],
public_keys: &[String],
github_users: &[String],
yes: bool,
) -> Result<u8> {
let mut config = BmuxConfig::load()?;
let mut entries = collect_key_entries(agent, key_files, public_keys, github_users)?;
if entries.is_empty() {
if !io::stdin().is_terminal() {
anyhow::bail!(
"access init needs key input in non-interactive mode; pass --agent/--key-file/--public-key/--github-user"
);
}
let candidates = discover_local_candidates()?;
entries = prompt_select_candidates(&candidates)?;
}
if entries.is_empty() {
anyhow::bail!("no keys selected for iroh SSH access init");
}
if !yes && io::stdin().is_terminal() {
print_init_confirmation(&entries)?;
}
let inserted =
merge_allowlist_entries(&mut config.connections.iroh_ssh_access.allowlist, &entries);
config.connections.iroh_ssh_access.enabled = true;
config.save()?;
println!("iroh SSH access initialized (enabled)");
println!(
"authorized keys now: {}",
config.connections.iroh_ssh_access.allowlist.len()
);
println!("added this run: {inserted}");
println!("Important: add a backup key to avoid lockout (`bmux access add ...`).");
Ok(0)
}
fn print_init_confirmation(entries: &[KeyEntry]) -> Result<()> {
println!("The following SSH keys will be authorized and SSH access will be enabled:");
for entry in entries {
println!("- {} ({})", entry.fingerprint, entry.source);
}
println!(
"If none of these keys are available later, you may lose remote access until SSH access is disabled locally."
);
println!("Continue? [y/N]");
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.context("failed reading confirmation input")?;
let accepted = matches!(input.trim().to_ascii_lowercase().as_str(), "y" | "yes");
if !accepted {
anyhow::bail!("access init cancelled");
}
Ok(())
}
fn merge_allowlist_entries(
allowlist: &mut BTreeMap<String, IrohSshAuthorizedKey>,
entries: &[KeyEntry],
) -> usize {
let mut inserted = 0usize;
for entry in entries {
let key = IrohSshAuthorizedKey {
public_key: entry.public_key.clone(),
label: Some(entry.source.clone()),
added_at_unix: Some(current_unix_timestamp()),
};
if allowlist.insert(entry.fingerprint.clone(), key).is_none() {
inserted = inserted.saturating_add(1);
}
}
inserted
}
fn collect_key_entries(
agent: bool,
key_files: &[String],
public_keys: &[String],
github_users: &[String],
) -> Result<Vec<KeyEntry>> {
let mut entries = Vec::new();
if agent {
for key in list_agent_keys()? {
let public_key = key
.public_key
.to_openssh()
.context("failed encoding SSH agent key")?;
entries.push(KeyEntry {
fingerprint: key.fingerprint,
public_key,
source: "ssh-agent".to_string(),
});
}
}
for key_file in key_files {
let public_key = read_public_key_file(key_file)?;
let fingerprint = fingerprint_for_public_key_line(&public_key)
.ok_or_else(|| anyhow::anyhow!("invalid public key in file: {key_file}"))?;
entries.push(KeyEntry {
fingerprint,
public_key,
source: key_file.clone(),
});
}
for public_key in public_keys {
let normalized = normalize_public_key_line(public_key)?;
let fingerprint = fingerprint_for_public_key_line(&normalized)
.ok_or_else(|| anyhow::anyhow!("invalid --public-key input"))?;
entries.push(KeyEntry {
fingerprint,
public_key: normalized,
source: "inline".to_string(),
});
}
for github_user in github_users {
let fetched = fetch_github_user_keys(github_user)
.with_context(|| format!("failed fetching keys for GitHub user '{github_user}'"))?;
for key in fetched.keys {
let normalized = normalize_public_key_line(&key)
.with_context(|| format!("invalid key returned for GitHub user '{github_user}'"))?;
let fingerprint = fingerprint_for_public_key_line(&normalized).ok_or_else(|| {
anyhow::anyhow!("invalid key returned for GitHub user '{github_user}'")
})?;
entries.push(KeyEntry {
fingerprint,
public_key: normalized,
source: format!("github:{github_user}"),
});
}
}
Ok(deduplicate_key_entries(entries))
}
fn discover_local_candidates() -> Result<Vec<KeyEntry>> {
let mut entries = Vec::new();
for key in list_agent_keys()? {
entries.push(KeyEntry {
fingerprint: key.fingerprint,
public_key: key
.public_key
.to_openssh()
.context("failed encoding SSH agent key")?,
source: "ssh-agent".to_string(),
});
}
for path in default_public_key_candidates() {
if !path.exists() {
continue;
}
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("failed reading {}", path.display()))?;
let normalized = normalize_public_key_line(&raw)
.with_context(|| format!("invalid public key in {}", path.display()))?;
let Some(fingerprint) = fingerprint_for_public_key_line(&normalized) else {
continue;
};
entries.push(KeyEntry {
fingerprint,
public_key: normalized,
source: path.display().to_string(),
});
}
let deduped = deduplicate_key_entries(entries);
if deduped.is_empty() {
anyhow::bail!(
"no local SSH keys discovered; use --public-key, --key-file, or --github-user"
);
}
Ok(deduped)
}
fn prompt_select_candidates(candidates: &[KeyEntry]) -> Result<Vec<KeyEntry>> {
println!("Discovered SSH keys. Select which key(s) to authorize:");
for (index, entry) in candidates.iter().enumerate() {
println!("{}. {} ({})", index + 1, entry.fingerprint, entry.source);
}
println!("Enter comma-separated numbers (example: 1,3), 'all', or press Enter to cancel.");
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.context("failed reading key selection")?;
let trimmed = input.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}
if trimmed.eq_ignore_ascii_case("all") {
return Ok(candidates.to_vec());
}
let mut selected = Vec::new();
for token in trimmed
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
{
let index = token
.parse::<usize>()
.with_context(|| format!("invalid selection '{token}'"))?;
if index == 0 || index > candidates.len() {
anyhow::bail!("selection out of range: {token}");
}
selected.push(candidates[index - 1].clone());
}
Ok(deduplicate_key_entries(selected))
}
fn read_public_key_file(path: &str) -> Result<String> {
let expanded = if let Some(rest) = path.strip_prefix("~/") {
dirs::home_dir()
.map(|home| home.join(rest))
.ok_or_else(|| anyhow::anyhow!("failed resolving home directory for path: {path}"))?
} else {
std::path::PathBuf::from(path)
};
let raw = std::fs::read_to_string(&expanded)
.with_context(|| format!("failed reading {}", expanded.display()))?;
normalize_public_key_line(&raw)
}
fn normalize_public_key_line(raw: &str) -> Result<String> {
let line = raw
.lines()
.find(|value| !value.trim().is_empty())
.map(str::trim)
.ok_or_else(|| anyhow::anyhow!("public key is empty"))?;
if fingerprint_for_public_key_line(line).is_none() {
anyhow::bail!("invalid OpenSSH public key line");
}
Ok(line.to_string())
}
fn deduplicate_key_entries(entries: Vec<KeyEntry>) -> Vec<KeyEntry> {
let mut by_fingerprint = BTreeMap::new();
for entry in entries {
by_fingerprint
.entry(entry.fingerprint.clone())
.or_insert(entry);
}
by_fingerprint.into_values().collect()
}
fn current_unix_timestamp() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |value| i64::try_from(value.as_secs()).unwrap_or(0))
}