difftreelog
feat unify shared and host secret handling
in: trunk
8 files changed
cmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth--- 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<String>,
- /// Override secret if already present
- #[clap(long)]
- force: bool,
- /// Secret public part
- #[clap(long)]
- public: Option<String>,
- /// Load public part from specified file
- #[clap(long)]
- public_file: Option<PathBuf>,
-
- /// Create a notification on secret expiration
- #[clap(long)]
- expires_at: Option<DateTime<Utc>>,
- /// 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<String>,
- /// Load public part from specified file
- #[clap(long)]
- public_file: Option<PathBuf>,
+ machine: Option<String>,
- /// 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<String>,
},
- UpdateShared {
- name: String,
-
- #[clap(short = 'm', long)]
- machine: Option<Vec<String>>,
-
- #[clap(long)]
- add_machine: Vec<String>,
- #[clap(long)]
- remove_machine: Vec<String>,
-
- /// Which host should we use to decrypt
- #[clap(long)]
- prefer_identities: Vec<String>,
- },
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<FleetSharedSecret> {
+) -> Result<FleetSecretDistribution> {
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<FleetSharedSecret> {
+) -> Result<FleetSecretDistribution> {
// let owners: Vec<String> = 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<String> = 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::<HashSet<_>>();
- let stored_shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();
- {
- // 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::<HashSet<_>>();
+ let stored_shared_set = config.list_secrets().into_iter().collect::<HashSet<_>>();
+ {
+ // 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::<HashSet<_>>();
- let stored_set = config
- .list_secrets(&host.name)
- .into_iter()
- .collect::<HashSet<_>>();
- 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::<HashSet<_>>();
+ let stored_set = config
+ .list_secrets_for_owner(&host.name)
+ .into_iter()
+ .collect::<HashSet<_>>();
+ 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?;
crates/fleet-base/src/fleetdata.rsdiffbeforeafterboth1use std::{2 collections::{3 BTreeMap, BTreeSet,4 btree_map::{self, Entry},5 },6 io::{self, Cursor},7 ops::Deref,8};910use age::Recipient;11use chrono::{DateTime, Utc};12use fleet_shared::SecretData;13use rand::{14 distr::{Alphanumeric, SampleString as _},15 rng,16};17use serde::{18 Deserialize, Serialize,19 de::{self, Error},20};21use serde_json::Value;22use tracing::info;2324#[derive(Serialize, Deserialize, Default)]25#[serde(rename_all = "camelCase")]26pub struct HostData {27 #[serde(default)]28 #[serde(skip_serializing_if = "String::is_empty")]29 pub encryption_key: String,30}3132const VERSION: &str = "0.1.0";33pub struct FleetDataVersion;34impl Serialize for FleetDataVersion {35 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>36 where37 S: serde::Serializer,38 {39 VERSION.serialize(serializer)40 }41}42impl<'de> Deserialize<'de> for FleetDataVersion {43 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>44 where45 D: serde::Deserializer<'de>,46 {47 let version = String::deserialize(deserializer)?;48 if version != VERSION {49 return Err(D::Error::custom(format!(50 "fleet.nix data version mismatch, expected {VERSION}, got {version}.\nFollow the docs for migration instruction"51 )));52 }53 Ok(Self)54 }55}5657fn generate_gc_prefix() -> String {58 let id = Alphanumeric.sample_string(&mut rng(), 8);59 format!("fleet-gc-{id}")60}6162#[derive(Serialize, Deserialize)]63#[serde(rename_all = "camelCase")]64pub struct ManagerKey {65 pub name: String,66 pub key: String,67}6869#[derive(Serialize, Deserialize)]70#[serde(rename_all = "camelCase")]71pub struct FleetData {72 pub version: FleetDataVersion,73 #[serde(default = "generate_gc_prefix")]74 pub gc_root_prefix: String,7576 #[serde(default)]77 pub manager_keys: Vec<ManagerKey>,7879 #[serde(default)]80 pub hosts: BTreeMap<String, HostData>,8182 #[serde(default, alias = "shared_secrets")]83 pub secrets: FleetSecrets,8485 // extra_name => anything86 #[serde(default)]87 #[serde(skip_serializing_if = "BTreeMap::is_empty")]88 pub extra: BTreeMap<String, Value>,8990 #[serde(default)]91 #[serde(skip_serializing_if = "BTreeMap::is_empty")]92 host_secrets: BTreeMap<String, BTreeMap<String, FleetSecretDistribution>>,93}94impl FleetData {95 pub fn from_str(s: &str) -> anyhow::Result<Self> {96 let mut data: Self = nixlike::parse_str(s)?;97 if !data.host_secrets.is_empty() {98 info!("migrating host secrets into shared secrets structure");99 data.secrets100 .merge_from_hosts(std::mem::take(&mut data.host_secrets));101 }102 Ok(data)103 }104}105106/// Returns None if recipients.is_empty()107pub fn encrypt_secret_data<'r>(108 recipients: impl IntoIterator<Item = &'r Box<dyn Recipient>>,109 data: Vec<u8>,110) -> Option<SecretData> {111 let mut encrypted = vec![];112 let mut encryptor = age::Encryptor::with_recipients(recipients.into_iter().map(|v| &**v))113 .ok()?114 .wrap_output(&mut encrypted)115 .expect("in memory write");116 io::copy(&mut Cursor::new(data), &mut encryptor).expect("in memory copy");117 encryptor.finish().expect("in memory flush");118 Some(SecretData {119 data: encrypted,120 encrypted: true,121 })122}123124#[derive(Serialize, Deserialize, Clone)]125pub struct FleetSecretPart {126 pub raw: SecretData,127}128129#[derive(Serialize, Deserialize, Clone)]130#[serde(rename_all = "camelCase")]131#[must_use]132pub struct FleetSecretData {133 #[serde(default = "Utc::now")]134 pub created_at: DateTime<Utc>,135 #[serde(default)]136 #[serde(skip_serializing_if = "Option::is_none", alias = "expire_at")]137 pub expires_at: Option<DateTime<Utc>>,138139 #[serde(flatten)]140 pub parts: BTreeMap<String, FleetSecretPart>,141142 #[serde(default)]143 #[serde(skip_serializing_if = "Value::is_null")]144 pub generation_data: Value,145}146147#[derive(Serialize, Deserialize, Clone)]148#[serde(rename_all = "camelCase")]149#[must_use]150pub struct FleetSecretDistribution {151 #[serde(default)]152 #[serde(skip_serializing_if = "Option::is_none")]153 pub managed: Option<bool>,154 #[serde(default)]155 pub owners: BTreeSet<String>,156 #[serde(flatten)]157 pub secret: FleetSecretData,158}159160#[derive(Clone)]161#[must_use]162pub struct FleetSecretDistributions(Vec<FleetSecretDistribution>);163164impl Deref for FleetSecretDistributions {165 type Target = [FleetSecretDistribution];166167 fn deref(&self) -> &Self::Target {168 self.0.as_slice()169 }170}171172impl FleetSecretDistributions {173 pub fn owners(&self) -> impl Iterator<Item = &String> {174 self.0.iter().flat_map(|v| v.owners.iter())175 }176 #[allow(177 clippy::len_without_is_empty,178 reason = "should not be empty for a long time"179 )]180 pub fn len(&self) -> usize {181 self.0.len()182 }183184 pub fn get(&self, owner: &str) -> Option<&FleetSecretDistribution> {185 self.0.iter().find(|d| d.owners.contains(owner))186 }187 fn entry(&mut self, owner: String) -> DistEntry<'_> {188 let Some(idx) = self.0.iter().position(|d| d.owners.contains(&owner)) else {189 return DistEntry::Vacant(VacantDistEntry {190 distributions: self,191 owner,192 });193 };194 DistEntry::Occupied(OccupiedDistEntry {195 distributions: self,196 idx,197 owner,198 })199 }200 fn extend(&mut self, dist: FleetSecretDistribution) {201 for owner in &dist.owners {202 self.entry(owner.to_owned()).remove();203 }204 self.0.push(dist);205 }206 pub fn contains(&self, owner: &str) -> bool {207 self.0.iter().any(|d| d.owners.contains(owner))208 }209}210211struct OccupiedDistEntry<'d> {212 distributions: &'d mut FleetSecretDistributions,213 idx: usize,214 owner: String,215}216impl<'d> OccupiedDistEntry<'d> {217 fn remove(self) -> VacantDistEntry<'d> {218 let dist = &mut self.distributions.0[self.idx];219 assert!(220 dist.owners.remove(&self.owner),221 "entry exists, as we have its reference"222 );223 if dist.owners.is_empty() {224 self.distributions.0.remove(self.idx);225 }226 VacantDistEntry {227 distributions: self.distributions,228 owner: self.owner,229 }230 }231 fn set(self, secret: FleetSecretData) -> Self {232 self.remove().set(secret)233 }234}235struct VacantDistEntry<'d> {236 distributions: &'d mut FleetSecretDistributions,237 owner: String,238}239impl<'d> VacantDistEntry<'d> {240 fn set(self, secret: FleetSecretData) -> OccupiedDistEntry<'d> {241 let Self {242 distributions,243 owner,244 } = self;245 let idx = distributions.0.len();246 distributions.0.push(FleetSecretDistribution {247 managed: None,248 owners: BTreeSet::from_iter([owner.clone()]),249 secret,250 });251 OccupiedDistEntry {252 distributions,253 owner,254 idx,255 }256 }257}258259enum DistEntry<'d> {260 Vacant(VacantDistEntry<'d>),261 Occupied(OccupiedDistEntry<'d>),262}263impl DistEntry<'_> {264 fn remove(self) -> Self {265 match self {266 DistEntry::Vacant(_) => self,267 DistEntry::Occupied(o) => Self::Vacant(o.remove()),268 }269 }270 fn set(self, secret: FleetSecretData) -> Self {271 Self::Occupied(match self {272 DistEntry::Vacant(e) => e.set(secret),273 DistEntry::Occupied(e) => e.set(secret),274 })275 }276}277278impl Serialize for FleetSecretDistributions {279 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>280 where281 S: serde::Serializer,282 {283 let mut found_hosts = BTreeSet::new();284 for ele in self.0.iter() {285 if ele.owners.is_empty() {286 panic!("consistency: secret distribution has no defined owners");287 }288 for ele in ele.owners.iter() {289 if !found_hosts.insert(ele) {290 panic!(291 "consistency: secret distribution contains duplicate entry for the same host",292 );293 }294 }295 }296 match self.0.len() {297 0 => panic!("consistency: empty distributions"),298 1 => self.0[0].serialize(serializer),299 _ => self.0.serialize(serializer),300 }301 }302}303impl<'de> Deserialize<'de> for FleetSecretDistributions {304 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>305 where306 D: serde::Deserializer<'de>,307 {308 #[derive(Deserialize)]309 #[serde(untagged)]310 enum Distributions {311 One(FleetSecretDistribution),312 Many(Vec<FleetSecretDistribution>),313 }314 let d = Distributions::deserialize(deserializer)?;315 let ds = match d {316 Distributions::One(d) => vec![d],317 Distributions::Many(ds) => ds,318 };319 if ds.is_empty() {320 return Err(de::Error::custom("consistency: empty distributions"));321 }322 let mut found_hosts = BTreeSet::new();323 for ele in ds.iter() {324 if ele.owners.is_empty() {325 return Err(de::Error::custom(326 "consistency: secret distribution has no defined owners",327 ));328 }329 for ele in ele.owners.iter() {330 if !found_hosts.insert(ele) {331 return Err(de::Error::custom(332 "consistency: secret distribution contains duplicate entry for the same host",333 ));334 }335 }336 }337 Ok(Self(ds))338 }339}340341#[derive(Serialize, Deserialize, Default)]342pub struct FleetSecrets(BTreeMap<String, FleetSecretDistributions>);343344impl FleetSecrets {345 pub fn keys(&self) -> btree_map::Keys<String, FleetSecretDistributions> {346 self.0.keys()347 }348349 pub fn keys_for_owner(&self, owner: &str) -> impl Iterator<Item = &String> {350 self.0351 .iter()352 .filter(|(_, d)| d.contains(owner))353 .map(|(n, _)| n)354 }355356 pub fn drop_owner_no_reencrypt(&mut self, secret: &str, owner: &str) -> bool {357 let Entry::Occupied(mut dists) = self.0.entry(secret.to_owned()) else {358 return false;359 };360 let DistEntry::Occupied(dist) = dists.get_mut().entry(owner.to_owned()) else {361 return false;362 };363364 dist.remove();365366 if dists.get().0.is_empty() {367 dists.remove();368 };369370 true371 }372 pub fn set_single_data(&mut self, secret: String, owner: String, data: FleetSecretData) {373 let e = self374 .0375 .entry(secret.to_owned())376 .or_insert_with(|| FleetSecretDistributions(Default::default()));377 e.entry(owner.to_owned()).set(data);378 }379 pub fn set_data(&mut self, secret: String, data: FleetSecretDistribution) {380 match self.0.entry(secret) {381 Entry::Vacant(e) => {382 e.insert(FleetSecretDistributions(vec![data]));383 }384 Entry::Occupied(mut e) => {385 let dists = e.get_mut();386 dists.extend(data)387 }388 }389 }390 pub fn get_single(&self, secret: &str, owner: &str) -> Option<&FleetSecretDistribution> {391 let secret = self.0.get(secret)?;392 secret.get(owner)393 }394 pub fn get(&self, secret: &str) -> Option<&FleetSecretDistributions> {395 self.0.get(secret)396 }397398 pub fn contains_for_owner(&self, secret: &str, owner: &str) -> bool {399 let Some(secret) = self.0.get(secret) else {400 return false;401 };402 secret.contains(owner)403 }404 pub fn contains(&self, secret: &str) -> bool {405 self.0.contains_key(secret)406 }407 pub fn remove(&mut self, secret: &str) {408 self.0.remove(secret);409 }410411 fn merge_from_hosts(412 &mut self,413 host_secrets: BTreeMap<String, BTreeMap<String, FleetSecretDistribution>>,414 ) {415 for (host, host_secrets) in host_secrets {416 for (secret_name, mut secret_data) in host_secrets {417 secret_data.owners.insert(host.clone());418 self.set_data(secret_name, secret_data);419 }420 }421 }422}crates/fleet-base/src/host.rsdiffbeforeafterboth--- 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<String> {
- 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<String> {
- let data = self.data();
- let mut out = data
- .host_secrets
- .get(host)
- .map(|s| s.keys().cloned().collect::<Vec<String>>())
- .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<String> {
+ let data = self.data_mut();
+ data.secrets.keys_for_owner(host).cloned().collect()
+ }
+ pub fn list_secrets(&self) -> Vec<String> {
+ 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<FleetHostSecret> {
+ pub fn host_secret(&self, host: &str, secret: &str) -> Option<FleetSecretDistribution> {
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<Option<FleetSharedSecret>> {
+ pub fn shared_secret(&self, secret: &str) -> Option<FleetSecretDistributions> {
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<SharedSecretDefinition> {
let config_field = &self.config_field;
crates/fleet-base/src/opts.rsdiffbeforeafterboth--- 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<FleetData> = 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");
flake.lockdiffbeforeafterboth--- 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": {
flake.nixdiffbeforeafterboth--- 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 {
modules/secrets-data.nixdiffbeforeafterboth--- 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);
- };
}
modules/secrets.nixdiffbeforeafterboth--- 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 =