difftreelog
feat unify shared and host secret handling
in: trunk
8 files changed
cmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth8use chrono::{DateTime, Utc};8use chrono::{DateTime, Utc};9use clap::Parser;9use clap::Parser;10use fleet_base::{10use fleet_base::{11 fleetdata::{11 fleetdata::{FleetSecretData, FleetSecretDistribution, FleetSecretPart, encrypt_secret_data},12 FleetHostSecret, FleetSecretData, FleetSecretPart, FleetSharedSecret, encrypt_secret_data,13 },14 host::Config,12 host::Config,15 opts::FleetOpts,13 opts::FleetOpts,16 secret::{Expectations, RegenerationReason, SharedSecretDefinition, secret_needs_regeneration},14 secret::{Expectations, RegenerationReason, SharedSecretDefinition, secret_needs_regeneration},28 AddManager,26 AddManager,29 /// Force load host keys for all defined hosts27 /// Force load host keys for all defined hosts30 ForceKeys,28 ForceKeys,31 /// Add secret, data should be provided in stdin29 /// Read secret from remote host, requires sudo on one of the owning hosts32 AddShared {30 Read {33 /// Secret name31 /// Secret name to read34 name: String,32 name: String,35 /// Secret owners36 #[clap(long, short)]37 machines: Vec<String>,38 /// Override secret if already present39 #[clap(long)]40 force: bool,41 /// Secret public part42 #[clap(long)]43 public: Option<String>,44 /// Load public part from specified file45 #[clap(long)]46 public_file: Option<PathBuf>,473348 /// Create a notification on secret expiration34 /// Distribution with what machine to read49 #[clap(long)]50 expires_at: Option<DateTime<Utc>>,5152 /// Secret with this name already exists, override its value while keeping the same owners.53 #[clap(long)]54 re_add: bool,5556 /// How to name public secret part57 #[clap(long, short = 'p', default_value = "public")]35 /// If not shared between multiple - defaults to single owner58 public_part: String,59 /// How to name private secret part60 #[clap(short = 's', long, default_value = "secret")]61 part: String,62 },63 /// Add secret, data should be provided in stdin64 Add {65 /// Secret name66 name: String,67 /// Secret owner68 #[clap(short = 'm', long)]36 #[clap(short = 'm', long)]69 machine: String,37 machine: Option<String>,70 /// Replace secret if already present71 #[clap(long)]72 replace: bool,73 /// Add new parts to existing secret74 #[clap(long)]75 merge: bool,76 /// Secret public part77 #[clap(long)]78 public: Option<String>,79 /// Load public part from specified file80 #[clap(long)]81 public_file: Option<PathBuf>,823883 /// How to name public secret part84 #[clap(short = 'p', long, default_value = "public")]85 public_part: String,86 /// How to name private secret part87 #[clap(short = 's', long, default_value = "secret")]88 part: String,89 },90 /// Read secret from remote host, requires sudo on said host91 Read {92 name: String,93 #[clap(short = 'm', long)]94 machine: String,9596 /// Which private secret part to read39 /// Which private secret part to read97 #[clap(short = 'p', long, default_value = "secret")]40 #[clap(short = 'p', long, default_value = "secret")]98 part: String,41 part: String,99 },42100 /// Read secret from remote host, requires sudo on said host101 ReadShared {102 name: String,103 /// Which private secret part to read104 #[clap(short = 'p', long, default_value = "secret")]105 part: String,106 /// Which host should we use to decrypt, in case if reencryption is required, without43 /// Which host should we use to decrypt, in case if reencryption is required, without107 /// regeneration44 /// regeneration108 #[clap(long)]45 #[clap(long)]109 prefer_identities: Vec<String>,46 prefer_identities: Vec<String>,110 },47 },111 UpdateShared {112 name: String,113114 #[clap(short = 'm', long)]115 machine: Option<Vec<String>>,116117 #[clap(long)]118 add_machine: Vec<String>,119 #[clap(long)]120 remove_machine: Vec<String>,121122 /// Which host should we use to decrypt123 #[clap(long)]124 prefer_identities: Vec<String>,125 },126 Regenerate {48 Regenerate {127 /// Which host should we use to decrypt, in case if reencryption is required, without49 /// Which host should we use to decrypt, in case if reencryption is required, without128 /// regeneration50 /// regeneration152async fn maybe_regenerate_shared_secret(74async fn maybe_regenerate_shared_secret(153 secret_name: &str,75 secret_name: &str,154 config: &Config,76 config: &Config,155 mut secret: FleetSharedSecret,77 mut secret: FleetSecretDistribution,156 definition: SharedSecretDefinition,78 definition: SharedSecretDefinition,157 prefer_identities: &[String],79 prefer_identities: &[String],158 expectations: &Expectations,80 expectations: &Expectations,159) -> Result<FleetSharedSecret> {81) -> Result<FleetSecretDistribution> {160 let reason = secret_needs_regeneration(&secret.secret, &secret.owners, expectations);82 let reason = secret_needs_regeneration(&secret.secret, &secret.owners, expectations);161 let value = definition.definition_value();83 let value = definition.definition_value();16284397 display_name: &str,319 display_name: &str,398 secret: SharedSecretDefinition,320 secret: SharedSecretDefinition,399 expectations: &Expectations,321 expectations: &Expectations,400) -> Result<FleetSharedSecret> {322) -> Result<FleetSecretDistribution> {401 // let owners: Vec<String> = nix_go_json!(secret.expectedOwners);323 // let owners: Vec<String> = nix_go_json!(secret.expectedOwners);402 Ok(FleetSharedSecret {324 Ok(FleetSecretDistribution {403 managed: Some(true),325 managed: Some(true),404 secret: generate(326 secret: generate(405 config,327 config,504 config.key(&host.name).await?;426 config.key(&host.name).await?;505 }427 }506 }428 }507 Secret::AddShared {429 Secret::Read {508 machines,509 name,430 name,510 force,431 machine,511 public,512 public_part: public_name,513 public_file,514 expires_at,515 re_add,516 part: part_name,432 part: part_name,433 mut prefer_identities,517 } => {434 } => {518 let mut machines: BTreeSet<String> = machines.into_iter().collect();435 let Some(secret) = config.shared_secret(&name) else {519 // TODO: Forbid updating secrets with set expectedOwners (= not user-managed).520521 if let Some(old_shared) = config.shared_secret(&name)? {522 if !force && !re_add {523 bail!("secret already defined");524 };525 if old_shared.managed.unwrap_or(false) {526 bail!("secret is marked as managed, should not be updated manually");527 };528 if re_add {529 // Fixme: use clap to limit this usage530 ensure!(!force, "--force and --readd are not compatible");531 ensure!(532 machines.is_empty(),533 "you can't use machines argument for --readd"534 );535 machines = old_shared.owners;536 }537 } else if re_add {538 bail!("secret doesn't exists");436 bail!("secret doesn't exists");539 };437 };540438541 let recipients = config439 let dist = if secret.len() == 1 {542 .recipients(machines.iter().cloned().collect())543 .await?;544545 let mut parts = BTreeMap::new();546547 let mut input = vec![];548 io::stdin().read_to_end(&mut input)?;549550 if !input.is_empty() {551 let encrypted = encrypt_secret_data(recipients.iter(), input)440 &secret[0]552 .ok_or_else(|| anyhow!("no recipients provided"))?;441 } else if let Some(machine) = machine {553 parts.insert(part_name, FleetSecretPart { raw: encrypted });554 }555556 if let Some(public) = parse_public(public, public_file).await? {557 parts.insert(public_name, FleetSecretPart { raw: public });442 let dist = secret.get(&machine);558 }443 let Some(dist) = dist else {559560 config.replace_shared(561 name,562 FleetSharedSecret {563 managed: Some(false),564 owners: machines,565 secret: FleetSecretData {566 created_at: Utc::now(),444 bail!("machine {machine} has no distribution of secret {name}");567 expires_at,568 parts,569 generation_data: serde_json::Value::Null,570 },571 },572 );573 }574 Secret::Add {575 machine,576 name,577 replace,578 merge,579 public,580 public_part: public_name,581 public_file,582 part: part_name,583 } => {584 if config.has_secret(&machine, &name) && !replace && !merge {585 bail!(586 "secret already defined.\nUse --replace to override, or --merge to add new parts to existing secret"587 );588 }445 };589590 let mut out = if merge && !replace {446 prefer_identities.push(machine);591 config592 .host_secret(&machine, &name)593 .context("failed to read existing secret for --merge")?447 dist594 } else {448 } else {595 FleetHostSecret {449 bail!(596 managed: Some(false),597 secret: FleetSecretData {450 "secret {name} has shares, but no --machine specified for specifing which do you need"598 created_at: Utc::now(),599 expires_at: None,600 parts: BTreeMap::new(),601 generation_data: serde_json::Value::Null,602 },451 )603 }604 };452 };605 if out.managed.unwrap_or(false) {606 bail!("secret is managed by fleet and should not be updated manually");607 }608 out.managed = Some(false);609453610 if let Some(secret) = parse_secret().await? {454 let Some(part) = dist.secret.parts.get(&part_name) else {611 let recipient = config.recipient(&machine).await?;612 let encrypted =613 encrypt_secret_data([&recipient], secret).expect("recipient provided");614 if out615 .secret616 .parts617 .insert(part_name.clone(), FleetSecretPart { raw: encrypted })618 .is_some() && !replace619 {620 bail!(621 "part {part_name:?} is already defined, use --replace if you wish to replace it"622 );623 }624 }625626 if let Some(public) = parse_public(public, public_file).await? {627 if out628 .secret629 .parts630 .insert(public_name.clone(), FleetSecretPart { raw: public })631 .is_some() && !replace632 {633 bail!(634 "part {public_name:?} is already defined, use --replace if you wish to replace it"635 );636 }637 };638639 config.insert_secret(&machine, name, out);640 }641 #[allow(clippy::await_holding_refcell_ref)]642 Secret::Read {643 name,644 machine,645 part: part_name,646 } => {647 let secret = config.host_secret(&machine, &name)?;648 let Some(secret) = secret.secret.parts.get(&part_name) else {649 bail!("no part {part_name} in secret {name}");455 bail!("no part {part_name} in secret {name}");650 };456 };651 let data = if secret.raw.encrypted {652 let host = config.host(&machine).await?;653 host.decrypt(secret.raw.clone()).await?654 } else {655 secret.raw.data.clone()656 };657658 stdout().write_all(&data)?;659 }660 Secret::ReadShared {661 name,662 part: part_name,663 prefer_identities,664 } => {665 let Some(secret) = config.shared_secret(&name)? else {666 bail!("secret doesn't exists");667 };668 let Some(part) = secret.secret.parts.get(&part_name) else {669 bail!("no part {part_name} in secret {name}");670 };671 let data = if part.raw.encrypted {457 let data = if part.raw.encrypted {672 let identity_holder = if !prefer_identities.is_empty() {458 let identity_holder = if !prefer_identities.is_empty() {673 prefer_identities459 prefer_identities674 .iter()460 .iter()675 .find(|i| secret.owners.iter().any(|s| s == *i))461 .find(|i| dist.owners.iter().any(|s| s == *i))676 } else {462 } else {677 secret.owners.first()463 dist.owners.first()678 };464 };679 let Some(identity_holder) = identity_holder else {465 let Some(identity_holder) = identity_holder else {680 bail!("no available holder found");466 bail!("no available holder found");686 };472 };687 stdout().write_all(&data)?;473 stdout().write_all(&data)?;688 }474 }689 Secret::UpdateShared {690 name,691 machine,692 add_machine,693 remove_machine,694 prefer_identities,695 } => {696 // TODO: Forbid updating secrets with set expectedOwners (= not user-managed).697698 let Some(secret) = config.shared_secret(&name)? else {699 bail!("secret doesn't exists");700 };701 if secret.secret.parts.values().all(|v| !v.raw.encrypted) {702 bail!("no secret");703 }704705 let initial_machines = secret.owners.clone();706 let target_machines = parse_machines(707 initial_machines.clone(),708 machine,709 add_machine,710 remove_machine,711 )?;712713 if target_machines.is_empty() {714 info!("no machines left for secret, removing it");715 config.remove_shared(&name);716 return Ok(());717 }718719 let definition = config.shared_secret_definition(&name)?;720 let expectations = definition721 .expectations()722 .with_context(|| format!("expectations for shared {name:?}"))?;723724 let updated = maybe_regenerate_shared_secret(725 &name,726 config,727 secret,728 definition,729 &prefer_identities,730 &expectations,731 )732 .await?;733 config.replace_shared(name, updated);734 }735 Secret::Regenerate {475 Secret::Regenerate {736 prefer_identities,476 prefer_identities,737 skip_hosts,477 skip_hosts,738 } => {478 } => {739 info!("checking for secrets to regenerate");479 /*740 let expected_shared_set = config480 info!("checking for secrets to regenerate");741 .list_configured_shared()481 let expected_shared_set = config742 .await?482 .list_configured_shared()743 .into_iter()483 .await?744 .collect::<HashSet<_>>();484 .into_iter()745 let stored_shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();485 .collect::<HashSet<_>>();746 {486 let stored_shared_set = config.list_secrets().into_iter().collect::<HashSet<_>>();747 // Generate missing shared748 let _span = info_span!("shared").entered();749 for missing in expected_shared_set.difference(&stored_shared_set) {750 let definition = config.shared_secret_definition(missing)?;751 if !definition.is_managed()? {752 info!("skipping unmanaged secret: {missing}");753 continue;754 }755 let expectations = definition756 .expectations()757 .with_context(|| format!("expectations for shared {missing:?}"))?;758 info!("generating secret: {missing}");759 let shared = generate_shared(config, missing, definition, &expectations)760 .in_current_span()761 .await?;762 config.replace_shared(missing.to_string(), shared)763 }764 }765 if !skip_hosts {766 for host in config.list_hosts().await? {767 if opts.should_skip(&host).await? {768 continue;769 }770771 let _span = info_span!("host", host = host.name).entered();772 let expected_set = host773 .list_defined_secrets()?774 .into_iter()775 .collect::<HashSet<_>>();776 let stored_set = config777 .list_secrets(&host.name)778 .into_iter()779 .collect::<HashSet<_>>();780 for missing_secret in expected_set.difference(&stored_set) {781 let secret = host.secret_definition(missing_secret)?;782 if secret.is_shared()? {783 continue;784 }785 info!("generating missing secret: {missing_secret}");786 let expectations = secret.expectations().with_context(|| {787 format!("expectations for {missing_secret:?} of {:?}", host.name)788 })?;789 let generated = match generate(790 config,791 missing_secret,792 secret.definition_value()?,793 &expectations,794 )795 .in_current_span()796 .await797 {798 Ok(v) => v,799 Err(e) => {800 error!("{e:?}");801 continue;802 }803 };804 config.insert_secret(805 &host.name,806 missing_secret.to_string(),807 FleetHostSecret {808 managed: Some(true),809 secret: generated,810 },811 )812 }813 for known_secret in stored_set.intersection(&expected_set) {814 let secret = host.secret_definition(known_secret)?;815 if secret.is_shared()? {816 continue;817 }818 info!("updating secret: {known_secret}");819 let data = config.host_secret(&host.name, known_secret)?;820 let expectations = secret.expectations()?;821 if let Some(regen_reason) = data.needs_regeneration(&expectations) {822 info!("needs regeneration: {regen_reason}");823 let generated = match generate(824 config,825 known_secret,826 secret.definition_value()?,827 &expectations,828 )829 .in_current_span()830 .await831 {487 {832 Ok(v) => v,488 // Generate missing shared489 let _span = info_span!("shared").entered();490 for missing in expected_shared_set.difference(&stored_shared_set) {491 let definition = config.shared_secret_definition(missing)?;833 Err(e) => {492 if !definition.is_managed()? {493 info!("skipping unmanaged secret: {missing}");834 error!("{e:?}");494 continue;495 }496 let expectations = definition497 .expectations()498 .with_context(|| format!("expectations for shared {missing:?}"))?;835 continue;499 info!("generating secret: {missing}");500 let shared = generate_shared(config, missing, definition, &expectations)501 .in_current_span()502 .await?;503 config.replace_shared(missing.to_string(), shared)836 }504 }837 };505 }838 config.insert_secret(506 if !skip_hosts {839 &host.name,840 known_secret.to_string(),841 FleetHostSecret {842 managed: Some(true),507 for host in config.list_hosts().await? {843 secret: generated,844 },845 )846 }847 }848 for removed_secret in stored_set.difference(&expected_set) {849 let definition = host.secret_definition(removed_secret)?;508 if opts.should_skip(&host).await? {850 if definition.is_shared()? {851 continue;509 continue;852 }853 info!("removing secret: {removed_secret}");854 config.remove_secret(&host.name, removed_secret);510 }855 }856 }857 }858 for known_secret in stored_shared_set.intersection(&expected_shared_set) {859 info!("updating shared secret: {known_secret}");860 let data = config.shared_secret(known_secret)?.expect("exists");861511862 let definition = config.shared_secret_definition(known_secret)?;512 let _span = info_span!("host", host = host.name).entered();863 let expectations = definition.expectations()?;513 let expected_set = host864 config.replace_shared(514 .list_defined_secrets()?865 known_secret.to_owned(),515 .into_iter()866 maybe_regenerate_shared_secret(516 .collect::<HashSet<_>>();867 known_secret,517 let stored_set = config868 config,518 .list_secrets_for_owner(&host.name)869 data,519 .into_iter()870 definition,520 .collect::<HashSet<_>>();871 &prefer_identities,521 for missing_secret in expected_set.difference(&stored_set) {872 &expectations,522 let secret = host.secret_definition(missing_secret)?;873 )523 if secret.is_shared()? {874 .await?,524 continue;875 );525 }876 }526 info!("generating missing secret: {missing_secret}");877 for removed_secret in stored_shared_set.difference(&expected_shared_set) {527 let expectations = secret.expectations().with_context(|| {878 info!("removing shared secret: {removed_secret}");528 format!("expectations for {missing_secret:?} of {:?}", host.name)879 config.remove_shared(removed_secret);529 })?;880 }530 let generated = match generate(531 config,532 missing_secret,533 secret.definition_value()?,534 &expectations,535 )536 .in_current_span()537 .await538 {539 Ok(v) => v,540 Err(e) => {541 error!("{e:?}");542 continue;543 }544 };545 config.insert_secret(host.name, missing_secret.to_string(), generated)546 }547 for known_secret in stored_set.intersection(&expected_set) {548 let secret = host.secret_definition(known_secret)?;549 if secret.is_shared()? {550 continue;551 }552 info!("updating secret: {known_secret}");553 let data = config.host_secret(&host.name, known_secret)?;554 let expectations = secret.expectations()?;555 if let Some(regen_reason) = data.needs_regeneration(&expectations) {556 info!("needs regeneration: {regen_reason}");557 let generated = match generate(558 config,559 known_secret,560 secret.definition_value()?,561 &expectations,562 )563 .in_current_span()564 .await565 {566 Ok(v) => v,567 Err(e) => {568 error!("{e:?}");569 continue;570 }571 };572 config.insert_secret(573 &host.name,574 known_secret.to_string(),575 FleetLegacyHostSecret {576 managed: Some(true),577 secret: generated,578 },579 )580 }581 }582 for removed_secret in stored_set.difference(&expected_set) {583 let definition = host.secret_definition(removed_secret)?;584 if definition.is_shared()? {585 continue;586 }587 info!("removing secret: {removed_secret}");588 config.remove_secret(&host.name, removed_secret);589 }590 }591 }592 for known_secret in stored_shared_set.intersection(&expected_shared_set) {593 info!("updating shared secret: {known_secret}");594 let data = config.shared_secret(known_secret)?.expect("exists");595596 let definition = config.shared_secret_definition(known_secret)?;597 let expectations = definition.expectations()?;598 config.replace_shared(599 known_secret.to_owned(),600 maybe_regenerate_shared_secret(601 known_secret,602 config,603 data,604 definition,605 &prefer_identities,606 &expectations,607 )608 .await?,609 );610 }611 for removed_secret in stored_shared_set.difference(&expected_shared_set) {612 info!("removing shared secret: {removed_secret}");613 config.remove_shared(removed_secret);614 }615 */616 todo!()881 }617 }882 Secret::List {} => {618 Secret::List {} => {883 let _span = info_span!("loading secrets").entered();619 let _span = info_span!("loading secrets").entered();892 let mut table = vec![];628 let mut table = vec![];893 for name in configured.iter().cloned() {629 for name in configured.iter().cloned() {894 let config = config.clone();630 let config = config.clone();895 let data = config.shared_secret(&name)?.expect("exists");631 let data = config.shared_secret(&name).expect("exists");896 let definition = config.shared_secret_definition(&name)?;632 let definition = config.shared_secret_definition(&name)?;897 let expectations = definition.expectations()?;633 let expectations = definition.expectations()?;898 let owners = data634 let owners = data899 .owners635 .owners()900 .iter()901 .map(|o| {636 .map(|o| {902 if expectations.owners.contains(o) {637 if expectations.owners.contains(o) {903 o.green().to_string()638 o.green().to_string()919 part,654 part,920 add,655 add,921 } => {656 } => {922 let secret = config.host_secret(&machine, &name)?;657 let secret = config658 .host_secret(&machine, &name)659 .context("secret not found")?;923 if let Some(data) = secret.secret.parts.get(&part) {660 if let Some(data) = secret.secret.parts.get(&part) {924 let host = config.host(&machine).await?;661 let host = config.host(&machine).await?;925 let secret = host.decrypt(data.raw.clone()).await?;662 let secret = host.decrypt(data.raw.clone()).await?;crates/fleet-base/src/fleetdata.rsdiffbeforeafterboth1use std::{1use std::{2 collections::{BTreeMap, BTreeSet},2 collections::{3 BTreeMap, BTreeSet,4 btree_map::{self, Entry},5 },3 io::{self, Cursor},6 io::{self, Cursor},7 ops::Deref,4};8};596use age::Recipient;10use age::Recipient;12};16};13use serde::{Deserialize, Serialize, de::Error};17use serde::{18 Deserialize, Serialize,19 de::{self, Error},20};14use serde_json::Value;21use serde_json::Value;1516use crate::secret::{Expectations, RegenerationReason, secret_needs_regeneration};22use tracing::info;172318#[derive(Serialize, Deserialize, Default)]24#[derive(Serialize, Deserialize, Default)]19#[serde(rename_all = "camelCase")]25#[serde(rename_all = "camelCase")]73 #[serde(default)]79 #[serde(default)]74 pub hosts: BTreeMap<String, HostData>,80 pub hosts: BTreeMap<String, HostData>,8175 #[serde(default)]82 #[serde(default, alias = "shared_secrets")]76 #[serde(skip_serializing_if = "BTreeMap::is_empty")]77 pub shared_secrets: BTreeMap<String, FleetSharedSecret>,83 pub secrets: FleetSecrets,8485 // extra_name => anything78 #[serde(default)]86 #[serde(default)]79 #[serde(skip_serializing_if = "BTreeMap::is_empty")]87 #[serde(skip_serializing_if = "BTreeMap::is_empty")]80 pub host_secrets: BTreeMap<String, BTreeMap<String, FleetHostSecret>>,88 pub extra: BTreeMap<String, Value>,818982 // extra_name => anything83 #[serde(default)]90 #[serde(default)]84 #[serde(skip_serializing_if = "BTreeMap::is_empty")]91 #[serde(skip_serializing_if = "BTreeMap::is_empty")]85 pub extra: BTreeMap<String, Value>,92 host_secrets: BTreeMap<String, BTreeMap<String, FleetSecretDistribution>>,86}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}8710588/// Returns None if recipients.is_empty()106/// Returns None if recipients.is_empty()89pub fn encrypt_secret_data<'r>(107pub fn encrypt_secret_data<'r>(129#[derive(Serialize, Deserialize, Clone)]147#[derive(Serialize, Deserialize, Clone)]130#[serde(rename_all = "camelCase")]148#[serde(rename_all = "camelCase")]131#[must_use]149#[must_use]132pub struct FleetHostSecret {150pub struct FleetSecretDistribution {133 #[serde(default)]151 #[serde(default)]134 #[serde(skip_serializing_if = "Option::is_none")]152 #[serde(skip_serializing_if = "Option::is_none")]135 pub managed: Option<bool>,153 pub managed: Option<bool>,154 #[serde(default)]155 pub owners: BTreeSet<String>,136 #[serde(flatten)]156 #[serde(flatten)]137 pub secret: FleetSecretData,157 pub secret: FleetSecretData,138}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}171139impl FleetHostSecret {172impl 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 }183140 pub fn needs_regeneration(&self, expectations: &Expectations) -> Option<RegenerationReason> {184 pub fn get(&self, owner: &str) -> Option<&FleetSecretDistribution> {141 secret_needs_regeneration(&self.secret, &expectations.owners, expectations)185 self.0.iter().find(|d| d.owners.contains(owner))142 }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 }143}209}144210211struct 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 {145#[derive(Serialize, Deserialize, Clone)]357 let Entry::Occupied(mut dists) = self.0.entry(secret.to_owned()) else {358 return false;359 };146#[serde(rename_all = "camelCase")]360 let DistEntry::Occupied(dist) = dists.get_mut().entry(owner.to_owned()) else {147#[must_use]361 return false;362 };363364 dist.remove();365366 if dists.get().0.is_empty() {367 dists.remove();368 };369370 true371 }148pub struct FleetSharedSecret {372 pub fn set_single_data(&mut self, secret: String, owner: String, data: FleetSecretData) {149 #[serde(default)]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> {150 #[serde(skip_serializing_if = "Option::is_none")]391 let secret = self.0.get(secret)?;392 secret.get(owner)393 }151 pub managed: Option<bool>,394 pub fn get(&self, secret: &str) -> Option<&FleetSecretDistributions> {395 self.0.get(secret)396 }397152 pub owners: BTreeSet<String>,398 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 {153 #[serde(flatten)]405 self.0.contains_key(secret)406 }154 pub secret: FleetSecretData,407 pub fn remove(&mut self, secret: &str) {155}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}156423crates/fleet-base/src/host.rsdiffbeforeafterboth222223use crate::{23use crate::{24 command::MyCommand,24 command::MyCommand,25 fleetdata::{FleetData, FleetHostSecret, FleetSharedSecret},25 fleetdata::{FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretDistributions},26 secret::{HostSecretDefinition, SharedSecretDefinition},26 secret::{HostSecretDefinition, SharedSecretDefinition},27};27};2828623 let config_field = &self.config_field;623 let config_field = &self.config_field;624 nix_go!(config_field.sharedSecrets).list_fields()624 nix_go!(config_field.sharedSecrets).list_fields()625 }625 }626 /// Shared secrets configured in fleet.nix627 pub fn list_shared(&self) -> Vec<String> {628 let data = self.data();629 data.shared_secrets.keys().cloned().collect()630 }631 pub fn has_shared(&self, name: &str) -> bool {626 pub fn has_shared(&self, name: &str) -> bool {632 let data = self.data();627 let data = self.data();633 data.shared_secrets.contains_key(name)628 data.secrets.contains(name)634 }629 }635 pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {630 pub fn replace_shared(&self, name: String, shared: FleetSecretDistribution) {636 let mut data = self.data_mut();631 let mut data = self.data_mut();637 data.shared_secrets.insert(name.to_owned(), shared);632 data.secrets.set_data(name, shared);638 }633 }639 pub fn remove_shared(&self, secret: &str) {634 pub fn remove_shared(&self, secret: &str) {640 let mut data = self.data_mut();635 let mut data = self.data_mut();641 data.shared_secrets.remove(secret);636 data.secrets.remove(secret);642 }637 }643638644 pub fn list_secrets(&self, host: &str) -> Vec<String> {639 pub fn list_secrets_for_owner(&self, host: &str) -> Vec<String> {645 let data = self.data();640 let data = self.data_mut();646 let mut out = data641 data.secrets.keys_for_owner(host).cloned().collect()647 .host_secrets648 .get(host)649 .map(|s| s.keys().cloned().collect::<Vec<String>>())650 .unwrap_or_default();651652 for (name, shared) in data.shared_secrets.iter() {653 if shared.owners.contains(host) {654 out.push(name.clone());655 }656 }657658 out659 }642 }643 pub fn list_secrets(&self) -> Vec<String> {644 let data = self.data_mut();645 data.secrets.keys().cloned().collect()646 }660647661 pub fn has_secret(&self, host: &str, secret: &str) -> bool {648 pub fn has_secret(&self, host: &str, secret: &str) -> bool {662 let data = self.data();649 let data = self.data();663 let Some(host_secrets) = data.host_secrets.get(host) else {650 data.secrets.contains_for_owner(secret, host)664 return false;665 };666 host_secrets.contains_key(secret)667 }651 }668 pub fn insert_secret(&self, host: &str, secret: String, value: FleetHostSecret) {652 pub fn insert_secret(&self, host: String, secret: String, value: FleetSecretData) {669 let mut data = self.data_mut();653 let mut data = self.data_mut();670 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();671 host_secrets.insert(secret, value);654 data.secrets.set_single_data(secret, host, value);672 }655 }673 pub fn remove_secret(&self, host: &str, secret: &str) {656 pub fn remove_secret(&self, host: &str, secret: &str) {674 let mut data = self.data_mut();657 let mut data = self.data_mut();675 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();658 data.secrets.drop_owner_no_reencrypt(secret, host);676 host_secrets.remove(secret);677 }659 }678660679 pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetHostSecret> {661 pub fn host_secret(&self, host: &str, secret: &str) -> Option<FleetSecretDistribution> {680 let data = self.data();662 let data = self.data();681 if let Some(host_secrets) = data.host_secrets.get(host) {663 data.secrets.get_single(secret, host).cloned()682 if let Some(secret) = host_secrets.get(secret) {683 return Ok(secret.clone());684 }685 };686 let Some(shared) = data.shared_secrets.get(secret) else {687 bail!("machine {host} has no secret {secret}");688 };689 if !shared.owners.contains(host) {690 bail!("shared secret {secret} is not owned by {host}");691 };692 Ok(FleetHostSecret {693 managed: shared.managed,694 secret: shared.secret.clone(),695 })696 }664 }697 pub fn shared_secret(&self, secret: &str) -> Result<Option<FleetSharedSecret>> {665 pub fn shared_secret(&self, secret: &str) -> Option<FleetSecretDistributions> {698 let data = self.data();666 let data = self.data();699 Ok(data.shared_secrets.get(secret).cloned())667 data.secrets.get(secret).cloned()700 }668 }701 pub fn shared_secret_definition(&self, secret: &str) -> Result<SharedSecretDefinition> {669 pub fn shared_secret_definition(&self, secret: &str) -> Result<SharedSecretDefinition> {702 let config_field = &self.config_field;670 let config_field = &self.config_field;crates/fleet-base/src/opts.rsdiffbeforeafterboth211 }211 }212 let bytes =212 let bytes =213 std::fs::read_to_string(&fleet_data_path).context("reading fleet state (fleet.nix)")?;213 std::fs::read_to_string(&fleet_data_path).context("reading fleet state (fleet.nix)")?;214 let data: Mutex<FleetData> = nixlike::parse_str(&bytes)?;214 let data = Mutex::new(FleetData::from_str(&bytes)?);215215216 let mut fetch_settings = FetchSettings::new();216 let mut fetch_settings = FetchSettings::new();217 fetch_settings.set(c"warn-dirty", c"false");217 fetch_settings.set(c"warn-dirty", c"false");flake.lockdiffbeforeafterboth2 "nodes": {2 "nodes": {3 "crane": {3 "crane": {4 "locked": {4 "locked": {5 "lastModified": 1766181779,5 "lastModified": 1767461147,6 "owner": "ipetkov",6 "owner": "ipetkov",7 "repo": "crane",7 "repo": "crane",8 "rev": "0263f510ba38bee5b7f817498066adaad694e50b",8 "rev": "7d59256814085fd9666a2ae3e774dc5ee216b630",9 "type": "github"9 "type": "github"10 },10 },11 "original": {11 "original": {37 ]37 ]38 },38 },39 "locked": {39 "locked": {40 "lastModified": 1765835352,40 "lastModified": 1767609335,41 "owner": "hercules-ci",41 "owner": "hercules-ci",42 "repo": "flake-parts",42 "repo": "flake-parts",43 "rev": "a34fae9c08a15ad73f295041fec82323541400a9",43 "rev": "250481aafeb741edfe23d29195671c19b36b6dca",44 "type": "github"44 "type": "github"45 },45 },46 "original": {46 "original": {126 },126 },127 "nixpkgs": {127 "nixpkgs": {128 "locked": {128 "locked": {129 "lastModified": 1766181714,129 "lastModified": 1767657734,130 "owner": "nixos",130 "owner": "nixos",131 "repo": "nixpkgs",131 "repo": "nixpkgs",132 "rev": "ff2da5fee8b3248cac330f14eac98228620beab0",132 "rev": "d4ccebf51ee4dbeb9df364dce1fe9848635c1258",133 "type": "github"133 "type": "github"134 },134 },135 "original": {135 "original": {190 ]190 ]191 },191 },192 "locked": {192 "locked": {193 "lastModified": 1766112155,193 "lastModified": 1767667566,194 "owner": "oxalica",194 "owner": "oxalica",195 "repo": "rust-overlay",195 "repo": "rust-overlay",196 "rev": "2a6db3fc1c27ae77f9caa553d7609b223cb770b5",196 "rev": "056ce5b125ab32ffe78c7d3e394d9da44733c95e",197 "type": "github"197 "type": "github"198 },198 },199 "original": {199 "original": {223 ]223 ]224 },224 },225 "locked": {225 "locked": {226 "lastModified": 1766000401,226 "lastModified": 1767468822,227 "owner": "numtide",227 "owner": "numtide",228 "repo": "treefmt-nix",228 "repo": "treefmt-nix",229 "rev": "42d96e75aa56a3f70cab7e7dc4a32868db28e8fd",229 "rev": "d56486eb9493ad9c4777c65932618e9c2d0468fc",230 "type": "github"230 "type": "github"231 },231 },232 "original": {232 "original": {flake.nixdiffbeforeafterboth128 overlays = [128 overlays = [129 (inputs.rust-overlay.overlays.default)129 (inputs.rust-overlay.overlays.default)130 (final: prev: {130 (final: prev: {131 boehmgc = prev.boehmgc.overrideAttrs (prevAttrs: {132 configureFlags = prevAttrs.configureFlags ++ [133 "--enable-gc-assertions"134 ];135 });136 # Libsecret is stupidly huge131 # Libsecret is stupidly huge137 # https://github.com/oxalica/rust-overlay/issues/211132 # https://github.com/oxalica/rust-overlay/issues/211138 libsecret = final.stdenv.mkDerivation {133 libsecret = final.stdenv.mkDerivation {modules/secrets-data.nixdiffbeforeafterboth1{1{2 lib,2 lib,3 fleetLib,3 fleetLib,4 config,5 ...4 ...6}:5}:7let6let16 bool15 bool17 unspecified16 unspecified18 ;17 ;19 inherit (lib.attrsets)20 mapAttrsToList21 mapAttrs22 filterAttrs23 genAttrs24 ;25 inherit (lib.lists) sort unique concatLists;26 inherit (lib.strings) toJSON;271828 secretDataValue = {19 secretDataValue = {29 options = {20 options = {71 default = null;62 default = null;72 };63 };73 };64 };74 config = { };75 };65 };766677 hostSecretData = {78 freeformType = attrsOf (submodule secretDataValue);79 options = {80 createdAt = mkOption {81 type = str;82 description = "Timestamp of secret generation/last rotation.";83 default = null;84 };85 expiresAt = mkOption {86 type = nullOr str;87 description = "Expiration timestamp triggering mandatory secret rotation.";88 default = null;89 };90 shared = mkOption {91 type = bool;92 description = "Indicates if secret is a shared secret, so other hosts might have the same piece of secret data.";93 default = false;94 };95 generationData = mkOption {96 type = unspecified;97 description = "Contextual metadata associated with secret part.";98 default = null;99 };100 };101 config = { };102 };103 managerKey = {67 managerKey = {104 options = {68 options = {105 name = mkOption {69 name = mkOption {121 managerKeys = mkOption {85 managerKeys = mkOption {122 type = listOf (submodule managerKey);86 type = listOf (submodule managerKey);123 };87 };124 sharedSecrets = mkOption {88 secrets = mkOption {125 type = attrsOf (submodule sharedSecretData);89 type = attrsOf (listOf submodule sharedSecretData);126 default = { };90 default = { };127 description = "Shared secret data.";91 description = "Shared secret data.";128 };92 };129 hostSecrets = mkOption {130 type = attrsOf (attrsOf (submodule hostSecretData));131 default = { };132 description = "Host-specific secrets.";133 internal = true;134 };135 };93 };136 config.hostSecrets =137 let138 hostsWithSharedSecrets = unique (139 concatLists (mapAttrsToList (_: s: s.owners) config.sharedSecrets)140 );141 secretsHavingHost = host: filterAttrs (_: secret: lib.elem host secret.owners) config.sharedSecrets;142 toHostSecret = _: secret: (removeAttrs secret [ "owners" ]) // { shared = true; };143 in144 genAttrs hostsWithSharedSecrets (host: mapAttrs toHostSecret (secretsHavingHost host));145 });94 });146 config = {147 assertions =148 (mapAttrsToList (name: secret: {149 assertion =150 secret.expectedOwners == null151 ||152 sort (a: b: a < b) (config.data.sharedSecrets.${name} or { owners = [ ]; }).owners153 == sort (a: b: a < b) secret.expectedOwners;154 message = "Shared secret ${name} is expected to be encrypted for ${toJSON secret.expectedOwners}, but it is encrypted for ${155 toJSON (config.data.sharedSecrets.${name} or { owners = [ ]; }).owners156 }. Run fleet secrets regenerate to fix";157 }) config.sharedSecrets)158159 ++ (mapAttrsToList (name: secret: {160 # TODO: Same assertion should be in host secrets161 assertion =162 (config.data.sharedSecrets.${name} or { generationData = null; }).generationData163 == secret.expectedGenerationData;164 message = "Shared secret ${name} has unexpected generation data ${toJSON secret.expectedGenerationData} != ${165 toJSON (config.data.sharedSecrets.${name} or { generationData = null; }).generationData166 }. Run fleet secrets regenerate to fix";167 }) config.sharedSecrets);168 };169}95}17096modules/secrets.nixdiffbeforeafterboth1{1{2 lib,2 lib,3 config,4 ...3 ...5}:4}:6let5let18 uniq17 uniq19 ;18 ;20 inherit (lib.strings) concatStringsSep;19 inherit (lib.strings) concatStringsSep;21 inherit (lib.attrsets) mapAttrs;222023 sharedSecret =21 sharedSecret =24 { config, ... }:22 { config, ... }:54 Set to false if host permissions are revoked through alternative mechanisms like firewall rules.52 Set to false if host permissions are revoked through alternative mechanisms like firewall rules.55 '';53 '';56 };54 };55 allowDifferent = mkOption {56 type = bool;57 description = ''58 When adding owner, do not update secret value for other owners, instead creating a new distribution59 '';60 };57 generator = mkOption {61 generator = mkOption {58 type = uniq (nullOr (functionTo package));62 type = uniq (nullOr (functionTo package));59 description = ''63 description = ''84in88in85{89{86 options = {90 options = {87 sharedSecrets = mkOption {91 secrets = mkOption {88 type = attrsOf (submodule sharedSecret);92 type = attrsOf (submodule sharedSecret);89 default = { };93 default = { };90 description = "Collection of secrets shared across multiple hosts with configurable ownership";94 description = "Collection of secrets shared across multiple hosts with configurable ownership";91 };95 };92 };96 };93 config = {97 config = {94 hosts = mapAttrs (95 _: secretMap:96 let97 partsOf =98 s:99 removeAttrs s [100 "createdAt"101 "expiresAt"102 "generationData"103 ];104105 in106 {107 nixos.data.secrets = mapAttrs (_: s: partsOf s) secretMap;108 # nixos.secrets = mapAttrs (109 # _: s: mapAttrs (_: _: {}) (partsOf s)110 # ) secretMap;111 }112 ) config.data.hostSecrets;113 nixpkgs.overlays = [98 nixpkgs.overlays = [114 (final: prev: {99 (final: prev: {115 mkSecretGenerators =100 mkSecretGenerators =