mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
refs #3731 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
296 lines
9.3 KiB
Rust
296 lines
9.3 KiB
Rust
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_saved_ssh_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"));
|
|
}
|
|
}
|