git.delta.rocks / jrsonnet / refs/commits / 3e7b063c34a7

difftreelog

feat secret regeneration

Yaroslav Bolyukin2024-11-30parent: #2a6bd68.patch.diff
in: trunk

6 files changed

modifiedcmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/secrets/mod.rs
+++ b/cmds/fleet/src/cmds/secrets/mod.rs
@@ -130,13 +130,28 @@
 	},
 }
 
+fn secret_needs_regeneration(
+	secret: &FleetSecret,
+	expected_generation_data: &serde_json::Value,
+) -> bool {
+	let data_is_expected = secret.generation_data == *expected_generation_data;
+	// TODO: Leeway?
+	let expired = secret
+		.expires_at
+		.map(|expiration| expiration < Utc::now())
+		.unwrap_or(false);
+	expired || !data_is_expected
+}
+
+#[allow(clippy::too_many_arguments)]
 #[tracing::instrument(skip(config, secret, field, prefer_identities, batch))]
-async fn update_owner_set(
+async fn maybe_regenerate_shared_secret(
 	secret_name: &str,
 	config: &Config,
 	mut secret: FleetSharedSecret,
 	field: Value,
 	expected_owners: &[String],
+	expected_generation_data: serde_json::Value,
 	prefer_identities: &[String],
 	batch: Option<NixBuildBatch>,
 ) -> Result<FleetSharedSecret> {
@@ -145,12 +160,18 @@
 	let set = original_set.iter().collect::<BTreeSet<_>>();
 	let expected_set = expected_owners.iter().collect::<BTreeSet<_>>();
 
-	if set == expected_set {
+	let regeneration_required =
+		secret_needs_regeneration(&secret.secret, &expected_generation_data);
+
+	if set == expected_set && !regeneration_required {
 		info!("no need to update owner list, it is already correct");
 		return Ok(secret);
 	}
 
-	let should_regenerate = if set.difference(&expected_set).next().is_some() {
+	let should_regenerate = if regeneration_required {
+		info!("secret has its generation data changed, regeneration is required");
+		true
+	} else if set.difference(&expected_set).next().is_some() {
 		// TODO: Remove this warning for revokable secrets.
 		warn!("host was removed from secret owners, but until this host rebuild, the secret will still be stored on it.");
 		nix_go_json!(field.regenerateOnOwnerRemoved)
@@ -161,9 +182,16 @@
 	};
 
 	if should_regenerate {
-		info!("secret is owner-dependent, will regenerate");
-		let generated =
-			generate_shared(config, secret_name, field, expected_owners.to_vec(), batch).await?;
+		info!("secret needs to be regenerated");
+		let generated = generate_shared(
+			config,
+			secret_name,
+			field,
+			expected_owners.to_vec(),
+			expected_generation_data,
+			batch,
+		)
+		.await?;
 		Ok(generated)
 	} else {
 		drop(batch);
@@ -216,7 +244,8 @@
 	_display_name: &str,
 	secret: Value,
 	default_generator: Value,
-	owners: &[String],
+	expected_owners: &[String],
+	expected_generation_data: serde_json::Value,
 	batch: Option<NixBuildBatch>,
 ) -> Result<FleetSecret> {
 	let generator = nix_go!(secret.generator);
@@ -232,7 +261,7 @@
 	let mk_secret_generators = nix_go!(on_pkgs.mkSecretGenerators);
 
 	let mut recipients = Vec::new();
-	for owner in owners {
+	for owner in expected_owners {
 		let key = config.key(owner).await?;
 		recipients.push(key);
 	}
@@ -288,15 +317,15 @@
 		created_at,
 		expires_at,
 		parts,
-		// TODO: Fill with expected
-		generation_data: serde_json::Value::Null,
+		generation_data: expected_generation_data,
 	})
 }
 async fn generate(
 	config: &Config,
 	display_name: &str,
 	secret: Value,
-	owners: &[String],
+	expected_owners: &[String],
+	expected_generation_data: serde_json::Value,
 	batch: Option<NixBuildBatch>,
 ) -> Result<FleetSecret> {
 	let generator = nix_go!(secret.generator);
@@ -335,13 +364,21 @@
 				display_name,
 				secret,
 				default_generator,
-				owners,
+				expected_owners,
+				expected_generation_data,
 				batch,
 			)
 			.await
 		}
 		GeneratorKind::Pure => {
-			generate_pure(config, display_name, secret, default_generator, owners).await
+			generate_pure(
+				config,
+				display_name,
+				secret,
+				default_generator,
+				expected_owners,
+			)
+			.await
 		}
 	}
 }
@@ -350,11 +387,20 @@
 	display_name: &str,
 	secret: Value,
 	expected_owners: Vec<String>,
+	expected_generation_data: serde_json::Value,
 	batch: Option<NixBuildBatch>,
 ) -> Result<FleetSharedSecret> {
 	// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);
 	Ok(FleetSharedSecret {
-		secret: generate(config, display_name, secret, &expected_owners, batch).await?,
+		secret: generate(
+			config,
+			display_name,
+			secret,
+			&expected_owners,
+			expected_generation_data,
+			batch,
+		)
+		.await?,
 		owners: expected_owners,
 	})
 }
@@ -615,13 +661,15 @@
 
 				let config_field = &config.config_field;
 				let field = nix_go!(config_field.sharedSecrets[{ name }]);
+				let expected_generation_data = nix_go_json!(field.expectedGenerationData);
 
-				let updated = update_owner_set(
+				let updated = maybe_regenerate_shared_secret(
 					&name,
 					config,
 					secret,
 					field,
 					&target_machines,
+					expected_generation_data,
 					&prefer_identities,
 					None,
 				)
@@ -630,7 +678,9 @@
 			}
 			Secret::Regenerate { prefer_identities } => {
 				info!("checking for secrets to regenerate");
+				let stored_shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();
 				{
+					// Generate missing shared
 					let shared_batch = None;
 					let _span = info_span!("shared").entered();
 					let expected_shared_set = config
@@ -638,14 +688,15 @@
 						.await?
 						.into_iter()
 						.collect::<HashSet<_>>();
-					let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();
-					for missing in expected_shared_set.difference(&shared_set) {
+					for missing in expected_shared_set.difference(&stored_shared_set) {
 						let config_field = &config.config_field;
 						let secret = nix_go!(config_field.sharedSecrets[{ missing }]);
+						let expected_generation_data: serde_json::Value =
+							nix_go_json!(secret.expectedGenerationData);
 						let expected_owners: Option<Vec<String>> =
 							nix_go_json!(secret.expectedOwners);
 						let Some(expected_owners) = expected_owners else {
-							// TODO: Might still need to regenerate
+							// Can't generate this missing secret, as it has no defined owners.
 							continue;
 						};
 						info!("generating secret: {missing}");
@@ -654,6 +705,7 @@
 							missing,
 							secret,
 							expected_owners,
+							expected_generation_data,
 							shared_batch.clone(),
 						)
 						.in_current_span()
@@ -681,11 +733,13 @@
 					for missing in expected_set.difference(&stored_set) {
 						info!("generating secret: {missing}");
 						let secret = host.secret_field(missing).in_current_span().await?;
+						let expected_generation_data = nix_go_json!(secret.expectedGenerationData);
 						let generated = match generate(
 							config,
 							missing,
 							secret,
 							&[host.name.clone()],
+							expected_generation_data,
 							hosts_batch.clone(),
 						)
 						.in_current_span()
@@ -699,9 +753,35 @@
 						};
 						config.insert_secret(&host.name, missing.to_string(), generated)
 					}
+					for name in stored_set {
+						info!("updating secret: {name}");
+						let data = config.host_secret(&host.name, &name)?;
+						let secret = host.secret_field(&name).in_current_span().await?;
+						let expected_generation_data = nix_go_json!(secret.expectedGenerationData);
+						if secret_needs_regeneration(&data, &expected_generation_data) {
+							let generated = match generate(
+								config,
+								&name,
+								secret,
+								&[host.name.clone()],
+								expected_generation_data,
+								hosts_batch.clone(),
+							)
+							.in_current_span()
+							.await
+							{
+								Ok(v) => v,
+								Err(e) => {
+									error!("{e:?}");
+									continue;
+								}
+							};
+							config.insert_secret(&host.name, name.to_string(), generated)
+						}
+					}
 				}
 				let mut to_remove = Vec::new();
-				for name in &config.list_shared() {
+				for name in &stored_shared_set {
 					info!("updating secret: {name}");
 					let data = config.shared_secret(name)?;
 					let config_field = &config.config_field;
@@ -714,14 +794,16 @@
 					}
 
 					let secret = nix_go!(config_field.sharedSecrets[{ name }]);
+					let expected_generation_data = nix_go_json!(secret.expectedGenerationData);
 					config.replace_shared(
 						name.to_owned(),
-						update_owner_set(
+						maybe_regenerate_shared_secret(
 							name,
 							config,
 							data,
 							secret,
 							&expected_owners,
+							expected_generation_data,
 							&prefer_identities,
 							None,
 						)
modifiedcrates/fleet-base/src/host.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/host.rs
+++ b/crates/fleet-base/src/host.rs
@@ -1,5 +1,6 @@
 use std::{
 	cell::OnceCell,
+	collections::BTreeSet,
 	ffi::{OsStr, OsString},
 	fmt::Display,
 	io::Write,
@@ -312,6 +313,23 @@
 }
 
 impl Config {
+	pub async fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {
+		let config = &self.config_field;
+		let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);
+		Ok(tagged)
+	}
+	pub async fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {
+		let mut out = BTreeSet::new();
+		for owner in owners {
+			if let Some(tag) = owner.strip_prefix('@') {
+				let hosts = self.tagged_hostnames(tag).await?;
+				out.extend(hosts);
+			} else {
+				out.insert(owner);
+			}
+		}
+		Ok(out)
+	}
 	pub fn local_host(&self) -> ConfigHost {
 		ConfigHost {
 			config: self.clone(),
modifiedcrates/fleet-base/src/keys.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/keys.rs
+++ b/crates/fleet-base/src/keys.rs
@@ -45,6 +45,7 @@
 	}
 
 	pub async fn recipients(&self, hosts: Vec<String>) -> Result<Vec<impl Recipient>> {
+		let hosts = self.expand_owner_set(hosts).await?;
 		futures::stream::iter(hosts.iter())
 			.then(|m| self.recipient(m.as_ref()))
 			.try_collect::<Vec<_>>()
modifiedmodules/nixos/secrets.nixdiffbeforeafterboth
before · modules/nixos/secrets.nix
1{2  lib,3  fleetLib,4  config,5  pkgs,6  ...7}: let8  inherit (builtins) hashString;9  inherit (lib.stringsWithDeps) stringAfter;10  inherit (lib.options) mkOption literalExpression;11  inherit (lib.lists) optional;12  inherit (lib.attrsets) mapAttrs;13  inherit (lib.modules) mkIf;14  inherit (lib.types) submodule str attrsOf nullOr unspecified lazyAttrsOf;15  inherit (fleetLib.strings) decodeRawSecret;1617  sysConfig = config;18  secretPartType = secretName:19    submodule ({config, ...}: let20      partName = config._module.args.name;21    in {22      options = {23        raw = mkOption {24          type = str;25          internal = true;26          description = "Encoded & Encrypted secret part data, passed from fleet.nix";27        };28        hash = mkOption {29          type = str;30          description = "Hash of secret in encoded format";31        };32        path = mkOption {33          type = str;34          description = "Path to secret part, incorporating data hash (thus it will be updated on secret change)";35        };36        stablePath = mkOption {37          type = str;38          description = "Path to secret part, incorporating data hash (thus it will be updated on secret change)";39        };40        data = mkOption {41          type = str;42          description = "Secret public data (only available for plaintext)";43        };4445        expectedGenerationData = mkOption {46          type = unspecified;47          description = "Data that gets embedded into secret part";48          default = null;49        };50        generationData = mkOption {51          type = unspecified;52          description = "Data that is embedded into secret part";53          default = null;54        };55      };56      config = {57        hash = hashString "sha1" config.raw;58        data = decodeRawSecret config.raw;59        path = "/run/secrets/${secretName}/${config.hash}-${partName}";60        stablePath = "/run/secrets/${secretName}/${partName}";61      };62    });63  secretType = submodule ({config, ...}: let64    secretName = config._module.args.name;65  in {66    freeformType = lazyAttrsOf (secretPartType secretName);67    options = {68      shared = mkOption {69        description = "Is this secret owned by this machine, or propagated from shared secrets";70        default = false;71      };7273      generator = mkOption {74        type = nullOr unspecified;75        description = "Derivation to evaluate for secret generation";76        default = null;77      };78      mode = mkOption {79        type = str;80        description = "Secret mode";81        default = "0440";82      };83      owner = mkOption {84        type = str;85        description = "Owner of the secret";86        default = "root";87      };88      group = mkOption {89        type = str;90        description = "Group of the secret";91        default = sysConfig.users.users.${config.owner}.group;92        defaultText = literalExpression "config.users.users.$${owner}.group";93      };94    };95  });96  processPart = part: {97    inherit (part) raw path stablePath;98  };99  processSecret = secret:100    {101      inherit (secret) group mode owner;102    }103    // (mapAttrs (_: processPart) (removeAttrs secret [104      "shared"105      "generator"106      "mode"107      "group"108      "owner"109    ]));110  secretsFile = pkgs.writeTextFile {111    name = "secrets.json";112    text =113      builtins.toJSON (mapAttrs (_: processSecret)114        config.secrets);115  };116  useSysusers = (config.systemd ? sysusers && config.systemd.sysusers.enable) || (config ? userborn && config.userborn.enable);117in {118  options = {119    secrets = mkOption {120      type = attrsOf secretType;121      default = {};122      description = "Host-local secrets";123    };124  };125  config = {126    environment.systemPackages = [pkgs.fleet-install-secrets];127128    systemd.services.fleet-install-secrets = mkIf useSysusers {129      wantedBy = ["sysinit.target"];130      after = ["systemd-sysusers.service"];131      restartTriggers = [132        secretsFile133      ];134      aliases = [135        "sops-install-secrets"136        "agenix-install-secrets"137      ];138139      unitConfig.DefaultDependencies = false;140141      serviceConfig = {142        Type = "oneshot";143        RemainAfterExit = true;144        ExecStart = "${pkgs.fleet-install-secrets}/bin/fleet-install-secrets install ${secretsFile}";145      };146    };147    system.activationScripts.decryptSecrets =148      mkIf (!useSysusers)149      (150        stringAfter (151          [152            # secrets are owned by user/group, thus we need to refer to those153            "users"154            "groups"155            "specialfs"156          ]157          # nixos-impermanence compatibility: secrets are encrypted by host-key,158          # but with impermanence we expect that the host-key is installed by159          # persist-file activation script.160          ++ (optional (config.system.activationScripts ? "persist-files") "persist-files")161        ) ''162          1>&2 echo "setting up secrets"163          ${pkgs.fleet-install-secrets}/bin/fleet-install-secrets install ${secretsFile}164        ''165      );166  };167}
after · modules/nixos/secrets.nix
1{2  lib,3  fleetLib,4  config,5  pkgs,6  ...7}: let8  inherit (builtins) hashString;9  inherit (lib.stringsWithDeps) stringAfter;10  inherit (lib.options) mkOption literalExpression;11  inherit (lib.lists) optional;12  inherit (lib.attrsets) mapAttrs;13  inherit (lib.modules) mkIf;14  inherit (lib.types) submodule str attrsOf nullOr unspecified lazyAttrsOf;15  inherit (fleetLib.strings) decodeRawSecret;1617  sysConfig = config;18  secretPartType = secretName:19    submodule ({config, ...}: let20      partName = config._module.args.name;21    in {22      options = {23        raw = mkOption {24          type = str;25          internal = true;26          description = "Encoded & Encrypted secret part data, passed from fleet.nix";27        };28        hash = mkOption {29          type = str;30          description = "Hash of secret in encoded format";31        };32        path = mkOption {33          type = str;34          description = "Path to secret part, incorporating data hash (thus it will be updated on secret change)";35        };36        stablePath = mkOption {37          type = str;38          description = "Path to secret part, incorporating data hash (thus it will be updated on secret change)";39        };40        data = mkOption {41          type = str;42          description = "Secret public data (only available for plaintext)";43        };44      };45      config = {46        hash = hashString "sha1" config.raw;47        data = decodeRawSecret config.raw;48        path = "/run/secrets/${secretName}/${config.hash}-${partName}";49        stablePath = "/run/secrets/${secretName}/${partName}";50      };51    });52  secretType = submodule ({config, ...}: let53    secretName = config._module.args.name;54  in {55    freeformType = lazyAttrsOf (secretPartType secretName);56    options = {57      shared = mkOption {58        description = "Is this secret owned by this machine, or propagated from shared secrets";59        default = false;60      };6162      generator = mkOption {63        type = nullOr unspecified;64        description = "Derivation to evaluate for secret generation";65        default = null;66      };67      mode = mkOption {68        type = str;69        description = "Secret mode";70        default = "0440";71      };72      owner = mkOption {73        type = str;74        description = "Owner of the secret";75        default = "root";76      };77      group = mkOption {78        type = str;79        description = "Group of the secret";80        default = sysConfig.users.users.${config.owner}.group;81        defaultText = literalExpression "config.users.users.$${owner}.group";82      };83      expectedGenerationData = mkOption {84        type = unspecified;85        description = "Data that gets embedded into secret part";86        default = null;87      };88    };89  });90  processPart = part: {91    inherit (part) raw path stablePath;92  };93  processSecret = secret:94    {95      inherit (secret) group mode owner;96    }97    // (mapAttrs (_: processPart) (removeAttrs secret [98      "shared"99      "generator"100      "mode"101      "group"102      "owner"103    ]));104  secretsFile = pkgs.writeTextFile {105    name = "secrets.json";106    text =107      builtins.toJSON (mapAttrs (_: processSecret)108        config.secrets);109  };110  useSysusers = (config.systemd ? sysusers && config.systemd.sysusers.enable) || (config ? userborn && config.userborn.enable);111in {112  options = {113    secrets = mkOption {114      type = attrsOf secretType;115      default = {};116      description = "Host-local secrets";117    };118  };119  config = {120    environment.systemPackages = [pkgs.fleet-install-secrets];121122    systemd.services.fleet-install-secrets = mkIf useSysusers {123      wantedBy = ["sysinit.target"];124      after = ["systemd-sysusers.service"];125      restartTriggers = [126        secretsFile127      ];128      aliases = [129        "sops-install-secrets"130        "agenix-install-secrets"131      ];132133      unitConfig.DefaultDependencies = false;134135      serviceConfig = {136        Type = "oneshot";137        RemainAfterExit = true;138        ExecStart = "${pkgs.fleet-install-secrets}/bin/fleet-install-secrets install ${secretsFile}";139      };140    };141    system.activationScripts.decryptSecrets =142      mkIf (!useSysusers)143      (144        stringAfter (145          [146            # secrets are owned by user/group, thus we need to refer to those147            "users"148            "groups"149            "specialfs"150          ]151          # nixos-impermanence compatibility: secrets are encrypted by host-key,152          # but with impermanence we expect that the host-key is installed by153          # persist-file activation script.154          ++ (optional (config.system.activationScripts ? "persist-files") "persist-files")155        ) ''156          1>&2 echo "setting up secrets"157          ${pkgs.fleet-install-secrets}/bin/fleet-install-secrets install ${secretsFile}158        ''159      );160  };161}
modifiedmodules/secrets-data.nixdiffbeforeafterboth
--- a/modules/secrets-data.nix
+++ b/modules/secrets-data.nix
@@ -6,7 +6,7 @@
 }: let
   inherit (fleetLib.options) mkDataOption;
   inherit (lib.options) mkOption;
-  inherit (lib.types) nullOr listOf str attrsOf submodule bool;
+  inherit (lib.types) nullOr listOf str attrsOf submodule bool unspecified;
   inherit (lib.attrsets) mapAttrsToList mapAttrs filterAttrs genAttrs;
   inherit (lib.lists) sort unique concatLists;
   inherit (lib.strings) toJSON;
@@ -46,6 +46,11 @@
         '';
         default = [];
       };
+      generationData = mkOption {
+        type = unspecified;
+        description = "Data that is embedded into secret part";
+        default = null;
+      };
     };
   };
 
@@ -67,6 +72,11 @@
         description = "On which date this secret will expire, someone should regenerate this secret before it expires.";
         default = false;
       };
+      generationData = mkOption {
+        type = unspecified;
+        description = "Data that is embedded into secret part";
+        default = null;
+      };
     };
   };
 in {
@@ -93,12 +103,19 @@
   });
   config = {
     assertions =
-      mapAttrsToList
-      (name: secret: {
-        assertion = secret.expectedOwners == null || sort (a: b: a < b) config.data.sharedSecrets.${name}.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}.owners}. Run fleet secrets regenerate to fix";
-      })
-      config.sharedSecrets;
+      (mapAttrsToList
+        (name: secret: {
+          assertion = secret.expectedOwners == null || sort (a: b: a < b) config.data.sharedSecrets.${name}.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}.owners}. Run fleet secrets regenerate to fix";
+        })
+        config.sharedSecrets)
+      ++ (mapAttrsToList
+        (name: secret: {
+          # TODO: Same aassertion should be in host secrets
+          assertion = config.data.sharedSecrets.${name}.generationData == secret.expectedGenerationData;
+          message = "Shared secret ${name} has unexpected generation data ${toJSON secret.expectedGenerationData} != ${toJSON config.data.sharedSecrets.${name}.expectedGenerationData}. Run fleet secrets regenerate to fix";
+        })
+        config.sharedSecrets);
     sharedSecrets =
       mapAttrs (_: _: {}) config.data.sharedSecrets;
   };
modifiedmodules/secrets.nixdiffbeforeafterboth
--- a/modules/secrets.nix
+++ b/modules/secrets.nix
@@ -45,6 +45,11 @@
         description = "Derivation to evaluate for secret generation";
         default = null;
       };
+      expectedGenerationData = mkOption {
+        type = unspecified;
+        description = "Data that gets embedded into secret part";
+        default = null;
+      };
     };
   };
 in {