diff options
Diffstat (limited to 'src/cli/machine.rs')
| -rw-r--r-- | src/cli/machine.rs | 295 |
1 files changed, 295 insertions, 0 deletions
diff --git a/src/cli/machine.rs b/src/cli/machine.rs new file mode 100644 index 00000000..a55e4611 --- /dev/null +++ b/src/cli/machine.rs @@ -0,0 +1,295 @@ +use serde::Serialize; + +use crate::client::endpoint::{EndpointCatalog, ProfileId}; + +const HELP: &str = "Usage: + herdr machine list [--json] + herdr machine add <ssh-target> --label <label> [--remote-session <name>] + herdr machine rename <profile-id> --label <label> + herdr machine remove <profile-id> + herdr machine enable <profile-id> + herdr machine disable <profile-id> + +Add prepares the remote Herdr installation and starts its server before saving. +Missing or incompatible installations require approval in an interactive terminal. +Changes apply automatically to open local Herdr clients. +Removing or disabling a machine leaves its remote sessions running. +Saved machines contain only a label, SSH target, explicit Herdr session, and enabled state. +SSH credentials and key material remain owned by OpenSSH."; + +#[derive(Serialize)] +struct MachineListRow<'a> { + id: &'a str, + label: &'a str, + target: &'a str, + session: &'a str, + enabled: bool, + selected: bool, +} + +pub(super) fn run_machine_command(args: &[String]) -> std::io::Result<i32> { + match args.first().map(String::as_str) { + Some("list") => list(&args[1..]), + Some("add") => add(&args[1..]), + Some("rename") => rename(&args[1..]), + Some("remove") => remove(&args[1..]), + Some("enable") => set_enabled(&args[1..], true), + Some("disable") => set_enabled(&args[1..], false), + Some("help" | "--help" | "-h") => { + println!("{HELP}"); + Ok(0) + } + _ => { + eprintln!("{HELP}"); + Ok(2) + } + } +} + +fn list(args: &[String]) -> std::io::Result<i32> { + let json = match args { + [] => false, + [flag] if flag == "--json" => true, + _ => { + eprintln!("usage: herdr machine list [--json]"); + return Ok(2); + } + }; + let catalog = load_catalog()?; + let rows = catalog + .ssh + .iter() + .map(|profile| MachineListRow { + id: profile.id.as_str(), + label: &profile.label, + target: &profile.target, + session: &profile.session, + enabled: profile.enabled, + selected: catalog.selected_profile.as_ref() == Some(&profile.id), + }) + .collect::<Vec<_>>(); + if json { + println!( + "{}", + serde_json::to_string_pretty(&rows).map_err(std::io::Error::other)? + ); + return Ok(0); + } + if rows.is_empty() { + println!("No saved SSH machines."); + return Ok(0); + } + for row in rows { + let state = if row.enabled { "enabled" } else { "disabled" }; + println!( + "{}\t{}\t{}\t{}\t{}", + row.id, row.label, row.target, row.session, state + ); + } + Ok(0) +} + +fn add(args: &[String]) -> std::io::Result<i32> { + let args = super::expand_equals_args(args, &["--label", "--remote-session"]); + let Some(target) = args.first().filter(|value| !value.starts_with('-')) else { + eprintln!( + "usage: herdr machine add <ssh-target> --label <label> [--remote-session <name>]" + ); + return Ok(2); + }; + let mut label = None; + let mut session = None; + let mut index = 1; + while index < args.len() { + let (name, value) = match args[index].as_str() { + "--label" | "--remote-session" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for {}", args[index]); + return Ok(2); + }; + index += 2; + (args[index - 2].as_str(), value.clone()) + } + unknown => { + eprintln!("unknown machine add option: {unknown}"); + return Ok(2); + } + }; + match name { + "--label" if label.is_none() => label = Some(value), + "--remote-session" if session.is_none() => session = Some(value), + "--remote-session" => { + eprintln!("--remote-session can only be specified once"); + return Ok(2); + } + "--label" => { + eprintln!("--label can only be specified once"); + return Ok(2); + } + _ => unreachable!("validated machine add option"), + } + } + let Some(label) = label else { + eprintln!("--label is required"); + return Ok(2); + }; + let session = session.unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_owned()); + let mut catalog = load_catalog()?; + match catalog.add_ssh(label.clone(), target, session.clone()) { + Ok(_) => {} + Err(error) => { + eprintln!("error: {error}"); + return Ok(2); + } + } + if let Err(error) = crate::remote::prepare_saved_ssh(target, &session) { + eprintln!("error: {error}; machine was not saved"); + crate::remote::print_remote_error_hint(&error, target); + return Ok(1); + } + // Setup can wait for human approval. Do not overwrite catalog edits made meanwhile. + let mut catalog = load_catalog().map_err(|error| { + std::io::Error::other(format!( + "remote prepared, but machine was not saved: {error}" + )) + })?; + let id = match catalog.add_ssh(label, target, session) { + Ok(id) => id, + Err(error) => { + eprintln!("error: {error}"); + return Ok(2); + } + }; + store_catalog(&catalog).map_err(|error| { + std::io::Error::other(format!( + "remote prepared, but machine was not saved: {error}" + )) + })?; + println!("Saved SSH machine {id}. Remote server is ready."); + println!("Open Herdr clients connect automatically."); + Ok(0) +} + +fn rename(args: &[String]) -> std::io::Result<i32> { + let args = super::expand_equals_args(args, &["--label"]); + let [raw_id, flag, label] = args.as_slice() else { + eprintln!("usage: herdr machine rename <profile-id> --label <label>"); + return Ok(2); + }; + if flag != "--label" { + eprintln!("usage: herdr machine rename <profile-id> --label <label>"); + return Ok(2); + } + let id = match ProfileId::parse(raw_id.clone()) { + Ok(id) => id, + Err(error) => { + eprintln!("error: {error}"); + return Ok(2); + } + }; + let mut catalog = load_catalog()?; + match catalog.rename_ssh(&id, label) { + Ok(true) => {} + Ok(false) => { + eprintln!("machine profile {id} was not found"); + return Ok(1); + } + Err(error) => { + eprintln!("error: {error}"); + return Ok(2); + } + } + store_catalog(&catalog)?; + println!("Renamed SSH machine {id}."); + Ok(0) +} + +fn remove(args: &[String]) -> std::io::Result<i32> { + let Some(id) = one_profile_id(args, "usage: herdr machine remove <profile-id>")? else { + return Ok(2); + }; + let mut catalog = load_catalog()?; + let previous_selection = catalog.selected_profile.clone(); + if !catalog.remove_ssh(&id) { + eprintln!("machine profile {id} was not found"); + return Ok(1); + } + store_catalog(&catalog)?; + if catalog.selected_profile != previous_selection { + catalog.store_selection().map_err(std::io::Error::other)?; + } + println!("Removed SSH machine {id}."); + Ok(0) +} + +fn set_enabled(args: &[String], enabled: bool) -> std::io::Result<i32> { + let action = if enabled { "enable" } else { "disable" }; + let usage = format!("usage: herdr machine {action} <profile-id>"); + let Some(id) = one_profile_id(args, &usage)? else { + return Ok(2); + }; + let mut catalog = load_catalog()?; + let previous_selection = catalog.selected_profile.clone(); + if !catalog.set_enabled(&id, enabled) { + eprintln!("machine profile {id} was not found"); + return Ok(1); + } + store_catalog(&catalog)?; + if catalog.selected_profile != previous_selection { + catalog.store_selection().map_err(std::io::Error::other)?; + } + println!( + "{} SSH machine {id}.", + if enabled { "Enabled" } else { "Disabled" } + ); + Ok(0) +} + +fn one_profile_id(args: &[String], usage: &str) -> std::io::Result<Option<ProfileId>> { + let [raw] = args else { + eprintln!("{usage}"); + return Ok(None); + }; + match ProfileId::parse(raw.clone()) { + Ok(id) => Ok(Some(id)), + Err(error) => { + eprintln!("error: {error}"); + Ok(None) + } + } +} + +fn load_catalog() -> std::io::Result<EndpointCatalog> { + EndpointCatalog::load().map_err(std::io::Error::other) +} + +fn store_catalog(catalog: &EndpointCatalog) -> std::io::Result<()> { + catalog.store_profiles().map_err(std::io::Error::other) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_id_parser_rejects_target_text() { + assert!(one_profile_id(&["build.example".into()], "usage") + .unwrap() + .is_none()); + } + + #[test] + fn list_rows_do_not_have_credential_fields() { + let encoded = serde_json::to_string(&MachineListRow { + id: "0123456789abcdef0123456789abcdef", + label: "Build", + target: "dev@build", + session: "agents", + enabled: true, + selected: false, + }) + .unwrap(); + assert!(!encoded.contains("password")); + assert!(!encoded.contains("key")); + } +} |
