difftreelog
feat unify shared and host secret handling
in: trunk
8 files changed
cmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth1use std::{2 collections::{BTreeMap, BTreeSet, HashSet},3 io::{self, Read, Write, stdin, stdout},4 path::PathBuf,5};67use anyhow::{Context, Result, anyhow, bail, ensure};8use chrono::{DateTime, Utc};9use clap::Parser;10use fleet_base::{11 fleetdata::{12 FleetHostSecret, FleetSecretData, FleetSecretPart, FleetSharedSecret, encrypt_secret_data,13 },14 host::Config,15 opts::FleetOpts,16 secret::{Expectations, RegenerationReason, SharedSecretDefinition, secret_needs_regeneration},17};18use fleet_shared::SecretData;19use nix_eval::{NixType, Value, nix_go, nix_go_json};20use owo_colors::OwoColorize;21use serde::Deserialize;22use tabled::{Table, Tabled};23use tokio::{fs::read, task::spawn_blocking};24use tracing::{Instrument, error, info, info_span, warn};2526#[derive(Parser)]27pub enum Secret {28 AddManager,29 /// Force load host keys for all defined hosts30 ForceKeys,31 /// Add secret, data should be provided in stdin32 AddShared {33 /// Secret name34 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>,4748 /// Create a notification on secret expiration49 #[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")]58 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)]69 machine: 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>,8283 /// 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 read97 #[clap(short = 'p', long, default_value = "secret")]98 part: String,99 },100 /// 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, without107 /// regeneration108 #[clap(long)]109 prefer_identities: Vec<String>,110 },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 {127 /// Which host should we use to decrypt, in case if reencryption is required, without128 /// regeneration129 #[clap(long)]130 prefer_identities: Vec<String>,131 /// Only regenerate shared secrets132 #[clap(long)]133 skip_hosts: bool,134 },135 List {},136 Edit {137 name: String,138 #[clap(short = 'm', long)]139 machine: String,140141 #[clap(long)]142 add: bool,143144 /// Which private secret part to read145 #[clap(short = 'p', long, default_value = "secret")]146 part: String,147 },148}149150#[allow(clippy::too_many_arguments)]151#[tracing::instrument(skip(config, secret, definition, prefer_identities))]152async fn maybe_regenerate_shared_secret(153 secret_name: &str,154 config: &Config,155 mut secret: FleetSharedSecret,156 definition: SharedSecretDefinition,157 prefer_identities: &[String],158 expectations: &Expectations,159) -> Result<FleetSharedSecret> {160 let reason = secret_needs_regeneration(&secret.secret, &secret.owners, expectations);161 let value = definition.definition_value();162163 let (should_reencrypt, reason) = match reason {164 Some(RegenerationReason::OwnersAdded(_)) => {165 // Secret always needs to be reencrypted for new owners to be able to read it166 (167 true,168 if nix_go_json!(value.regenerateOnOwnerAdded) {169 reason170 } else {171 None172 },173 )174 }175 Some(RegenerationReason::OwnersRemoved(_)) => {176 // No need to reencrypt, we can just leave stanzas in place.177 if nix_go_json!(value.regenerateOnOwnerRemoved) {178 (true, reason)179 } else {180 (false, None)181 }182 }183 Some(_) => (true, reason),184 None => (false, None),185 };186187 if let Some(reason) = reason {188 info!("secret needs to be regenerated: {reason}");189 let generated = generate_shared(config, secret_name, definition, expectations).await?;190 Ok(generated)191 } else if should_reencrypt {192 info!("secret needs to be reencrypted");193 let identity_holder = if !prefer_identities.is_empty() {194 prefer_identities195 .iter()196 .find(|i| secret.owners.iter().any(|s| s == *i))197 } else {198 secret.owners.first()199 };200 let Some(identity_holder) = identity_holder else {201 bail!("no available holder found");202 };203204 for (part_name, part) in secret.secret.parts.iter_mut() {205 let _span = info_span!("part reencryption", part_name);206 if !part.raw.encrypted {207 continue;208 }209 let host = config.host(identity_holder).await?;210 let encrypted = host211 .reencrypt(212 part.raw.clone(),213 expectations.owners.iter().cloned().collect(),214 )215 .await?;216 part.raw = encrypted;217 }218 secret.owners = expectations.owners.clone();219 Ok(secret)220 } else {221 Ok(secret)222 }223}224225#[derive(Deserialize)]226#[serde(rename_all = "camelCase")]227enum GeneratorKind {228 Impure,229 Pure,230}231232async fn generate_pure(233 _config: &Config,234 _display_name: &str,235 _secret: Value,236 _default_generator: Value,237 _expectations: &Expectations,238) -> Result<FleetSecretData> {239 bail!("pure generators are broken for now")240}241async fn generate_impure(242 config: &Config,243 _display_name: &str,244 secret: Value,245 default_generator: Value,246 expectations: &Expectations,247) -> Result<FleetSecretData> {248 let generator = nix_go!(secret.generator);249 let on: Option<String> = nix_go_json!(default_generator.impureOn);250251 let nixpkgs = &config.nixpkgs;252253 let host = if let Some(on) = &on {254 config.host(on).await?255 } else {256 config.local_host()257 };258 let on_pkgs = host.pkgs().await?;259 let mk_secret_generators = nix_go!(on_pkgs.mkSecretGenerators);260261 let mut recipients = Vec::new();262 for owner in &expectations.owners {263 let key = config.key(owner).await?;264 recipients.push(key);265 }266 let generators = nix_go!(mk_secret_generators(Obj { recipients }));267 let pkgs_and_generators = on_pkgs.attrs_update(generators)?;268269 let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));270271 let generator = nix_go!(call_package(generator)(Obj {}));272273 let generator = spawn_blocking(move || generator.build("out"))274 .await275 .expect("nix build shouldn't fail")?;276 let generator = host.remote_derivation(&generator).await?;277278 let out_parent = host.mktemp_dir().await?;279 let out = format!("{out_parent}/out");280281 let mut r#gen = host.cmd(generator).await?;282 r#gen.env("out", &out);283 if on.is_none() {284 // This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.285 let project_path: String = config286 .directory287 .clone()288 .into_os_string()289 .into_string()290 .map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;291 r#gen.env("FLEET_PROJECT", project_path);292 }293 r#gen.run().await.context("impure generator")?;294295 {296 let marker = host.read_file_text(format!("{out}/marker")).await?;297 ensure!(marker == "SUCCESS", "generation not succeeded");298 }299300 let mut parts = BTreeMap::new();301 for part in host.read_dir(&out).await? {302 if part == "created_at" || part == "expires_at" || part == "marker" {303 continue;304 }305 let contents: SecretData = host306 .read_file_text(format!("{out}/{part}"))307 .await?308 .parse()309 .map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;310 parts.insert(part.to_owned(), FleetSecretPart { raw: contents });311 }312313 let created_at = host.read_file_value(format!("{out}/created_at")).await?;314 let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();315316 let new_data = FleetSecretData {317 created_at,318 expires_at,319 parts,320 generation_data: expectations.generation_data.clone(),321 };322323 if let Some(reason) = secret_needs_regeneration(&new_data, &expectations.owners, expectations) {324 bail!("newly generated secret needs to be regenerated: {reason}")325 }326327 Ok(new_data)328}329330async fn generate(331 config: &Config,332 display_name: &str,333 secret: Value,334 expectations: &Expectations,335) -> Result<FleetSecretData> {336 let generator = nix_go!(secret.generator);337 // Can't properly check on nix module system level338 {339 let gen_ty = generator.type_of();340 if matches!(gen_ty, NixType::Null) {341 bail!("secret has no generator defined, can't automatically generate it.");342 }343 if matches!(gen_ty, NixType::Attrs) {344 if !generator.has_field("__functor")? {345 bail!("generator should be functor, got {gen_ty:?}");346 }347 } else if matches!(gen_ty, NixType::Function) {348 bail!("generator should be functor, got {gen_ty:?}");349 }350 }351 let nixpkgs = &config.nixpkgs;352 let default_pkgs = &config.default_pkgs;353 let default_mk_secret_generators = nix_go!(default_pkgs.mkSecretGenerators);354 // Generators provide additional information in passthru, to access355 // passthru we should call generator, but information about where this generator is supposed to build356 // is located in passthru... Thus evaluating generator on host.357 //358 // Maybe it is also possible to do some magic with __functor?359 //360 // I don't want to make modules always responsible for additional secret data anyway,361 // so it should be in derivation, and not in the secret data itself.362 let generators = nix_go!(default_mk_secret_generators(Obj {363 recipients: <Vec<String>>::new(),364 }));365 let pkgs_and_generators = default_pkgs.clone().attrs_update(generators)?;366367 let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));368 let default_generator = nix_go!(call_package(generator)(Obj {}));369370 let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);371372 match kind {373 GeneratorKind::Impure => {374 generate_impure(375 config,376 display_name,377 secret,378 default_generator,379 expectations,380 )381 .await382 }383 GeneratorKind::Pure => {384 generate_pure(385 config,386 display_name,387 secret,388 default_generator,389 expectations,390 )391 .await392 }393 }394}395async fn generate_shared(396 config: &Config,397 display_name: &str,398 secret: SharedSecretDefinition,399 expectations: &Expectations,400) -> Result<FleetSharedSecret> {401 // let owners: Vec<String> = nix_go_json!(secret.expectedOwners);402 Ok(FleetSharedSecret {403 managed: Some(true),404 secret: generate(405 config,406 display_name,407 secret.definition_value(),408 expectations,409 )410 .await?,411 owners: expectations.owners.clone(),412 })413}414415async fn parse_public(416 public: Option<String>,417 public_file: Option<PathBuf>,418) -> Result<Option<SecretData>> {419 Ok(match (public, public_file) {420 (Some(v), None) => Some(SecretData {421 data: v.into(),422 encrypted: false,423 }),424 (None, Some(v)) => Some(SecretData {425 data: read(v).await?,426 encrypted: false,427 }),428 (Some(_), Some(_)) => {429 bail!("only public or public_file should be set")430 }431 (None, None) => None,432 })433}434435async fn parse_secret() -> Result<Option<Vec<u8>>> {436 let mut input = vec![];437 stdin().read_to_end(&mut input)?;438 if input.is_empty() {439 Ok(None)440 } else {441 Ok(Some(input))442 }443}444445fn parse_machines(446 initial: BTreeSet<String>,447 machines: Option<Vec<String>>,448 mut add_machines: Vec<String>,449 mut remove_machines: Vec<String>,450) -> Result<BTreeSet<String>> {451 if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {452 bail!("no operation");453 }454455 let initial_machines = initial.clone();456 let mut target_machines = initial;457 info!("Currently encrypted for {initial_machines:?}");458459 if let Some(machines) = machines {460 ensure!(461 add_machines.is_empty() && remove_machines.is_empty(),462 "can't combine --machines and --add-machines/--remove-machines"463 );464 let target = initial_machines.iter().collect::<HashSet<_>>();465 let source = machines.iter().collect::<HashSet<_>>();466 for removed in target.difference(&source) {467 remove_machines.push((*removed).clone());468 }469 for added in source.difference(&target) {470 add_machines.push((*added).clone());471 }472 }473474 for machine in &remove_machines {475 if !target_machines.remove(machine) {476 warn!("secret is not enabled for {machine}");477 }478 }479 for machine in &add_machines {480 if !target_machines.insert(machine.to_owned()) {481 warn!("secret is already added to {machine}");482 }483 }484 if !remove_machines.is_empty() {485 // TODO: maybe force secret regeneration?486 // Not that useful without revokation.487 warn!(488 "secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret"489 );490 }491 Ok(target_machines)492}493impl Secret {494 pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {495 match self {496 Secret::AddManager => {497 todo!("part of fleet-pusher")498 }499 Secret::ForceKeys => {500 for host in config.list_hosts().await? {501 if opts.should_skip(&host).await? {502 continue;503 }504 config.key(&host.name).await?;505 }506 }507 Secret::AddShared {508 machines,509 name,510 force,511 public,512 public_part: public_name,513 public_file,514 expires_at,515 re_add,516 part: part_name,517 } => {518 let mut machines: BTreeSet<String> = machines.into_iter().collect();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");539 };540541 let recipients = config542 .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)552 .ok_or_else(|| anyhow!("no recipients provided"))?;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 });558 }559560 config.replace_shared(561 name,562 FleetSharedSecret {563 managed: Some(false),564 owners: machines,565 secret: FleetSecretData {566 created_at: Utc::now(),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 }589590 let mut out = if merge && !replace {591 config592 .host_secret(&machine, &name)593 .context("failed to read existing secret for --merge")?594 } else {595 FleetHostSecret {596 managed: Some(false),597 secret: FleetSecretData {598 created_at: Utc::now(),599 expires_at: None,600 parts: BTreeMap::new(),601 generation_data: serde_json::Value::Null,602 },603 }604 };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);609610 if let Some(secret) = parse_secret().await? {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}");650 };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 {672 let identity_holder = if !prefer_identities.is_empty() {673 prefer_identities674 .iter()675 .find(|i| secret.owners.iter().any(|s| s == *i))676 } else {677 secret.owners.first()678 };679 let Some(identity_holder) = identity_holder else {680 bail!("no available holder found");681 };682 let host = config.host(identity_holder).await?;683 host.decrypt(part.raw.clone()).await?684 } else {685 part.raw.data.clone()686 };687 stdout().write_all(&data)?;688 }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 {736 prefer_identities,737 skip_hosts,738 } => {739 info!("checking for secrets to regenerate");740 let expected_shared_set = config741 .list_configured_shared()742 .await?743 .into_iter()744 .collect::<HashSet<_>>();745 let stored_shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();746 {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 {832 Ok(v) => v,833 Err(e) => {834 error!("{e:?}");835 continue;836 }837 };838 config.insert_secret(839 &host.name,840 known_secret.to_string(),841 FleetHostSecret {842 managed: Some(true),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)?;850 if definition.is_shared()? {851 continue;852 }853 info!("removing secret: {removed_secret}");854 config.remove_secret(&host.name, removed_secret);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");861862 let definition = config.shared_secret_definition(known_secret)?;863 let expectations = definition.expectations()?;864 config.replace_shared(865 known_secret.to_owned(),866 maybe_regenerate_shared_secret(867 known_secret,868 config,869 data,870 definition,871 &prefer_identities,872 &expectations,873 )874 .await?,875 );876 }877 for removed_secret in stored_shared_set.difference(&expected_shared_set) {878 info!("removing shared secret: {removed_secret}");879 config.remove_shared(removed_secret);880 }881 }882 Secret::List {} => {883 let _span = info_span!("loading secrets").entered();884 let configured = config.list_configured_shared().await?;885 #[derive(Tabled)]886 struct SecretDisplay {887 #[tabled(rename = "Name")]888 name: String,889 #[tabled(rename = "Owners")]890 owners: String,891 }892 let mut table = vec![];893 for name in configured.iter().cloned() {894 let config = config.clone();895 let data = config.shared_secret(&name)?.expect("exists");896 let definition = config.shared_secret_definition(&name)?;897 let expectations = definition.expectations()?;898 let owners = data899 .owners900 .iter()901 .map(|o| {902 if expectations.owners.contains(o) {903 o.green().to_string()904 } else {905 o.red().to_string()906 }907 })908 .collect::<Vec<_>>();909 table.push(SecretDisplay {910 owners: owners.join(", "),911 name,912 })913 }914 info!("loaded\n{}", Table::new(table).to_string())915 }916 Secret::Edit {917 name,918 machine,919 part,920 add,921 } => {922 let secret = config.host_secret(&machine, &name)?;923 if let Some(data) = secret.secret.parts.get(&part) {924 let host = config.host(&machine).await?;925 let secret = host.decrypt(data.raw.clone()).await?;926 String::from_utf8(secret).context("secret is not utf8")?927 } else if add {928 String::new()929 } else {930 bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");931 };932 }933 }934 Ok(())935 }936}937938/*939async fn edit_temp_file(940 builder: tempfile::Builder<'_, '_>,941 r: Vec<u8>,942 header: &str,943 comment: &str,944) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {945 if !stdin().is_tty() {946 // TODO: Also try to open /dev/tty directly?947 bail!("stdin is not tty, can't open editor");948 }949950 use std::fmt::Write;951 let mut file = builder.tempfile()?;952953 let mut full_header = String::new();954 let mut had = false;955 for line in header.trim_end().lines() {956 had = true;957 writeln!(&mut full_header, "{comment}{line}")?;958 }959 if had {960 writeln!(&mut full_header, "{}", comment.trim_end())?;961 }962 writeln!(963 &mut full_header,964 "{comment}Do not touch this header! It will be removed automatically"965 )?;966967 file.write_all(full_header.as_bytes())?;968 file.write_all(&r)?;969970 let abs_path = file.into_temp_path();971 let editor = std::env::var_os("VISUAL")972 .or_else(|| std::env::var_os("EDITOR"))973 .unwrap_or_else(|| "vi".into());974 let editor_args = shlex::bytes::split(editor.as_encoded_bytes())975 .ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;976 let editor_args = editor_args977 .into_iter()978 .map(|v| {979 // Only ASCII subsequences are replaced980 unsafe { OsString::from_encoded_bytes_unchecked(v) }981 })982 .collect_vec();983 let Some((editor, args)) = editor_args.split_first() else {984 bail!("EDITOR env var has no command");985 };986 let mut command = Command::new(editor);987 command.args(args);988989 let path_arg = abs_path.canonicalize()?;990991 // TODO: Save full state, using tcget/_getmode/_setmode992 let was_raw = terminal::is_raw_mode_enabled()?;993 terminal::enable_raw_mode()?;994995 let status = command.arg(path_arg).status().await;996997 if !was_raw {998 terminal::disable_raw_mode()?;999 }10001001 let success = match status {1002 Ok(s) => s.success(),1003 Err(e) if e.kind() == io::ErrorKind::NotFound => {1004 bail!("editor not found")1005 }1006 Err(e) => bail!("editor spawn error: {e}"),1007 };10081009 let mut file = std::fs::read(&abs_path).context("read editor output")?;1010 let Some(v) = file.strip_prefix(full_header.as_bytes()) else {1011 todo!();1012 };1013 todo!();10141015 // Ok((success, abs_path))1016}1017*/crates/fleet-base/src/fleetdata.rsdiffbeforeafterboth--- 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<String, HostData>,
+
+ #[serde(default, alias = "shared_secrets")]
+ pub secrets: FleetSecrets,
+
+ // extra_name => anything
#[serde(default)]
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
- pub shared_secrets: BTreeMap<String, FleetSharedSecret>,
- #[serde(default)]
- #[serde(skip_serializing_if = "BTreeMap::is_empty")]
- pub host_secrets: BTreeMap<String, BTreeMap<String, FleetHostSecret>>,
+ pub extra: BTreeMap<String, Value>,
- // extra_name => anything
#[serde(default)]
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
- pub extra: BTreeMap<String, Value>,
+ host_secrets: BTreeMap<String, BTreeMap<String, FleetSecretDistribution>>,
+}
+impl FleetData {
+ pub fn from_str(s: &str) -> anyhow::Result<Self> {
+ 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<bool>,
+ #[serde(default)]
+ pub owners: BTreeSet<String>,
#[serde(flatten)]
pub secret: FleetSecretData,
}
-impl FleetHostSecret {
- pub fn needs_regeneration(&self, expectations: &Expectations) -> Option<RegenerationReason> {
- secret_needs_regeneration(&self.secret, &expectations.owners, expectations)
+
+#[derive(Clone)]
+#[must_use]
+pub struct FleetSecretDistributions(Vec<FleetSecretDistribution>);
+
+impl Deref for FleetSecretDistributions {
+ type Target = [FleetSecretDistribution];
+
+ fn deref(&self) -> &Self::Target {
+ self.0.as_slice()
+ }
+}
+
+impl FleetSecretDistributions {
+ pub fn owners(&self) -> impl Iterator<Item = &String> {
+ 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<bool>,
- pub owners: BTreeSet<String>,
- #[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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ 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<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ #[derive(Deserialize)]
+ #[serde(untagged)]
+ enum Distributions {
+ One(FleetSecretDistribution),
+ Many(Vec<FleetSecretDistribution>),
+ }
+ 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<String, FleetSecretDistributions>);
+
+impl FleetSecrets {
+ pub fn keys(&self) -> btree_map::Keys<String, FleetSecretDistributions> {
+ self.0.keys()
+ }
+
+ pub fn keys_for_owner(&self, owner: &str) -> impl Iterator<Item = &String> {
+ 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<String, BTreeMap<String, FleetSecretDistribution>>,
+ ) {
+ 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);
+ }
+ }
+ }
}
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 =