From 4973cbaf26111f29a2c0f1475d45e9cd9015e114 Mon Sep 17 00:00:00 2001 From: Yaroslav Bolyukin Date: Thu, 12 Mar 2026 03:41:06 +0000 Subject: [PATCH] feat: secrets cmd update --- --- /dev/null +++ b/cmds/fleet/src/cmds/secrets.rs @@ -0,0 +1,301 @@ +use std::{ + collections::{BTreeSet, HashSet}, + io::{Read, stdin}, + path::PathBuf, +}; + +use anyhow::{Context as _, Result, anyhow, bail, ensure}; +use clap::Parser; +use fleet_base::{fleetdata::SecretOwner, host::Config, opts::FleetOpts}; +use fleet_shared::SecretData; +use itertools::{ExactlyOneError, Itertools as _}; +use tokio::fs::read; +use tracing::{info, warn}; + +#[derive(Parser)] +pub enum Secret { + /// Force load host keys for all defined hosts + ForceKeys, + /// Read secret from remote host, requires sudo on one of the owning hosts + Read { + /// Secret name to read + name: String, + + /// Distribution with what machine to read + /// If not shared between multiple - defaults to single owner + #[clap(short = 'm', long)] + machine: Option, + + /// Which private secret part to read + #[clap(short = 'p', long, default_value = "secret")] + part: Option, + + /// Which host should we use to decrypt, in case if reencryption is required, without + /// regeneration + #[clap(long)] + prefer_identities: Vec, + }, + /// Prune (remove, mark for regeneration) secrets + Prune { + /// Secret to prune + name: String, + + /// Machines to prune - if specified, only the choosen machines will be pruned + #[clap(short = 'm', long)] + machine: Vec, + }, + /// Ensure secret is generated and not expired + Ensure { + /// Secret to ensure generated + name: String, + + /// Machines to force secret for + #[clap(short = 'm', long)] + machine: Vec, + }, + List {}, + Edit { + name: String, + #[clap(short = 'm', long)] + machine: String, + + #[clap(long)] + add: bool, + + /// Which private secret part to read + #[clap(short = 'p', long, default_value = "secret")] + part: String, + }, +} + +impl Secret { + pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> { + match self { + Secret::ForceKeys => { + for host in config.list_hosts()? { + if opts.should_skip(&host)? { + continue; + } + config.host_key(&host.name).await?; + } + } + Secret::Read { + name, + machine, + part: part_name, + mut prefer_identities, + } => { + let secret = config.data.secrets.read().expect("not poisoned"); + + let Some(dist) = secret.get("name") else { + bail!("secret doesn't exists"); + }; + + let dist = if let Some(machine) = &machine { + dist.get(&SecretOwner::host(machine)) + .ok_or_else(|| anyhow!("machine {machine} has no secret generated"))? + } else { + dist.distributions() + .exactly_one() + .map_err(|e| anyhow!("{e}")) + .context( + "with no machine specified, there should be exactly one distribution", + )? + }; + + let part_name = part_name.unwrap_or_else(|| "secret".to_string()); + let Some(part) = dist.secret.parts.get(&part_name) else { + bail!("secret part {part_name:?} is not defined"); + }; + + // dist.get(SecretOwner(name)); + + todo!(); + /* + let Some(secret) = config.shared_secret(&name) else { + bail!("secret doesn't exists"); + }; + + let dist = if secret.len() == 1 { + &secret[0] + } else if let Some(machine) = machine { + let dist = secret.get(&machine); + let Some(dist) = dist else { + bail!("machine {machine} has no distribution of secret {name}"); + }; + prefer_identities.push(machine); + dist + } else { + bail!( + "secret {name} has shares, but no --machine specified for specifing which do you need" + ) + }; + + let Some(part) = dist.secret.parts.get(&part_name) else { + bail!("no part {part_name} in secret {name}"); + }; + let data = if part.raw.encrypted { + let identity_holder = if !prefer_identities.is_empty() { + prefer_identities + .iter() + .find(|i| dist.owners.iter().any(|s| s == *i)) + } else { + dist.owners.first() + }; + let Some(identity_holder) = identity_holder else { + bail!("no available holder found"); + }; + let host = config.host(identity_holder)?; + host.decrypt(part.raw.clone()).await? + } else { + part.raw.data.clone() + }; + stdout().write_all(&data)?; + */ + todo!() + } + Secret::List {} => { + /* + let _span = info_span!("loading secrets").entered(); + let configured = config.list_configured_shared()?; + #[derive(Tabled)] + struct SecretDisplay { + #[tabled(rename = "Name")] + name: String, + #[tabled(rename = "Owners")] + owners: String, + } + // let mut table = vec![]; + for name in configured.iter().cloned() { + let config = config.clone(); + let data = config.shared_secret(&name).expect("exists"); + /* + let definition = config.shared_secret_definition(&name)?; + let expectations = definition.expectations()?; + let owners = data + .owners() + .map(|o| { + if expectations.owners.contains(o) { + o.green().to_string() + } else { + o.red().to_string() + } + }) + .collect::>(); + table.push(SecretDisplay { + owners: owners.join(", "), + name, + }) + */ + } + // info!("loaded\n{}", Table::new(table).to_string()) + */ + todo!() + } + Secret::Edit { + name, + machine, + part, + add, + } => { + /*let secret = config + .host_secret(&machine, &name) + .context("secret not found")?; + if let Some(data) = secret.secret.parts.get(&part) { + let host = config.host(&machine)?; + let secret = host.decrypt(data.raw.clone()).await?; + String::from_utf8(secret).context("secret is not utf8")? + } else if add { + String::new() + } else { + bail!("part {part} not found in secret {name}. Did you mean to `--add` it?"); + };*/ + todo!() + } + Secret::Prune { name, machine } => todo!(), + Secret::Ensure { name, machine } => todo!(), + } + Ok(()) + } +} + +/* +async fn edit_temp_file( + builder: tempfile::Builder<'_, '_>, + r: Vec, + header: &str, + comment: &str, +) -> Result<(Vec, Option), anyhow::Error> { + if !stdin().is_tty() { + // TODO: Also try to open /dev/tty directly? + bail!("stdin is not tty, can't open editor"); + } + + use std::fmt::Write; + let mut file = builder.tempfile()?; + + let mut full_header = String::new(); + let mut had = false; + for line in header.trim_end().lines() { + had = true; + writeln!(&mut full_header, "{comment}{line}")?; + } + if had { + writeln!(&mut full_header, "{}", comment.trim_end())?; + } + writeln!( + &mut full_header, + "{comment}Do not touch this header! It will be removed automatically" + )?; + + file.write_all(full_header.as_bytes())?; + file.write_all(&r)?; + + let abs_path = file.into_temp_path(); + let editor = std::env::var_os("VISUAL") + .or_else(|| std::env::var_os("EDITOR")) + .unwrap_or_else(|| "vi".into()); + let editor_args = shlex::bytes::split(editor.as_encoded_bytes()) + .ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?; + let editor_args = editor_args + .into_iter() + .map(|v| { + // Only ASCII subsequences are replaced + unsafe { OsString::from_encoded_bytes_unchecked(v) } + }) + .collect_vec(); + let Some((editor, args)) = editor_args.split_first() else { + bail!("EDITOR env var has no command"); + }; + let mut command = Command::new(editor); + command.args(args); + + let path_arg = abs_path.canonicalize()?; + + // TODO: Save full state, using tcget/_getmode/_setmode + let was_raw = terminal::is_raw_mode_enabled()?; + terminal::enable_raw_mode()?; + + let status = command.arg(path_arg).status().await; + + if !was_raw { + terminal::disable_raw_mode()?; + } + + let success = match status { + Ok(s) => s.success(), + Err(e) if e.kind() == io::ErrorKind::NotFound => { + bail!("editor not found") + } + Err(e) => bail!("editor spawn error: {e}"), + }; + + let mut file = std::fs::read(&abs_path).context("read editor output")?; + let Some(v) = file.strip_prefix(full_header.as_bytes()) else { + todo!(); + }; + todo!(); + + // Ok((success, abs_path)) +} +*/ --- a/cmds/fleet/src/cmds/secrets/mod.rs +++ /dev/null @@ -1,352 +0,0 @@ -use std::{ - collections::{BTreeSet, HashSet}, - io::{Read, stdin}, - path::PathBuf, -}; - -use anyhow::{Result, bail, ensure}; -use clap::Parser; -use fleet_base::{host::Config, opts::FleetOpts}; -use fleet_shared::SecretData; -use tokio::fs::read; -use tracing::{info, warn}; - -#[derive(Parser)] -pub enum Secret { - /// Force load host keys for all defined hosts - ForceKeys, - /// Read secret from remote host, requires sudo on one of the owning hosts - Read { - /// Secret name to read - name: String, - - /// Distribution with what machine to read - /// If not shared between multiple - defaults to single owner - #[clap(short = 'm', long)] - machine: Option, - - /// Which private secret part to read - #[clap(short = 'p', long, default_value = "secret")] - part: Option, - - /// Which host should we use to decrypt, in case if reencryption is required, without - /// regeneration - #[clap(long)] - prefer_identities: Vec, - }, - /// Prune (remove, mark for regeneration) secrets - Prune { - /// Secret to prune - name: String, - - /// Machines to prune - if specified, only the choosen machines will be pruned - #[clap(short = 'm', long)] - machine: Vec, - }, - /// Ensure secret is generated and not expired - Ensure { - /// Secret to ensure generated - name: String, - - /// Machines to force secret for - #[clap(short = 'm', long)] - machine: Vec, - }, - List {}, - Edit { - name: String, - #[clap(short = 'm', long)] - machine: String, - - #[clap(long)] - add: bool, - - /// Which private secret part to read - #[clap(short = 'p', long, default_value = "secret")] - part: String, - }, -} - -async fn parse_public( - public: Option, - public_file: Option, -) -> Result> { - Ok(match (public, public_file) { - (Some(v), None) => Some(SecretData { - data: v.into(), - encrypted: false, - }), - (None, Some(v)) => Some(SecretData { - data: read(v).await?, - encrypted: false, - }), - (Some(_), Some(_)) => { - bail!("only public or public_file should be set") - } - (None, None) => None, - }) -} - -async fn parse_secret() -> Result>> { - let mut input = vec![]; - stdin().read_to_end(&mut input)?; - if input.is_empty() { - Ok(None) - } else { - Ok(Some(input)) - } -} - -fn parse_machines( - initial: BTreeSet, - machines: Option>, - mut add_machines: Vec, - mut remove_machines: Vec, -) -> Result> { - if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() { - bail!("no operation"); - } - - let initial_machines = initial.clone(); - let mut target_machines = initial; - info!("Currently encrypted for {initial_machines:?}"); - - if let Some(machines) = machines { - ensure!( - add_machines.is_empty() && remove_machines.is_empty(), - "can't combine --machines and --add-machines/--remove-machines" - ); - let target = initial_machines.iter().collect::>(); - let source = machines.iter().collect::>(); - for removed in target.difference(&source) { - remove_machines.push((*removed).clone()); - } - for added in source.difference(&target) { - add_machines.push((*added).clone()); - } - } - - for machine in &remove_machines { - if !target_machines.remove(machine) { - warn!("secret is not enabled for {machine}"); - } - } - for machine in &add_machines { - if !target_machines.insert(machine.to_owned()) { - warn!("secret is already added to {machine}"); - } - } - if !remove_machines.is_empty() { - // TODO: maybe force secret regeneration? - // Not that useful without revokation. - warn!( - "secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret" - ); - } - Ok(target_machines) -} -impl Secret { - pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> { - match self { - Secret::ForceKeys => { - for host in config.list_hosts()? { - if opts.should_skip(&host)? { - continue; - } - config.host_key(&host.name).await?; - } - } - Secret::Read { - name, - machine, - part: part_name, - mut prefer_identities, - } => { - /* - let Some(secret) = config.shared_secret(&name) else { - bail!("secret doesn't exists"); - }; - - let dist = if secret.len() == 1 { - &secret[0] - } else if let Some(machine) = machine { - let dist = secret.get(&machine); - let Some(dist) = dist else { - bail!("machine {machine} has no distribution of secret {name}"); - }; - prefer_identities.push(machine); - dist - } else { - bail!( - "secret {name} has shares, but no --machine specified for specifing which do you need" - ) - }; - - let Some(part) = dist.secret.parts.get(&part_name) else { - bail!("no part {part_name} in secret {name}"); - }; - let data = if part.raw.encrypted { - let identity_holder = if !prefer_identities.is_empty() { - prefer_identities - .iter() - .find(|i| dist.owners.iter().any(|s| s == *i)) - } else { - dist.owners.first() - }; - let Some(identity_holder) = identity_holder else { - bail!("no available holder found"); - }; - let host = config.host(identity_holder)?; - host.decrypt(part.raw.clone()).await? - } else { - part.raw.data.clone() - }; - stdout().write_all(&data)?; - */ - todo!() - } - Secret::List {} => { - /* - let _span = info_span!("loading secrets").entered(); - let configured = config.list_configured_shared()?; - #[derive(Tabled)] - struct SecretDisplay { - #[tabled(rename = "Name")] - name: String, - #[tabled(rename = "Owners")] - owners: String, - } - // let mut table = vec![]; - for name in configured.iter().cloned() { - let config = config.clone(); - let data = config.shared_secret(&name).expect("exists"); - /* - let definition = config.shared_secret_definition(&name)?; - let expectations = definition.expectations()?; - let owners = data - .owners() - .map(|o| { - if expectations.owners.contains(o) { - o.green().to_string() - } else { - o.red().to_string() - } - }) - .collect::>(); - table.push(SecretDisplay { - owners: owners.join(", "), - name, - }) - */ - } - // info!("loaded\n{}", Table::new(table).to_string()) - */ - todo!() - } - Secret::Edit { - name, - machine, - part, - add, - } => { - /*let secret = config - .host_secret(&machine, &name) - .context("secret not found")?; - if let Some(data) = secret.secret.parts.get(&part) { - let host = config.host(&machine)?; - let secret = host.decrypt(data.raw.clone()).await?; - String::from_utf8(secret).context("secret is not utf8")? - } else if add { - String::new() - } else { - bail!("part {part} not found in secret {name}. Did you mean to `--add` it?"); - };*/ - todo!() - } - Secret::Prune { name, machine } => todo!(), - Secret::Ensure { name, machine } => todo!(), - } - Ok(()) - } -} - -/* -async fn edit_temp_file( - builder: tempfile::Builder<'_, '_>, - r: Vec, - header: &str, - comment: &str, -) -> Result<(Vec, Option), anyhow::Error> { - if !stdin().is_tty() { - // TODO: Also try to open /dev/tty directly? - bail!("stdin is not tty, can't open editor"); - } - - use std::fmt::Write; - let mut file = builder.tempfile()?; - - let mut full_header = String::new(); - let mut had = false; - for line in header.trim_end().lines() { - had = true; - writeln!(&mut full_header, "{comment}{line}")?; - } - if had { - writeln!(&mut full_header, "{}", comment.trim_end())?; - } - writeln!( - &mut full_header, - "{comment}Do not touch this header! It will be removed automatically" - )?; - - file.write_all(full_header.as_bytes())?; - file.write_all(&r)?; - - let abs_path = file.into_temp_path(); - let editor = std::env::var_os("VISUAL") - .or_else(|| std::env::var_os("EDITOR")) - .unwrap_or_else(|| "vi".into()); - let editor_args = shlex::bytes::split(editor.as_encoded_bytes()) - .ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?; - let editor_args = editor_args - .into_iter() - .map(|v| { - // Only ASCII subsequences are replaced - unsafe { OsString::from_encoded_bytes_unchecked(v) } - }) - .collect_vec(); - let Some((editor, args)) = editor_args.split_first() else { - bail!("EDITOR env var has no command"); - }; - let mut command = Command::new(editor); - command.args(args); - - let path_arg = abs_path.canonicalize()?; - - // TODO: Save full state, using tcget/_getmode/_setmode - let was_raw = terminal::is_raw_mode_enabled()?; - terminal::enable_raw_mode()?; - - let status = command.arg(path_arg).status().await; - - if !was_raw { - terminal::disable_raw_mode()?; - } - - let success = match status { - Ok(s) => s.success(), - Err(e) if e.kind() == io::ErrorKind::NotFound => { - bail!("editor not found") - } - Err(e) => bail!("editor spawn error: {e}"), - }; - - let mut file = std::fs::read(&abs_path).context("read editor output")?; - let Some(v) = file.strip_prefix(full_header.as_bytes()) else { - todo!(); - }; - todo!(); - - // Ok((success, abs_path)) -} -*/ -- gitstuff