From 5a5b360a34032c3a5d24b5fc9e083c16892a3ec5 Mon Sep 17 00:00:00 2001 From: Yaroslav Bolyukin Date: Tue, 06 Jan 2026 02:54:12 +0000 Subject: [PATCH] feat: unify shared and host secret handling --- --- a/cmds/fleet/src/cmds/secrets/mod.rs +++ b/cmds/fleet/src/cmds/secrets/mod.rs @@ -8,9 +8,7 @@ use chrono::{DateTime, Utc}; use clap::Parser; use fleet_base::{ - fleetdata::{ - FleetHostSecret, FleetSecretData, FleetSecretPart, FleetSharedSecret, encrypt_secret_data, - }, + fleetdata::{FleetSecretData, FleetSecretDistribution, FleetSecretPart, encrypt_secret_data}, host::Config, opts::FleetOpts, secret::{Expectations, RegenerationReason, SharedSecretDefinition, secret_needs_regeneration}, @@ -28,101 +26,25 @@ AddManager, /// Force load host keys for all defined hosts ForceKeys, - /// Add secret, data should be provided in stdin - AddShared { - /// Secret name + /// Read secret from remote host, requires sudo on one of the owning hosts + Read { + /// Secret name to read name: String, - /// Secret owners - #[clap(long, short)] - machines: Vec, - /// Override secret if already present - #[clap(long)] - force: bool, - /// Secret public part - #[clap(long)] - public: Option, - /// Load public part from specified file - #[clap(long)] - public_file: Option, - - /// Create a notification on secret expiration - #[clap(long)] - expires_at: Option>, - /// Secret with this name already exists, override its value while keeping the same owners. - #[clap(long)] - re_add: bool, - - /// How to name public secret part - #[clap(long, short = 'p', default_value = "public")] - public_part: String, - /// How to name private secret part - #[clap(short = 's', long, default_value = "secret")] - part: String, - }, - /// Add secret, data should be provided in stdin - Add { - /// Secret name - name: String, - /// Secret owner + /// Distribution with what machine to read + /// If not shared between multiple - defaults to single owner #[clap(short = 'm', long)] - machine: String, - /// Replace secret if already present - #[clap(long)] - replace: bool, - /// Add new parts to existing secret - #[clap(long)] - merge: bool, - /// Secret public part - #[clap(long)] - public: Option, - /// Load public part from specified file - #[clap(long)] - public_file: Option, + machine: Option, - /// How to name public secret part - #[clap(short = 'p', long, default_value = "public")] - public_part: String, - /// How to name private secret part - #[clap(short = 's', long, default_value = "secret")] - part: String, - }, - /// Read secret from remote host, requires sudo on said host - Read { - name: String, - #[clap(short = 'm', long)] - machine: String, - /// Which private secret part to read #[clap(short = 'p', long, default_value = "secret")] part: String, - }, - /// Read secret from remote host, requires sudo on said host - ReadShared { - name: String, - /// Which private secret part to read - #[clap(short = 'p', long, default_value = "secret")] - part: String, + /// Which host should we use to decrypt, in case if reencryption is required, without /// regeneration #[clap(long)] prefer_identities: Vec, }, - UpdateShared { - name: String, - - #[clap(short = 'm', long)] - machine: Option>, - - #[clap(long)] - add_machine: Vec, - #[clap(long)] - remove_machine: Vec, - - /// Which host should we use to decrypt - #[clap(long)] - prefer_identities: Vec, - }, Regenerate { /// Which host should we use to decrypt, in case if reencryption is required, without /// regeneration @@ -152,11 +74,11 @@ async fn maybe_regenerate_shared_secret( secret_name: &str, config: &Config, - mut secret: FleetSharedSecret, + mut secret: FleetSecretDistribution, definition: SharedSecretDefinition, prefer_identities: &[String], expectations: &Expectations, -) -> Result { +) -> Result { let reason = secret_needs_regeneration(&secret.secret, &secret.owners, expectations); let value = definition.definition_value(); @@ -397,9 +319,9 @@ display_name: &str, secret: SharedSecretDefinition, expectations: &Expectations, -) -> Result { +) -> Result { // let owners: Vec = nix_go_json!(secret.expectedOwners); - Ok(FleetSharedSecret { + Ok(FleetSecretDistribution { managed: Some(true), secret: generate( config, @@ -504,177 +426,41 @@ config.key(&host.name).await?; } } - Secret::AddShared { - machines, - name, - force, - public, - public_part: public_name, - public_file, - expires_at, - re_add, - part: part_name, - } => { - let mut machines: BTreeSet = machines.into_iter().collect(); - // TODO: Forbid updating secrets with set expectedOwners (= not user-managed). - - if let Some(old_shared) = config.shared_secret(&name)? { - if !force && !re_add { - bail!("secret already defined"); - }; - if old_shared.managed.unwrap_or(false) { - bail!("secret is marked as managed, should not be updated manually"); - }; - if re_add { - // Fixme: use clap to limit this usage - ensure!(!force, "--force and --readd are not compatible"); - ensure!( - machines.is_empty(), - "you can't use machines argument for --readd" - ); - machines = old_shared.owners; - } - } else if re_add { - bail!("secret doesn't exists"); - }; - - let recipients = config - .recipients(machines.iter().cloned().collect()) - .await?; - - let mut parts = BTreeMap::new(); - - let mut input = vec![]; - io::stdin().read_to_end(&mut input)?; - - if !input.is_empty() { - let encrypted = encrypt_secret_data(recipients.iter(), input) - .ok_or_else(|| anyhow!("no recipients provided"))?; - parts.insert(part_name, FleetSecretPart { raw: encrypted }); - } - - if let Some(public) = parse_public(public, public_file).await? { - parts.insert(public_name, FleetSecretPart { raw: public }); - } - - config.replace_shared( - name, - FleetSharedSecret { - managed: Some(false), - owners: machines, - secret: FleetSecretData { - created_at: Utc::now(), - expires_at, - parts, - generation_data: serde_json::Value::Null, - }, - }, - ); - } - Secret::Add { - machine, - name, - replace, - merge, - public, - public_part: public_name, - public_file, - part: part_name, - } => { - if config.has_secret(&machine, &name) && !replace && !merge { - bail!( - "secret already defined.\nUse --replace to override, or --merge to add new parts to existing secret" - ); - } - - let mut out = if merge && !replace { - config - .host_secret(&machine, &name) - .context("failed to read existing secret for --merge")? - } else { - FleetHostSecret { - managed: Some(false), - secret: FleetSecretData { - created_at: Utc::now(), - expires_at: None, - parts: BTreeMap::new(), - generation_data: serde_json::Value::Null, - }, - } - }; - if out.managed.unwrap_or(false) { - bail!("secret is managed by fleet and should not be updated manually"); - } - out.managed = Some(false); - - if let Some(secret) = parse_secret().await? { - let recipient = config.recipient(&machine).await?; - let encrypted = - encrypt_secret_data([&recipient], secret).expect("recipient provided"); - if out - .secret - .parts - .insert(part_name.clone(), FleetSecretPart { raw: encrypted }) - .is_some() && !replace - { - bail!( - "part {part_name:?} is already defined, use --replace if you wish to replace it" - ); - } - } - - if let Some(public) = parse_public(public, public_file).await? { - if out - .secret - .parts - .insert(public_name.clone(), FleetSecretPart { raw: public }) - .is_some() && !replace - { - bail!( - "part {public_name:?} is already defined, use --replace if you wish to replace it" - ); - } - }; - - config.insert_secret(&machine, name, out); - } - #[allow(clippy::await_holding_refcell_ref)] Secret::Read { name, machine, part: part_name, + mut prefer_identities, } => { - let secret = config.host_secret(&machine, &name)?; - let Some(secret) = secret.secret.parts.get(&part_name) else { - bail!("no part {part_name} in secret {name}"); + let Some(secret) = config.shared_secret(&name) else { + bail!("secret doesn't exists"); }; - let data = if secret.raw.encrypted { - let host = config.host(&machine).await?; - host.decrypt(secret.raw.clone()).await? + + 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 { - secret.raw.data.clone() + bail!( + "secret {name} has shares, but no --machine specified for specifing which do you need" + ) }; - stdout().write_all(&data)?; - } - Secret::ReadShared { - name, - part: part_name, - prefer_identities, - } => { - let Some(secret) = config.shared_secret(&name)? else { - bail!("secret doesn't exists"); - }; - let Some(part) = secret.secret.parts.get(&part_name) else { + 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| secret.owners.iter().any(|s| s == *i)) + .find(|i| dist.owners.iter().any(|s| s == *i)) } else { - secret.owners.first() + dist.owners.first() }; let Some(identity_holder) = identity_holder else { bail!("no available holder found"); @@ -686,198 +472,148 @@ }; stdout().write_all(&data)?; } - Secret::UpdateShared { - name, - machine, - add_machine, - remove_machine, - prefer_identities, - } => { - // TODO: Forbid updating secrets with set expectedOwners (= not user-managed). - - let Some(secret) = config.shared_secret(&name)? else { - bail!("secret doesn't exists"); - }; - if secret.secret.parts.values().all(|v| !v.raw.encrypted) { - bail!("no secret"); - } - - let initial_machines = secret.owners.clone(); - let target_machines = parse_machines( - initial_machines.clone(), - machine, - add_machine, - remove_machine, - )?; - - if target_machines.is_empty() { - info!("no machines left for secret, removing it"); - config.remove_shared(&name); - return Ok(()); - } - - let definition = config.shared_secret_definition(&name)?; - let expectations = definition - .expectations() - .with_context(|| format!("expectations for shared {name:?}"))?; - - let updated = maybe_regenerate_shared_secret( - &name, - config, - secret, - definition, - &prefer_identities, - &expectations, - ) - .await?; - config.replace_shared(name, updated); - } Secret::Regenerate { prefer_identities, skip_hosts, } => { - info!("checking for secrets to regenerate"); - let expected_shared_set = config - .list_configured_shared() - .await? - .into_iter() - .collect::>(); - let stored_shared_set = config.list_shared().into_iter().collect::>(); - { - // Generate missing shared - let _span = info_span!("shared").entered(); - for missing in expected_shared_set.difference(&stored_shared_set) { - let definition = config.shared_secret_definition(missing)?; - if !definition.is_managed()? { - info!("skipping unmanaged secret: {missing}"); - continue; - } - let expectations = definition - .expectations() - .with_context(|| format!("expectations for shared {missing:?}"))?; - info!("generating secret: {missing}"); - let shared = generate_shared(config, missing, definition, &expectations) - .in_current_span() - .await?; - config.replace_shared(missing.to_string(), shared) - } - } - if !skip_hosts { - for host in config.list_hosts().await? { - if opts.should_skip(&host).await? { - continue; - } + /* + info!("checking for secrets to regenerate"); + let expected_shared_set = config + .list_configured_shared() + .await? + .into_iter() + .collect::>(); + let stored_shared_set = config.list_secrets().into_iter().collect::>(); + { + // Generate missing shared + let _span = info_span!("shared").entered(); + for missing in expected_shared_set.difference(&stored_shared_set) { + let definition = config.shared_secret_definition(missing)?; + if !definition.is_managed()? { + info!("skipping unmanaged secret: {missing}"); + continue; + } + let expectations = definition + .expectations() + .with_context(|| format!("expectations for shared {missing:?}"))?; + info!("generating secret: {missing}"); + let shared = generate_shared(config, missing, definition, &expectations) + .in_current_span() + .await?; + config.replace_shared(missing.to_string(), shared) + } + } + if !skip_hosts { + for host in config.list_hosts().await? { + if opts.should_skip(&host).await? { + continue; + } - let _span = info_span!("host", host = host.name).entered(); - let expected_set = host - .list_defined_secrets()? - .into_iter() - .collect::>(); - let stored_set = config - .list_secrets(&host.name) - .into_iter() - .collect::>(); - for missing_secret in expected_set.difference(&stored_set) { - let secret = host.secret_definition(missing_secret)?; - if secret.is_shared()? { - continue; - } - info!("generating missing secret: {missing_secret}"); - let expectations = secret.expectations().with_context(|| { - format!("expectations for {missing_secret:?} of {:?}", host.name) - })?; - let generated = match generate( - config, - missing_secret, - secret.definition_value()?, - &expectations, - ) - .in_current_span() - .await - { - Ok(v) => v, - Err(e) => { - error!("{e:?}"); - continue; - } - }; - config.insert_secret( - &host.name, - missing_secret.to_string(), - FleetHostSecret { - managed: Some(true), - secret: generated, - }, - ) - } - for known_secret in stored_set.intersection(&expected_set) { - let secret = host.secret_definition(known_secret)?; - if secret.is_shared()? { - continue; - } - info!("updating secret: {known_secret}"); - let data = config.host_secret(&host.name, known_secret)?; - let expectations = secret.expectations()?; - if let Some(regen_reason) = data.needs_regeneration(&expectations) { - info!("needs regeneration: {regen_reason}"); - let generated = match generate( - config, - known_secret, - secret.definition_value()?, - &expectations, - ) - .in_current_span() - .await - { - Ok(v) => v, - Err(e) => { - error!("{e:?}"); - continue; + let _span = info_span!("host", host = host.name).entered(); + let expected_set = host + .list_defined_secrets()? + .into_iter() + .collect::>(); + let stored_set = config + .list_secrets_for_owner(&host.name) + .into_iter() + .collect::>(); + for missing_secret in expected_set.difference(&stored_set) { + let secret = host.secret_definition(missing_secret)?; + if secret.is_shared()? { + continue; + } + info!("generating missing secret: {missing_secret}"); + let expectations = secret.expectations().with_context(|| { + format!("expectations for {missing_secret:?} of {:?}", host.name) + })?; + let generated = match generate( + config, + missing_secret, + secret.definition_value()?, + &expectations, + ) + .in_current_span() + .await + { + Ok(v) => v, + Err(e) => { + error!("{e:?}"); + continue; + } + }; + config.insert_secret(host.name, missing_secret.to_string(), generated) + } + for known_secret in stored_set.intersection(&expected_set) { + let secret = host.secret_definition(known_secret)?; + if secret.is_shared()? { + continue; + } + info!("updating secret: {known_secret}"); + let data = config.host_secret(&host.name, known_secret)?; + let expectations = secret.expectations()?; + if let Some(regen_reason) = data.needs_regeneration(&expectations) { + info!("needs regeneration: {regen_reason}"); + let generated = match generate( + config, + known_secret, + secret.definition_value()?, + &expectations, + ) + .in_current_span() + .await + { + Ok(v) => v, + Err(e) => { + error!("{e:?}"); + continue; + } + }; + config.insert_secret( + &host.name, + known_secret.to_string(), + FleetLegacyHostSecret { + managed: Some(true), + secret: generated, + }, + ) + } + } + for removed_secret in stored_set.difference(&expected_set) { + let definition = host.secret_definition(removed_secret)?; + if definition.is_shared()? { + continue; + } + info!("removing secret: {removed_secret}"); + config.remove_secret(&host.name, removed_secret); + } } - }; - config.insert_secret( - &host.name, - known_secret.to_string(), - FleetHostSecret { - managed: Some(true), - secret: generated, - }, - ) - } - } - for removed_secret in stored_set.difference(&expected_set) { - let definition = host.secret_definition(removed_secret)?; - if definition.is_shared()? { - continue; - } - info!("removing secret: {removed_secret}"); - config.remove_secret(&host.name, removed_secret); - } - } - } - for known_secret in stored_shared_set.intersection(&expected_shared_set) { - info!("updating shared secret: {known_secret}"); - let data = config.shared_secret(known_secret)?.expect("exists"); + } + for known_secret in stored_shared_set.intersection(&expected_shared_set) { + info!("updating shared secret: {known_secret}"); + let data = config.shared_secret(known_secret)?.expect("exists"); - let definition = config.shared_secret_definition(known_secret)?; - let expectations = definition.expectations()?; - config.replace_shared( - known_secret.to_owned(), - maybe_regenerate_shared_secret( - known_secret, - config, - data, - definition, - &prefer_identities, - &expectations, - ) - .await?, - ); - } - for removed_secret in stored_shared_set.difference(&expected_shared_set) { - info!("removing shared secret: {removed_secret}"); - config.remove_shared(removed_secret); - } + let definition = config.shared_secret_definition(known_secret)?; + let expectations = definition.expectations()?; + config.replace_shared( + known_secret.to_owned(), + maybe_regenerate_shared_secret( + known_secret, + config, + data, + definition, + &prefer_identities, + &expectations, + ) + .await?, + ); + } + for removed_secret in stored_shared_set.difference(&expected_shared_set) { + info!("removing shared secret: {removed_secret}"); + config.remove_shared(removed_secret); + } + */ + todo!() } Secret::List {} => { let _span = info_span!("loading secrets").entered(); @@ -892,12 +628,11 @@ let mut table = vec![]; for name in configured.iter().cloned() { let config = config.clone(); - let data = config.shared_secret(&name)?.expect("exists"); + let data = config.shared_secret(&name).expect("exists"); let definition = config.shared_secret_definition(&name)?; let expectations = definition.expectations()?; let owners = data - .owners - .iter() + .owners() .map(|o| { if expectations.owners.contains(o) { o.green().to_string() @@ -919,7 +654,9 @@ part, add, } => { - let secret = config.host_secret(&machine, &name)?; + 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).await?; let secret = host.decrypt(data.raw.clone()).await?; --- a/crates/fleet-base/src/fleetdata.rs +++ b/crates/fleet-base/src/fleetdata.rs @@ -1,6 +1,10 @@ use std::{ - collections::{BTreeMap, BTreeSet}, + collections::{ + BTreeMap, BTreeSet, + btree_map::{self, Entry}, + }, io::{self, Cursor}, + ops::Deref, }; use age::Recipient; @@ -10,10 +14,12 @@ distr::{Alphanumeric, SampleString as _}, rng, }; -use serde::{Deserialize, Serialize, de::Error}; +use serde::{ + Deserialize, Serialize, + de::{self, Error}, +}; use serde_json::Value; - -use crate::secret::{Expectations, RegenerationReason, secret_needs_regeneration}; +use tracing::info; #[derive(Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -72,17 +78,29 @@ #[serde(default)] pub hosts: BTreeMap, + + #[serde(default, alias = "shared_secrets")] + pub secrets: FleetSecrets, + + // extra_name => anything #[serde(default)] #[serde(skip_serializing_if = "BTreeMap::is_empty")] - pub shared_secrets: BTreeMap, - #[serde(default)] - #[serde(skip_serializing_if = "BTreeMap::is_empty")] - pub host_secrets: BTreeMap>, + pub extra: BTreeMap, - // extra_name => anything #[serde(default)] #[serde(skip_serializing_if = "BTreeMap::is_empty")] - pub extra: BTreeMap, + host_secrets: BTreeMap>, +} +impl FleetData { + pub fn from_str(s: &str) -> anyhow::Result { + let mut data: Self = nixlike::parse_str(s)?; + if !data.host_secrets.is_empty() { + info!("migrating host secrets into shared secrets structure"); + data.secrets + .merge_from_hosts(std::mem::take(&mut data.host_secrets)); + } + Ok(data) + } } /// Returns None if recipients.is_empty() @@ -129,27 +147,276 @@ #[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] #[must_use] -pub struct FleetHostSecret { +pub struct FleetSecretDistribution { #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] pub managed: Option, + #[serde(default)] + pub owners: BTreeSet, #[serde(flatten)] pub secret: FleetSecretData, } -impl FleetHostSecret { - pub fn needs_regeneration(&self, expectations: &Expectations) -> Option { - secret_needs_regeneration(&self.secret, &expectations.owners, expectations) + +#[derive(Clone)] +#[must_use] +pub struct FleetSecretDistributions(Vec); + +impl Deref for FleetSecretDistributions { + type Target = [FleetSecretDistribution]; + + fn deref(&self) -> &Self::Target { + self.0.as_slice() + } +} + +impl FleetSecretDistributions { + pub fn owners(&self) -> impl Iterator { + self.0.iter().flat_map(|v| v.owners.iter()) + } + #[allow( + clippy::len_without_is_empty, + reason = "should not be empty for a long time" + )] + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn get(&self, owner: &str) -> Option<&FleetSecretDistribution> { + self.0.iter().find(|d| d.owners.contains(owner)) + } + fn entry(&mut self, owner: String) -> DistEntry<'_> { + let Some(idx) = self.0.iter().position(|d| d.owners.contains(&owner)) else { + return DistEntry::Vacant(VacantDistEntry { + distributions: self, + owner, + }); + }; + DistEntry::Occupied(OccupiedDistEntry { + distributions: self, + idx, + owner, + }) + } + fn extend(&mut self, dist: FleetSecretDistribution) { + for owner in &dist.owners { + self.entry(owner.to_owned()).remove(); + } + self.0.push(dist); + } + pub fn contains(&self, owner: &str) -> bool { + self.0.iter().any(|d| d.owners.contains(owner)) + } +} + +struct OccupiedDistEntry<'d> { + distributions: &'d mut FleetSecretDistributions, + idx: usize, + owner: String, +} +impl<'d> OccupiedDistEntry<'d> { + fn remove(self) -> VacantDistEntry<'d> { + let dist = &mut self.distributions.0[self.idx]; + assert!( + dist.owners.remove(&self.owner), + "entry exists, as we have its reference" + ); + if dist.owners.is_empty() { + self.distributions.0.remove(self.idx); + } + VacantDistEntry { + distributions: self.distributions, + owner: self.owner, + } + } + fn set(self, secret: FleetSecretData) -> Self { + self.remove().set(secret) } } +struct VacantDistEntry<'d> { + distributions: &'d mut FleetSecretDistributions, + owner: String, +} +impl<'d> VacantDistEntry<'d> { + fn set(self, secret: FleetSecretData) -> OccupiedDistEntry<'d> { + let Self { + distributions, + owner, + } = self; + let idx = distributions.0.len(); + distributions.0.push(FleetSecretDistribution { + managed: None, + owners: BTreeSet::from_iter([owner.clone()]), + secret, + }); + OccupiedDistEntry { + distributions, + owner, + idx, + } + } +} -#[derive(Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -#[must_use] -pub struct FleetSharedSecret { - #[serde(default)] - #[serde(skip_serializing_if = "Option::is_none")] - pub managed: Option, - pub owners: BTreeSet, - #[serde(flatten)] - pub secret: FleetSecretData, +enum DistEntry<'d> { + Vacant(VacantDistEntry<'d>), + Occupied(OccupiedDistEntry<'d>), +} +impl DistEntry<'_> { + fn remove(self) -> Self { + match self { + DistEntry::Vacant(_) => self, + DistEntry::Occupied(o) => Self::Vacant(o.remove()), + } + } + fn set(self, secret: FleetSecretData) -> Self { + Self::Occupied(match self { + DistEntry::Vacant(e) => e.set(secret), + DistEntry::Occupied(e) => e.set(secret), + }) + } +} + +impl Serialize for FleetSecretDistributions { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut found_hosts = BTreeSet::new(); + for ele in self.0.iter() { + if ele.owners.is_empty() { + panic!("consistency: secret distribution has no defined owners"); + } + for ele in ele.owners.iter() { + if !found_hosts.insert(ele) { + panic!( + "consistency: secret distribution contains duplicate entry for the same host", + ); + } + } + } + match self.0.len() { + 0 => panic!("consistency: empty distributions"), + 1 => self.0[0].serialize(serializer), + _ => self.0.serialize(serializer), + } + } +} +impl<'de> Deserialize<'de> for FleetSecretDistributions { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum Distributions { + One(FleetSecretDistribution), + Many(Vec), + } + let d = Distributions::deserialize(deserializer)?; + let ds = match d { + Distributions::One(d) => vec![d], + Distributions::Many(ds) => ds, + }; + if ds.is_empty() { + return Err(de::Error::custom("consistency: empty distributions")); + } + let mut found_hosts = BTreeSet::new(); + for ele in ds.iter() { + if ele.owners.is_empty() { + return Err(de::Error::custom( + "consistency: secret distribution has no defined owners", + )); + } + for ele in ele.owners.iter() { + if !found_hosts.insert(ele) { + return Err(de::Error::custom( + "consistency: secret distribution contains duplicate entry for the same host", + )); + } + } + } + Ok(Self(ds)) + } +} + +#[derive(Serialize, Deserialize, Default)] +pub struct FleetSecrets(BTreeMap); + +impl FleetSecrets { + pub fn keys(&self) -> btree_map::Keys { + self.0.keys() + } + + pub fn keys_for_owner(&self, owner: &str) -> impl Iterator { + self.0 + .iter() + .filter(|(_, d)| d.contains(owner)) + .map(|(n, _)| n) + } + + pub fn drop_owner_no_reencrypt(&mut self, secret: &str, owner: &str) -> bool { + let Entry::Occupied(mut dists) = self.0.entry(secret.to_owned()) else { + return false; + }; + let DistEntry::Occupied(dist) = dists.get_mut().entry(owner.to_owned()) else { + return false; + }; + + dist.remove(); + + if dists.get().0.is_empty() { + dists.remove(); + }; + + true + } + pub fn set_single_data(&mut self, secret: String, owner: String, data: FleetSecretData) { + let e = self + .0 + .entry(secret.to_owned()) + .or_insert_with(|| FleetSecretDistributions(Default::default())); + e.entry(owner.to_owned()).set(data); + } + pub fn set_data(&mut self, secret: String, data: FleetSecretDistribution) { + match self.0.entry(secret) { + Entry::Vacant(e) => { + e.insert(FleetSecretDistributions(vec![data])); + } + Entry::Occupied(mut e) => { + let dists = e.get_mut(); + dists.extend(data) + } + } + } + pub fn get_single(&self, secret: &str, owner: &str) -> Option<&FleetSecretDistribution> { + let secret = self.0.get(secret)?; + secret.get(owner) + } + pub fn get(&self, secret: &str) -> Option<&FleetSecretDistributions> { + self.0.get(secret) + } + + pub fn contains_for_owner(&self, secret: &str, owner: &str) -> bool { + let Some(secret) = self.0.get(secret) else { + return false; + }; + secret.contains(owner) + } + pub fn contains(&self, secret: &str) -> bool { + self.0.contains_key(secret) + } + pub fn remove(&mut self, secret: &str) { + self.0.remove(secret); + } + + fn merge_from_hosts( + &mut self, + host_secrets: BTreeMap>, + ) { + for (host, host_secrets) in host_secrets { + for (secret_name, mut secret_data) in host_secrets { + secret_data.owners.insert(host.clone()); + self.set_data(secret_name, secret_data); + } + } + } } --- a/crates/fleet-base/src/host.rs +++ b/crates/fleet-base/src/host.rs @@ -22,7 +22,7 @@ use crate::{ command::MyCommand, - fleetdata::{FleetData, FleetHostSecret, FleetSharedSecret}, + fleetdata::{FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretDistributions}, secret::{HostSecretDefinition, SharedSecretDefinition}, }; @@ -623,80 +623,48 @@ let config_field = &self.config_field; nix_go!(config_field.sharedSecrets).list_fields() } - /// Shared secrets configured in fleet.nix - pub fn list_shared(&self) -> Vec { - let data = self.data(); - data.shared_secrets.keys().cloned().collect() - } pub fn has_shared(&self, name: &str) -> bool { let data = self.data(); - data.shared_secrets.contains_key(name) + data.secrets.contains(name) } - pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) { + pub fn replace_shared(&self, name: String, shared: FleetSecretDistribution) { let mut data = self.data_mut(); - data.shared_secrets.insert(name.to_owned(), shared); + data.secrets.set_data(name, shared); } pub fn remove_shared(&self, secret: &str) { let mut data = self.data_mut(); - data.shared_secrets.remove(secret); + data.secrets.remove(secret); } - pub fn list_secrets(&self, host: &str) -> Vec { - let data = self.data(); - let mut out = data - .host_secrets - .get(host) - .map(|s| s.keys().cloned().collect::>()) - .unwrap_or_default(); - - for (name, shared) in data.shared_secrets.iter() { - if shared.owners.contains(host) { - out.push(name.clone()); - } - } - - out + pub fn list_secrets_for_owner(&self, host: &str) -> Vec { + let data = self.data_mut(); + data.secrets.keys_for_owner(host).cloned().collect() + } + pub fn list_secrets(&self) -> Vec { + let data = self.data_mut(); + data.secrets.keys().cloned().collect() } pub fn has_secret(&self, host: &str, secret: &str) -> bool { let data = self.data(); - let Some(host_secrets) = data.host_secrets.get(host) else { - return false; - }; - host_secrets.contains_key(secret) + data.secrets.contains_for_owner(secret, host) } - pub fn insert_secret(&self, host: &str, secret: String, value: FleetHostSecret) { + pub fn insert_secret(&self, host: String, secret: String, value: FleetSecretData) { let mut data = self.data_mut(); - let host_secrets = data.host_secrets.entry(host.to_owned()).or_default(); - host_secrets.insert(secret, value); + data.secrets.set_single_data(secret, host, value); } pub fn remove_secret(&self, host: &str, secret: &str) { let mut data = self.data_mut(); - let host_secrets = data.host_secrets.entry(host.to_owned()).or_default(); - host_secrets.remove(secret); + data.secrets.drop_owner_no_reencrypt(secret, host); } - pub fn host_secret(&self, host: &str, secret: &str) -> Result { + pub fn host_secret(&self, host: &str, secret: &str) -> Option { let data = self.data(); - if let Some(host_secrets) = data.host_secrets.get(host) { - if let Some(secret) = host_secrets.get(secret) { - return Ok(secret.clone()); - } - }; - let Some(shared) = data.shared_secrets.get(secret) else { - bail!("machine {host} has no secret {secret}"); - }; - if !shared.owners.contains(host) { - bail!("shared secret {secret} is not owned by {host}"); - }; - Ok(FleetHostSecret { - managed: shared.managed, - secret: shared.secret.clone(), - }) + data.secrets.get_single(secret, host).cloned() } - pub fn shared_secret(&self, secret: &str) -> Result> { + pub fn shared_secret(&self, secret: &str) -> Option { let data = self.data(); - Ok(data.shared_secrets.get(secret).cloned()) + data.secrets.get(secret).cloned() } pub fn shared_secret_definition(&self, secret: &str) -> Result { let config_field = &self.config_field; --- a/crates/fleet-base/src/opts.rs +++ b/crates/fleet-base/src/opts.rs @@ -211,7 +211,7 @@ } let bytes = std::fs::read_to_string(&fleet_data_path).context("reading fleet state (fleet.nix)")?; - let data: Mutex = nixlike::parse_str(&bytes)?; + let data = Mutex::new(FleetData::from_str(&bytes)?); let mut fetch_settings = FetchSettings::new(); fetch_settings.set(c"warn-dirty", c"false"); --- a/flake.lock +++ b/flake.lock @@ -2,10 +2,10 @@ "nodes": { "crane": { "locked": { - "lastModified": 1766181779, + "lastModified": 1767461147, "owner": "ipetkov", "repo": "crane", - "rev": "0263f510ba38bee5b7f817498066adaad694e50b", + "rev": "7d59256814085fd9666a2ae3e774dc5ee216b630", "type": "github" }, "original": { @@ -37,10 +37,10 @@ ] }, "locked": { - "lastModified": 1765835352, + "lastModified": 1767609335, "owner": "hercules-ci", "repo": "flake-parts", - "rev": "a34fae9c08a15ad73f295041fec82323541400a9", + "rev": "250481aafeb741edfe23d29195671c19b36b6dca", "type": "github" }, "original": { @@ -126,10 +126,10 @@ }, "nixpkgs": { "locked": { - "lastModified": 1766181714, + "lastModified": 1767657734, "owner": "nixos", "repo": "nixpkgs", - "rev": "ff2da5fee8b3248cac330f14eac98228620beab0", + "rev": "d4ccebf51ee4dbeb9df364dce1fe9848635c1258", "type": "github" }, "original": { @@ -190,10 +190,10 @@ ] }, "locked": { - "lastModified": 1766112155, + "lastModified": 1767667566, "owner": "oxalica", "repo": "rust-overlay", - "rev": "2a6db3fc1c27ae77f9caa553d7609b223cb770b5", + "rev": "056ce5b125ab32ffe78c7d3e394d9da44733c95e", "type": "github" }, "original": { @@ -223,10 +223,10 @@ ] }, "locked": { - "lastModified": 1766000401, + "lastModified": 1767468822, "owner": "numtide", "repo": "treefmt-nix", - "rev": "42d96e75aa56a3f70cab7e7dc4a32868db28e8fd", + "rev": "d56486eb9493ad9c4777c65932618e9c2d0468fc", "type": "github" }, "original": { --- a/flake.nix +++ b/flake.nix @@ -128,11 +128,6 @@ overlays = [ (inputs.rust-overlay.overlays.default) (final: prev: { - boehmgc = prev.boehmgc.overrideAttrs (prevAttrs: { - configureFlags = prevAttrs.configureFlags ++ [ - "--enable-gc-assertions" - ]; - }); # Libsecret is stupidly huge # https://github.com/oxalica/rust-overlay/issues/211 libsecret = final.stdenv.mkDerivation { --- a/modules/secrets-data.nix +++ b/modules/secrets-data.nix @@ -1,7 +1,6 @@ { lib, fleetLib, - config, ... }: let @@ -15,15 +14,7 @@ submodule bool unspecified - ; - inherit (lib.attrsets) - mapAttrsToList - mapAttrs - filterAttrs - genAttrs ; - inherit (lib.lists) sort unique concatLists; - inherit (lib.strings) toJSON; secretDataValue = { options = { @@ -71,35 +62,8 @@ default = null; }; }; - config = { }; }; - hostSecretData = { - freeformType = attrsOf (submodule secretDataValue); - options = { - createdAt = mkOption { - type = str; - description = "Timestamp of secret generation/last rotation."; - default = null; - }; - expiresAt = mkOption { - type = nullOr str; - description = "Expiration timestamp triggering mandatory secret rotation."; - default = null; - }; - shared = mkOption { - type = bool; - description = "Indicates if secret is a shared secret, so other hosts might have the same piece of secret data."; - default = false; - }; - generationData = mkOption { - type = unspecified; - description = "Contextual metadata associated with secret part."; - default = null; - }; - }; - config = { }; - }; managerKey = { options = { name = mkOption { @@ -121,49 +85,11 @@ managerKeys = mkOption { type = listOf (submodule managerKey); }; - sharedSecrets = mkOption { - type = attrsOf (submodule sharedSecretData); + secrets = mkOption { + type = attrsOf (listOf submodule sharedSecretData); default = { }; description = "Shared secret data."; - }; - hostSecrets = mkOption { - type = attrsOf (attrsOf (submodule hostSecretData)); - default = { }; - description = "Host-specific secrets."; - internal = true; }; }; - config.hostSecrets = - let - hostsWithSharedSecrets = unique ( - concatLists (mapAttrsToList (_: s: s.owners) config.sharedSecrets) - ); - secretsHavingHost = host: filterAttrs (_: secret: lib.elem host secret.owners) config.sharedSecrets; - toHostSecret = _: secret: (removeAttrs secret [ "owners" ]) // { shared = true; }; - in - genAttrs hostsWithSharedSecrets (host: mapAttrs toHostSecret (secretsHavingHost host)); }); - config = { - assertions = - (mapAttrsToList (name: secret: { - assertion = - secret.expectedOwners == null - || - sort (a: b: a < b) (config.data.sharedSecrets.${name} or { owners = [ ]; }).owners - == sort (a: b: a < b) secret.expectedOwners; - message = "Shared secret ${name} is expected to be encrypted for ${toJSON secret.expectedOwners}, but it is encrypted for ${ - toJSON (config.data.sharedSecrets.${name} or { owners = [ ]; }).owners - }. Run fleet secrets regenerate to fix"; - }) config.sharedSecrets) - - ++ (mapAttrsToList (name: secret: { - # TODO: Same assertion should be in host secrets - assertion = - (config.data.sharedSecrets.${name} or { generationData = null; }).generationData - == secret.expectedGenerationData; - message = "Shared secret ${name} has unexpected generation data ${toJSON secret.expectedGenerationData} != ${ - toJSON (config.data.sharedSecrets.${name} or { generationData = null; }).generationData - }. Run fleet secrets regenerate to fix"; - }) config.sharedSecrets); - }; } --- a/modules/secrets.nix +++ b/modules/secrets.nix @@ -1,6 +1,5 @@ { lib, - config, ... }: let @@ -18,7 +17,6 @@ uniq ; inherit (lib.strings) concatStringsSep; - inherit (lib.attrsets) mapAttrs; sharedSecret = { config, ... }: @@ -54,6 +52,12 @@ Set to false if host permissions are revoked through alternative mechanisms like firewall rules. ''; }; + allowDifferent = mkOption { + type = bool; + description = '' + When adding owner, do not update secret value for other owners, instead creating a new distribution + ''; + }; generator = mkOption { type = uniq (nullOr (functionTo package)); description = '' @@ -84,32 +88,13 @@ in { options = { - sharedSecrets = mkOption { + secrets = mkOption { type = attrsOf (submodule sharedSecret); default = { }; description = "Collection of secrets shared across multiple hosts with configurable ownership"; }; }; config = { - hosts = mapAttrs ( - _: secretMap: - let - partsOf = - s: - removeAttrs s [ - "createdAt" - "expiresAt" - "generationData" - ]; - - in - { - nixos.data.secrets = mapAttrs (_: s: partsOf s) secretMap; - # nixos.secrets = mapAttrs ( - # _: s: mapAttrs (_: _: {}) (partsOf s) - # ) secretMap; - } - ) config.data.hostSecrets; nixpkgs.overlays = [ (final: prev: { mkSecretGenerators = -- gitstuff