From 904d12180e52796b37be3792a6050eba8894c6f6 Mon Sep 17 00:00:00 2001 From: Yaroslav Bolyukin Date: Thu, 28 Dec 2023 22:30:09 +0000 Subject: [PATCH] refactor: minor rewrites --- --- a/cmds/fleet/src/better_nix_eval.rs +++ b/cmds/fleet/src/better_nix_eval.rs @@ -247,7 +247,7 @@ Ok(()) } async fn send_command(&mut self, cmd: impl AsRef<[u8]>) -> Result<()> { - if tracing::enabled!(Level::DEBUG) { + if tracing::enabled!(Level::DEBUG) && cmd.as_ref() != REPL_DELIMITER.as_bytes() { let cmd_str = String::from_utf8_lossy(cmd.as_ref()); tracing::debug!("{cmd_str}"); }; @@ -627,13 +627,6 @@ } pub async fn field(session: NixSession, field: &str) -> Result { Self::root(session).select([Index::var(field)]).await - } - pub async fn get_json_deep<'a, V: DeserializeOwned>( - &self, - name: impl IntoIterator, - ) -> Result { - let field = self.select(name).await?; - field.as_json().await } pub async fn select<'a>(&self, name: impl IntoIterator) -> Result { let mut used_fields = Vec::new(); @@ -719,6 +712,19 @@ .await .with_context(|| context(self.0.full_path.as_deref(), &query)) } + pub async fn has_field(&self, name: &str) -> Result { + let id = self.0.value.expect("can't list root fields"); + let key = nixlike::escape_string(name); + let query = format!("sess_field_{id} ? {key}"); + self.0 + .session + .0 + .lock() + .await + .execute_expression_to_json(&query) + .await + .with_context(|| context(self.0.full_path.as_deref(), &query)) + } pub async fn list_fields(&self) -> Result> { let id = self.0.value.expect("can't list root fields"); let query = format!("builtins.attrNames sess_field_{id}"); @@ -731,6 +737,18 @@ .await .with_context(|| context(self.0.full_path.as_deref(), &query)) } + pub async fn type_of(&self) -> Result { + let id = self.0.value.expect("can't list root fields"); + let query = format!("builtins.typeOf sess_field_{id}"); + self.0 + .session + .0 + .lock() + .await + .execute_expression_to_json(&query) + .await + .with_context(|| context(self.0.full_path.as_deref(), &query)) + } pub async fn build(&self) -> Result> { let id = self.0.value.expect("can't use build on not-value"); let query = format!(":b sess_field_{id}"); --- a/cmds/fleet/src/cmds/secrets/mod.rs +++ b/cmds/fleet/src/cmds/secrets/mod.rs @@ -1,6 +1,6 @@ use crate::{ - command::MyCommand, - fleetdata::{FleetSecret, FleetSharedSecret}, + better_nix_eval::Field, + fleetdata::{FleetSecret, FleetSharedSecret, SecretData}, host::Config, nix_go, nix_go_json, }; @@ -8,16 +8,16 @@ use chrono::{DateTime, Utc}; use clap::Parser; use futures::{StreamExt, TryStreamExt}; +use itertools::Itertools; use owo_colors::OwoColorize; use std::{ collections::HashSet, io::{self, Cursor, Read}, path::PathBuf, - sync::Arc, }; use tabled::{Table, Tabled}; use tokio::fs::read_to_string; -use tracing::{error, info, info_span, warn}; +use tracing::{info, info_span, warn}; #[derive(Parser)] pub enum Secret { @@ -90,82 +90,153 @@ prefer_identities: Vec, }, List {}, - InvokeGenerator, } -impl Secret { - pub async fn run(self, config: &Config) -> Result<()> { - match self { - Secret::InvokeGenerator => { - let config_field = &config.config_unchecked_field; +async fn generate_shared( + config: &Config, + display_name: &str, + secret: Field, +) -> Result { + Ok(if secret.has_field("generateImpure").await? { + let config_field = &config.config_unchecked_field; + let generate = nix_go!(secret.generateImpure); + let owners: Vec = nix_go_json!(secret.expectedOwners); - let secret = - nix_go!(config_field.configUnchecked.sharedSecrets["kube-apiserver.pem"]); - let generate_impure = nix_go!(secret.generateImpure); - let on = nix_go!(generate_impure.on); - let call_package = nix_go!( - config_field.buildableSystems(Obj { - localSystem: { config.local_system.clone() } - })[on] - .config - .nixpkgs - .resolvedPkgs - .callPackage - ); - let generator = nix_go!(call_package(generate_impure.generator)(Obj {})); - let built = &generator.build().await?["out"]; - let mut nix = MyCommand::new("nix"); - let on: String = on.as_json().await?; - nix.arg("copy") - .arg("--substitute-on-destination") - .comparg("--to", format!("ssh-ng://{on}")) - .arg(built); - nix.run_nix().await?; + let on: String = nix_go_json!(generate.on); + let call_package = nix_go!( + config_field.buildableSystems(Obj { + localSystem: { config.local_system.clone() } + })[{ on }] + .config + .nixpkgs + .resolvedPkgs + .callPackage + ); - let session = config.host(&on).await?; + let host = config.host(&on).await?; - let owners: Vec = nix_go_json!(secret.expectedOwners); - dbg!(&owners); + let generator = nix_go!(call_package(generate.generator)(Obj {})); + let generator = generator.build().await?; + let generator = generator + .get("out") + .ok_or_else(|| anyhow!("missing generateImpure out"))?; + let generator = host.remote_derivation(generator).await?; - let mut recipients = String::new(); - for owner in owners { - let key = config.key(&owner).await?; - recipients.push_str(&format!("-r \"{key}\" ")); - } - recipients.push_str("-e"); + let mut recipients = String::new(); + for owner in &owners { + let key = config.key(owner).await?; + recipients.push_str(&format!("-r \"{key}\" ")); + } + recipients.push_str("-e"); - // FIXME: security: created directory might be accessible to other users - // This shouldn't be much of a concern, as data is encrypted right after creation, yet - // still better to have. - let tempdir = session.mktemp_dir().await?; + let out = host.mktemp_dir().await?; - let mut gen = session.cmd(built).await?; - gen.env("rageArgs", recipients).env("out", &tempdir); - gen.run().await?; + let mut gen = host.cmd(generator).await?; + gen.env("rageArgs", recipients).env("out", &out); + gen.run().await?; - { - let marker = session.read_file_text(format!("{tempdir}/marker")).await?; - ensure!(marker == "SUCCESS", "generation not succeeded"); - } + { + let marker = host.read_file_text(format!("{out}/marker")).await?; + ensure!(marker == "SUCCESS", "generation not succeeded"); + } - let public = session - .read_file_bin(format!("{tempdir}/public")) - .await - .ok(); - let secret = session - .read_file_bin(format!("{tempdir}/secret")) - .await - .ok(); - if let Some(secret) = &secret { - ensure!( - age::Decryptor::new(Cursor::new(&secret)).is_ok(), - "builder produced non-encrypted value as secret, this is highly insecure" - ); - } - dbg!(&secret); - // // .as_json().await?; - // dbg!(&built); - } + let public = host.read_file_text(format!("{out}/public")).await.ok(); + let secret = host.read_file_bin(format!("{out}/secret")).await.ok(); + if let Some(secret) = &secret { + ensure!( + age::Decryptor::new(Cursor::new(&secret)).is_ok(), + "builder produced non-encrypted value as secret, this is highly insecure" + ); + } + + let created_at = host.read_file_value(format!("{out}/created_at")).await?; + let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok(); + + FleetSharedSecret { + owners, + secret: FleetSecret { + created_at, + expires_at, + public, + secret: secret.map(SecretData), + }, + } + } else { + bail!("no generator defined for {display_name}") + }) +} + +async fn parse_public( + public: Option, + public_file: Option, +) -> Result> { + Ok(match (public, public_file) { + (Some(v), None) => Some(v), + (None, Some(v)) => Some(read_to_string(v).await?), + (Some(_), Some(_)) => { + bail!("only public or public_file should be set") + } + (None, None) => None, + }) +} + +fn parse_machines( + initial: Vec, + 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:?}"); + + // ensure!(machines.is_some() || !add_machines.is_empty() || ) + 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 { + let mut removed = false; + while let Some(pos) = target_machines.iter().position(|m| m == machine) { + target_machines.swap_remove(pos); + removed = true; + } + if !removed { + warn!("secret is not enabled for {machine}"); + } + } + for machine in &add_machines { + if target_machines.iter().any(|m| m == machine) { + warn!("secret is already added to {machine}"); + } else { + target_machines.push(machine.to_owned()); + } + } + 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) -> Result<()> { + match self { Secret::ForceKeys => { for host in config.list_hosts().await? { if config.should_skip(&host.name) { @@ -199,9 +270,8 @@ machines = shared.owners; } - let recipients = futures::stream::iter(machines.iter()) - .then(|m| config.recipient(m)) - .try_collect::>() + let recipients = config + .recipients(&machines.iter().map(String::as_str).collect_vec()) .await?; let secret = { @@ -209,22 +279,15 @@ io::stdin().read_to_end(&mut input)?; if input.is_empty() { - input + None } else { - let mut encrypted = vec![]; - let recipients = recipients - .iter() - .cloned() - .map(|r| Box::new(r) as Box) - .collect(); - let mut encryptor = age::Encryptor::with_recipients(recipients) - .ok_or_else(|| anyhow!("no recipients provided"))? - .wrap_output(&mut encrypted)?; - io::copy(&mut Cursor::new(input), &mut encryptor)?; - encryptor.finish()?; - encrypted + Some( + SecretData::encrypt(recipients, input) + .ok_or_else(|| anyhow!("no recipients provided"))?, + ) } }; + let public = parse_public(public, public_file).await?; config.replace_shared( name, FleetSharedSecret { @@ -233,14 +296,7 @@ created_at: Utc::now(), expires_at, secret, - public: match (public, public_file) { - (Some(v), None) => Some(v), - (None, Some(v)) => Some(read_to_string(v).await?), - (Some(_), Some(_)) => { - bail!("only public or public_file should be set") - } - (None, None) => None, - }, + public, }, }, ); @@ -261,19 +317,14 @@ bail!("no data provided") } - let mut encrypted = vec![]; - let recipient = Box::new(recipient) as Box; - let mut encryptor = age::Encryptor::with_recipients(vec![recipient]) - .expect("recipients provided") - .wrap_output(&mut encrypted)?; - io::copy(&mut Cursor::new(input), &mut encryptor)?; - encryptor.finish()?; - encrypted + Some(SecretData::encrypt(vec![recipient], input).expect("recipient provided")) }; if config.has_secret(&machine, &name) && !force { bail!("secret already defined"); } + let public = parse_public(public, public_file).await?; + config.insert_secret( &machine, name, @@ -281,16 +332,10 @@ created_at: Utc::now(), expires_at: None, secret, - public: match (public, public_file) { - (Some(v), None) => Some(v), - (None, Some(v)) => Some(std::fs::read_to_string(v)?), - (Some(_), Some(_)) => bail!("only public or public_file should be set"), - (None, None) => None, - }, + public, }, ); } - // TODO: Instead of using sudo, decode secret on remote machine #[allow(clippy::await_holding_refcell_ref)] Secret::Read { name, @@ -298,11 +343,11 @@ plaintext, } => { let secret = config.host_secret(&machine, &name)?; - if secret.secret.is_empty() { + let Some(secret) = secret.secret else { bail!("no secret {name}"); - } + }; let host = config.host(&machine).await?; - let data = host.decrypt(secret.secret).await?; + let data = host.decrypt(secret).await?; if plaintext { let s = String::from_utf8(data).context("output is not utf8")?; print!("{s}"); @@ -313,59 +358,22 @@ Secret::UpdateShared { name, machines, - mut add_machines, - mut remove_machines, + add_machines, + remove_machines, prefer_identities, } => { - if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() { - bail!("no operation"); - } - let mut secret = config.shared_secret(&name)?; - if secret.secret.secret.is_empty() { + if secret.secret.secret.is_none() { bail!("no secret"); } let initial_machines = secret.owners.clone(); - let mut target_machines = secret.owners.clone(); - info!("Currently encrypted for {initial_machines:?}"); - - // ensure!(machines.is_some() || !add_machines.is_empty() || ) - 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 { - let mut removed = false; - while let Some(pos) = target_machines.iter().position(|m| m == machine) { - target_machines.swap_remove(pos); - removed = true; - } - if !removed { - warn!("secret is not enabled for {machine}"); - } - } - for machine in &add_machines { - if target_machines.iter().any(|m| m == machine) { - warn!("secret is already added to {machine}"); - } else { - target_machines.push(machine.to_owned()); - } - } - if !remove_machines.is_empty() { - warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret"); - } + let target_machines = parse_machines( + initial_machines.clone(), + machines, + add_machines, + remove_machines, + )?; if target_machines.is_empty() { info!("no machines left for secret, removing it"); @@ -395,12 +403,14 @@ let target_recipients = target_recipients.into_iter().collect::>>()?; - let encrypted = config - .reencrypt_on_host(identity_holder, secret.secret.secret, target_recipients) - .await?; + if let Some(data) = secret.secret.secret { + let encrypted = config + .reencrypt_on_host(identity_holder, data, target_recipients) + .await?; + secret.secret.secret = Some(encrypted); + } secret.owners = target_machines; - secret.secret.secret = encrypted; config.replace_shared(name, secret); } Secret::Regenerate { prefer_identities } => { @@ -412,14 +422,20 @@ .collect::>(); let shared_set = config.list_shared().into_iter().collect::>(); for removed in expected_shared_set.difference(&shared_set) { - error!("secret needs to be generated: {removed}") + info!("generating secret: {removed}"); + let config_field = &config.config_unchecked_field; + let config_field = nix_go!(config_field.configUnchecked); + let secret = nix_go!(config_field.sharedSecrets[{ removed }]); + let shared = generate_shared(config, removed, secret).await?; + config.replace_shared(removed.to_string(), shared) } } let mut to_remove = Vec::new(); for name in &config.list_shared() { info!("updating secret: {name}"); let mut data = config.shared_secret(name)?; - let config_field = &config.config_field; + let config_field = &config.config_unchecked_field; + let config_field = nix_go!(config_field.configUnchecked); let expected_owners: Vec = nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners); if expected_owners.is_empty() { @@ -430,50 +446,52 @@ let set = data.owners.iter().collect::>(); let expected_set = expected_owners.iter().collect::>(); let should_remove = set.difference(&expected_set).next().is_some(); - if set != expected_set { - let owner_dependent: bool = - nix_go_json!(config_field.sharedSecrets[{ name }].ownerDependent); - if !owner_dependent { - warn!("reencrypting secret '{name}' for new owner set"); - // TODO: force regeneration - if should_remove { - warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret"); - } + if set == expected_set { + info!("secret data is ok"); + continue; + } + + let secret = nix_go!(config_field.sharedSecrets[{ name }]); + let owner_dependent: bool = nix_go_json!(secret.ownerDependent); + let regenerate_on_remove: bool = nix_go_json!(secret.regenerateOnOwnerRemoved); + #[allow(clippy::nonminimal_bool)] + if !owner_dependent && !(should_remove && regenerate_on_remove) { + warn!("reencrypting secret '{name}' for new owner set"); + // TODO: force regeneration + if should_remove { + warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret"); + } - let identity_holder = if !prefer_identities.is_empty() { - prefer_identities - .iter() - .find(|i| data.owners.iter().any(|s| s == *i)) - } else { - data.owners.first() - }; - let Some(identity_holder) = identity_holder else { - bail!("no available holder found"); - }; + let identity_holder = if !prefer_identities.is_empty() { + prefer_identities + .iter() + .find(|i| data.owners.iter().any(|s| s == *i)) + } else { + data.owners.first() + }; + let Some(identity_holder) = identity_holder else { + bail!("no available holder found"); + }; - let target_recipients = futures::stream::iter(&expected_owners) - .then(|m| async { config.key(m).await }) - .collect::>() - .await; - let target_recipients = - target_recipients.into_iter().collect::>>()?; + let target_recipients = futures::stream::iter(&expected_owners) + .then(|m| async { config.key(m).await }) + .collect::>() + .await; + let target_recipients = + target_recipients.into_iter().collect::>>()?; + if let Some(secret) = data.secret.secret { let encrypted = config - .reencrypt_on_host( - identity_holder, - data.secret.secret, - target_recipients, - ) + .reencrypt_on_host(identity_holder, secret, target_recipients) .await?; - data.secret.secret = encrypted; - data.owners = expected_owners; - config.replace_shared(name.to_owned(), data); - } else { - error!("secret '{name}' should be regenerated manually"); + data.secret.secret = Some(encrypted); } + data.owners = expected_owners; + config.replace_shared(name.to_owned(), data); } else { - info!("secret data is ok") + let shared = generate_shared(config, name, secret).await?; + config.replace_shared(name.to_owned(), shared) } } for k in to_remove { --- a/cmds/fleet/src/fleetdata.rs +++ b/cmds/fleet/src/fleetdata.rs @@ -1,8 +1,13 @@ +use age::Recipient; use anyhow::Result; use chrono::{DateTime, Utc}; +use itertools::Itertools; use nixlike::format_nix; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use std::collections::BTreeMap; +use std::{ + collections::BTreeMap, + io::{self, Cursor}, +}; use tempfile::TempDir; use tokio::{ fs::{self, File}, @@ -41,6 +46,43 @@ } #[derive(Serialize, Deserialize, Clone)] +pub struct SecretData( + #[serde( + default, + skip_serializing_if = "Vec::is_empty", + serialize_with = "as_z85", + deserialize_with = "from_z85" + )] + pub Vec, +); +impl SecretData { + /// Returns None if recipients.is_empty() + pub fn encrypt( + recipients: impl IntoIterator, + data: Vec, + ) -> Option { + let mut encrypted = vec![]; + let recipients = recipients + .into_iter() + .map(|v| Box::new(v) as Box) + .collect_vec(); + let mut encryptor = age::Encryptor::with_recipients(recipients)? + .wrap_output(&mut encrypted) + .expect("in memory write"); + io::copy(&mut Cursor::new(data), &mut encryptor).expect("in memory copy"); + encryptor.finish().expect("in memory flush"); + Some(Self(encrypted)) + } + pub fn encode_z85(&self) -> String { + z85::encode(&self.0) + } + pub fn decode_z85(v: &str) -> Result { + let v = z85::decode(v)?; + Ok(Self(v)) + } +} + +#[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] #[must_use] pub struct FleetSecret { @@ -51,13 +93,8 @@ pub expires_at: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub public: Option, - #[serde( - default, - skip_serializing_if = "Vec::is_empty", - serialize_with = "as_z85", - deserialize_with = "from_z85" - )] - pub secret: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub secret: Option, } fn as_z85(key: &[u8], serializer: S) -> Result --- a/cmds/fleet/src/host.rs +++ b/cmds/fleet/src/host.rs @@ -1,21 +1,25 @@ use std::{ env::current_dir, ffi::{OsStr, OsString}, + fmt::Display, io::Write, ops::Deref, path::PathBuf, + str::FromStr, sync::{Arc, Mutex, MutexGuard, OnceLock}, }; +use age::Recipient; use anyhow::{anyhow, bail, Context, Result}; use clap::{ArgGroup, Parser}; use openssh::SessionBuilder; +use serde::de::DeserializeOwned; use tempfile::NamedTempFile; use crate::{ better_nix_eval::{Field, NixSessionPool}, command::MyCommand, - fleetdata::{FleetData, FleetSecret, FleetSharedSecret}, + fleetdata::{FleetData, FleetSecret, FleetSharedSecret, SecretData}, nix_go, nix_go_json, }; @@ -80,14 +84,25 @@ cmd.arg(path); cmd.run_string().await } + pub async fn read_file_json(&self, path: impl AsRef) -> Result { + let text = self.read_file_text(path).await?; + Ok(serde_json::from_str(&text)?) + } + pub async fn read_file_value(&self, path: impl AsRef) -> Result + where + ::Err: Display, + { + let text = self.read_file_text(path).await?; + D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}")) + } pub async fn cmd(&self, cmd: impl AsRef) -> Result { let session = self.open_session().await?; Ok(MyCommand::new_on(cmd, session)) } - pub async fn decrypt(&self, data: Vec) -> Result> { + pub async fn decrypt(&self, data: SecretData) -> Result> { let mut cmd = self.cmd("fleet-install-secrets").await?; - cmd.arg("decrypt").eqarg("--secret", z85::encode(&data)); + cmd.arg("decrypt").eqarg("--secret", data.encode_z85()); let encoded = cmd .sudo() .run_string() @@ -95,6 +110,16 @@ .context("failed to call remote host for decrypt")?; z85::decode(encoded.trim_end()).context("bad encoded data? outdated host?") } + /// Returns path for futureproofing, as path might change i.e on conversion to CA + pub async fn remote_derivation(&self, path: &PathBuf) -> Result { + let mut nix = MyCommand::new("nix"); + nix.arg("copy") + .arg("--substitute-on-destination") + .comparg("--to", format!("ssh-ng://{}", self.name)) + .arg(path); + nix.run_nix().await?; + Ok(path.to_owned()) + } } impl Config { @@ -166,8 +191,10 @@ } /// Shared secrets configured in fleet.nix or in flake pub async fn list_configured_shared(&self) -> Result> { - let config_field = &self.config_field; - nix_go!(config_field.sharedSecrets).list_fields().await + let config_field = &self.config_unchecked_field; + nix_go!(config_field.configUnchecked.sharedSecrets) + .list_fields() + .await } /// Shared secrets configured in fleet.nix pub fn list_shared(&self) -> Vec { @@ -203,12 +230,11 @@ pub async fn reencrypt_on_host( &self, host: &str, - data: Vec, + data: SecretData, targets: Vec, - ) -> Result> { - let data = z85::encode(&data); + ) -> Result { let mut recmd = MyCommand::new("fleet-install-secrets"); - recmd.arg("reencrypt").eqarg("--secret", data); + recmd.arg("reencrypt").eqarg("--secret", data.encode_z85()); for target in targets { recmd.eqarg("--targets", target); } @@ -219,7 +245,7 @@ .context("failed to call remote host for decrypt")? .trim() .to_owned(); - z85::decode(encoded).context("bad encoded data? outdated host?") + SecretData::decode_z85(&encoded) } pub fn host_secret(&self, host: &str, secret: &str) -> Result { @@ -240,9 +266,9 @@ Ok(secret.clone()) } pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result> { - let config_field = &self.config_field; + let config_field = &self.config_unchecked_field; Ok(nix_go_json!( - config_field.sharedSecrets[{ secret }].expectedOwners + config_field.configUnchecked.sharedSecrets[{ secret }].expectedOwners )) } --- a/cmds/fleet/src/keys.rs +++ b/cmds/fleet/src/keys.rs @@ -2,7 +2,9 @@ use crate::command::MyCommand; use crate::host::Config; +use age::Recipient; use anyhow::{anyhow, Result}; +use futures::{StreamExt, TryStreamExt}; use itertools::Itertools; use tracing::warn; @@ -36,11 +38,18 @@ } } /// Insecure, requires root - pub async fn recipient(&self, host: &str) -> anyhow::Result { + pub async fn recipient(&self, host: &str) -> anyhow::Result { let key = self.key(host).await?; age::ssh::Recipient::from_str(&key).map_err(|e| anyhow!("parse recipient error: {:?}", e)) } + pub async fn recipients(&self, hosts: &[&str]) -> Result> { + futures::stream::iter(hosts.iter()) + .then(|m| self.recipient(m)) + .try_collect::>() + .await + } + #[allow(dead_code)] pub async fn orphaned_data(&self) -> Result> { let mut out = Vec::new(); --- a/crates/nixlike/src/lib.rs +++ b/crates/nixlike/src/lib.rs @@ -10,6 +10,8 @@ mod se_impl; mod to_string; +pub use to_string::escape_string; + #[derive(thiserror::Error, Debug)] pub enum Error { #[error("bad number")] --- a/crates/nixlike/src/to_string.rs +++ b/crates/nixlike/src/to_string.rs @@ -25,8 +25,8 @@ } } -fn write_nix_str(str: &str, out: &mut String) { - out.push_str(&format!( +pub fn escape_string(str: &str) -> String { + format!( "\"{}\"", str.replace('\\', "\\\\") .replace('"', "\\\"") @@ -34,7 +34,11 @@ .replace('\t', "\\t") .replace('\r', "\\r") .replace('$', "\\$") - )) + ) +} + +pub fn write_nix_str(str: &str, out: &mut String) { + out.push_str(&escape_string(str)) } fn write_nix_buf(value: &Value, out: &mut String) { -- gitstuff