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
before · crates/fleet-base/src/host.rs
1use std::{2	cell::OnceCell,3	ffi::{OsStr, OsString},4	fmt::Display,5	io::Write,6	ops::Deref,7	path::PathBuf,8	str::FromStr,9	sync::{Arc, Mutex, MutexGuard, OnceLock},10};1112use anyhow::{anyhow, bail, ensure, Context, Result};13use fleet_shared::SecretData;14use nix_eval::{nix_go, nix_go_json, util::assert_warn, NixSession, Value};15use openssh::SessionBuilder;16use serde::de::DeserializeOwned;17use tempfile::NamedTempFile;1819use crate::{20	command::MyCommand,21	fleetdata::{FleetData, FleetSecret, FleetSharedSecret},22};2324pub struct FleetConfigInternals {25	pub local_system: String,26	pub directory: PathBuf,27	pub data: Mutex<FleetData>,28	pub nix_args: Vec<OsString>,29	/// fleet_config.config30	pub config_field: Value,31	// TODO: Remove with connectivity refactor32	pub localhost: String,3334	/// import nixpkgs {system = local};35	pub default_pkgs: Value,3637	pub nix_session: NixSession,38}3940// TODO: Make field not pub41#[derive(Clone)]42pub struct Config(pub Arc<FleetConfigInternals>);4344impl Deref for Config {45	type Target = FleetConfigInternals;4647	fn deref(&self) -> &Self::Target {48		&self.049	}50}5152#[derive(Clone, Copy, Debug)]53pub enum EscalationStrategy {54	Sudo,55	Run0,56	Su,57}5859pub struct ConfigHost {60	config: Config,61	pub name: String,62	groups: OnceCell<Vec<String>>,6364	pub host_config: Option<Value>,65	pub nixos_config: OnceCell<Value>,66	pub pkgs_override: Option<Value>,6768	// TODO: Move command helpers away with connectivity refactor69	pub local: bool,70	pub session: OnceLock<Arc<openssh::Session>>,71}72// TODO: Move command helpers away with connectivity refactor73impl ConfigHost {74	pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {75		// Prefer sudo, as run0 has some gotchas with polkit76		// and too many repeating prompts.77		if (self.find_in_path("sudo").await).is_ok() {78			return Ok(EscalationStrategy::Sudo);79		}80		if (self.find_in_path("run0").await).is_ok() {81			return Ok(EscalationStrategy::Run0);82		}83		Ok(EscalationStrategy::Su)84	}85	async fn open_session(&self) -> Result<Arc<openssh::Session>> {86		assert!(!self.local, "do not open ssh connection to local session");87		// FIXME: TOCTOU88		if let Some(session) = &self.session.get() {89			return Ok((*session).clone());90		};91		let session = SessionBuilder::default();92		let session = session93			.connect(&self.name)94			.await95			.map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;96		let session = Arc::new(session);97		self.session.set(session.clone()).expect("TOCTOU happened");98		Ok(session)99	}100	pub async fn mktemp_dir(&self) -> Result<String> {101		let mut cmd = self.cmd("mktemp").await?;102		cmd.arg("-d");103		let path = cmd.run_string().await?;104		Ok(path.trim_end().to_owned())105	}106	pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {107		let mut cmd = self.cmd("cat").await?;108		cmd.arg(path);109		cmd.run_bytes().await110	}111	pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {112		let mut cmd = self.cmd("cat").await?;113		cmd.arg(path);114		cmd.run_string().await115	}116	pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {117		let mut cmd = self.cmd("ls").await?;118		cmd.arg(path);119		let out = cmd.run_string().await?;120		let mut lines = out.split('\n');121		if let Some(last) = lines.next_back() {122			ensure!(last.is_empty(), "output of ls should end with newline");123		}124		Ok(lines.map(ToOwned::to_owned).collect())125	}126	#[allow(dead_code)]127	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {128		let text = self.read_file_text(path).await?;129		Ok(serde_json::from_str(&text)?)130	}131	pub async fn read_env(&self, env: &str) -> Result<String> {132		let mut cmd = self.cmd("printenv").await?;133		cmd.arg(env);134		cmd.run_string().await135	}136	pub async fn find_in_path(&self, command: &str) -> Result<String> {137		// // `which` is not a part of coreutils, and it might not exist on machine.138		// let path = self.read_env("PATH").await?;139		// // Assuming delimiter is :, we don't work with windows host, this check will be much140		// // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)141		// for ele in path.split(':') {142		// 	let test_path = format!("{ele}/{cmd}");143		// 	test -x etc144		// }145		// let mut cmd = self.cmd("printenv").await?;146		// cmd.arg(env);147		// Ok(cmd.run_string().await?)148		// Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.149		let mut cmd = self150			.cmd_escalation(151				// Not used152				EscalationStrategy::Su,153				"which",154			)155			.await?;156		cmd.arg(command);157		cmd.run_string().await158	}159	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>160	where161		<D as FromStr>::Err: Display,162	{163		let text = self.read_file_text(path).await?;164		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))165	}166	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {167		self.cmd_escalation(self.escalation_strategy().await?, cmd)168			.await169	}170	pub async fn cmd_escalation(171		&self,172		escalation: EscalationStrategy,173		cmd: impl AsRef<OsStr>,174	) -> Result<MyCommand> {175		if self.local {176			Ok(MyCommand::new(escalation, cmd))177		} else {178			let session = self.open_session().await?;179			Ok(MyCommand::new_on(escalation, cmd, session))180		}181	}182183	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {184		ensure!(data.encrypted, "secret is not encrypted");185		let mut cmd = self.cmd("fleet-install-secrets").await?;186		cmd.arg("decrypt").eqarg("--secret", data.to_string());187		let encoded = cmd188			.sudo()189			.run_string()190			.await191			.context("failed to call remote host for decrypt")?;192		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;193		ensure!(!data.encrypted, "secret came out encrypted");194		Ok(data.data)195	}196	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {197		ensure!(data.encrypted, "secret is not encrypted");198		let mut cmd = self.cmd("fleet-install-secrets").await?;199		cmd.arg("reencrypt").eqarg("--secret", data.to_string());200		for target in targets {201			let key = self.config.key(&target).await?;202			cmd.eqarg("--targets", key);203		}204		let encoded = cmd205			.sudo()206			.run_string()207			.await208			.context("failed to call remote host for decrypt")?;209		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;210		ensure!(data.encrypted, "secret came out not encrypted");211		Ok(data)212	}213	/// Returns path for futureproofing, as path might change i.e on conversion to CA214	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {215		if self.local {216			// Path is located locally, thus already trusted.217			return Ok(path.to_owned());218		}219		let mut nix = MyCommand::new(220			// Not used221			EscalationStrategy::Su,222			"nix",223		);224		nix.arg("copy")225			.arg("--substitute-on-destination")226			.comparg("--to", format!("ssh-ng://{}", self.name))227			.arg(path);228		nix.run_nix().await.context("nix copy")?;229		Ok(path.to_owned())230	}231	pub async fn systemctl_stop(&self, name: &str) -> Result<()> {232		let mut cmd = self.cmd("systemctl").await?;233		cmd.arg("stop").arg(name);234		cmd.sudo().run().await235	}236	pub async fn systemctl_start(&self, name: &str) -> Result<()> {237		let mut cmd = self.cmd("systemctl").await?;238		cmd.arg("start").arg(name);239		cmd.sudo().run().await240	}241242	pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {243		let mut cmd = self.cmd("rm").await?;244		cmd.arg("-f").arg(path);245		if sudo {246			cmd = cmd.sudo()247		}248		cmd.run().await249	}250}251impl ConfigHost {252	// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,253	// assuming getting tags always returns the same value.254	pub async fn tags(&self) -> Result<Vec<String>> {255		if let Some(v) = self.groups.get() {256			return Ok(v.clone());257		}258		let Some(host_config) = &self.host_config else {259			return Ok(vec![]);260		};261		let tags: Vec<String> = nix_go_json!(host_config.tags);262263		let _ = self.groups.set(tags.clone());264265		Ok(tags)266	}267	pub async fn nixos_config(&self) -> Result<Value> {268		if let Some(v) = self.nixos_config.get() {269			return Ok(v.clone());270		}271		let Some(host_config) = &self.host_config else {272			bail!("local host has no nixos_config");273		};274		let nixos_config = nix_go!(host_config.nixos.config);275		assert_warn("nixos config evaluation", &nixos_config).await?;276277		let _ = self.nixos_config.set(nixos_config.clone());278279		Ok(nixos_config)280	}281282	pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {283		let nixos = self.nixos_config().await?;284		let secrets = nix_go!(nixos.secrets);285		let mut out = Vec::new();286		for name in secrets.list_fields().await? {287			let secret = nix_go!(secrets[{ name }]);288			let is_shared: bool = nix_go_json!(secret.shared);289			if is_shared {290				continue;291			}292			out.push(name);293		}294		Ok(out)295	}296	pub async fn secret_field(&self, name: &str) -> Result<Value> {297		let nixos = self.nixos_config().await?;298		Ok(nix_go!(nixos.secrets[{ name }]))299	}300301	/// Packages for this host, resolved with nixpkgs overlays302	pub async fn pkgs(&self) -> Result<Value> {303		if let Some(value) = &self.pkgs_override {304			return Ok(value.clone());305		}306		let Some(host_config) = &self.host_config else {307			bail!("local host has no host_config");308		};309		// TODO: Should nixos.options be cached?310		Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))311	}312}313314impl Config {315	pub fn local_host(&self) -> ConfigHost {316		ConfigHost {317			config: self.clone(),318			name: "<virtual localhost>".to_owned(),319			host_config: None,320			nixos_config: OnceCell::new(),321			groups: {322				let cell = OnceCell::new();323				let _ = cell.set(vec![]);324				cell325			},326			pkgs_override: Some(self.default_pkgs.clone()),327328			local: true,329			session: OnceLock::new(),330		}331	}332333	pub async fn host(&self, name: &str) -> Result<ConfigHost> {334		let config = &self.config_field;335		let host_config = nix_go!(config.hosts[{ name }]);336337		Ok(ConfigHost {338			config: self.clone(),339			name: name.to_owned(),340			host_config: Some(host_config),341			nixos_config: OnceCell::new(),342			groups: OnceCell::new(),343			pkgs_override: None,344345			// TODO: Remove with connectivit refactor346			local: self.localhost == name,347			session: OnceLock::new(),348		})349	}350	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {351		let config = &self.config_field;352		let names = nix_go!(config.hosts).list_fields().await?;353		let mut out = vec![];354		for name in names {355			out.push(self.host(&name).await?);356		}357		Ok(out)358	}359	// TODO: Replace usages with .host().nixos_config360	pub async fn system_config(&self, host: &str) -> Result<Value> {361		let fleet_field = &self.config_field;362		Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))363	}364365	/// Shared secrets configured in fleet.nix or in flake366	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {367		let config_field = &self.config_field;368		Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)369	}370	/// Shared secrets configured in fleet.nix371	pub fn list_shared(&self) -> Vec<String> {372		let data = self.data();373		data.shared_secrets.keys().cloned().collect()374	}375	pub fn has_shared(&self, name: &str) -> bool {376		let data = self.data();377		data.shared_secrets.contains_key(name)378	}379	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {380		let mut data = self.data_mut();381		data.shared_secrets.insert(name.to_owned(), shared);382	}383	pub fn remove_shared(&self, secret: &str) {384		let mut data = self.data_mut();385		data.shared_secrets.remove(secret);386	}387388	pub fn list_secrets(&self, host: &str) -> Vec<String> {389		let data = self.data();390		let Some(secrets) = data.host_secrets.get(host) else {391			return Vec::new();392		};393		secrets.keys().cloned().collect()394	}395396	pub fn has_secret(&self, host: &str, secret: &str) -> bool {397		let data = self.data();398		let Some(host_secrets) = data.host_secrets.get(host) else {399			return false;400		};401		host_secrets.contains_key(secret)402	}403	pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {404		let mut data = self.data_mut();405		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();406		host_secrets.insert(secret, value);407	}408409	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {410		let data = self.data();411		let Some(host_secrets) = data.host_secrets.get(host) else {412			bail!("no secrets for machine {host}");413		};414		let Some(secret) = host_secrets.get(secret) else {415			bail!("machine {host} has no secret {secret}");416		};417		Ok(secret.clone())418	}419	pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {420		let data = self.data();421		let Some(secret) = data.shared_secrets.get(secret) else {422			bail!("no shared secret {secret}");423		};424		Ok(secret.clone())425	}426	pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {427		let config_field = &self.config_field;428		Ok(nix_go_json!(429			config_field.sharedSecrets[{ secret }].expectedOwners430		))431	}432433	// TODO: Should this be something modifiable from other processes?434	// E.g terraform provider might want to update FleetData (e.g secrets),435	// and current implementation assumes only one process holds current fleet.nix436	// Given that it is no longer needs to be a file for nix evaluation,437	// maybe it can be a .nix file for persistence, but accessible only438	// thru some shared state controller? Might it be stored in terraform439	// state provider?440	pub fn data(&self) -> MutexGuard<FleetData> {441		self.data.lock().unwrap()442	}443	pub fn data_mut(&self) -> MutexGuard<FleetData> {444		self.data.lock().unwrap()445	}446	pub fn save(&self) -> Result<()> {447		let mut tempfile = NamedTempFile::new_in(self.directory.clone()).context("failed to create updated version of fleet.nix in the same directory as original.\nDo you have write access to it? Access only to the fleet.nix won't be enough, the directory is used for atomic overwrite operation.\nIt is not recommended to use fleet by root anyway, move fleet project to your home directory.")?;448		let data = nixlike::serialize(&self.data() as &FleetData)?;449		tempfile.write_all(450			format!(451				"# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",452				data453			)454			.as_bytes(),455		)?;456		let mut fleet_data_path = self.directory.clone();457		fleet_data_path.push("fleet.nix");458		tempfile.persist(fleet_data_path)?;459		Ok(())460	}461}
after · crates/fleet-base/src/host.rs
1use std::{2	cell::OnceCell,3	collections::BTreeSet,4	ffi::{OsStr, OsString},5	fmt::Display,6	io::Write,7	ops::Deref,8	path::PathBuf,9	str::FromStr,10	sync::{Arc, Mutex, MutexGuard, OnceLock},11};1213use anyhow::{anyhow, bail, ensure, Context, Result};14use fleet_shared::SecretData;15use nix_eval::{nix_go, nix_go_json, util::assert_warn, NixSession, Value};16use openssh::SessionBuilder;17use serde::de::DeserializeOwned;18use tempfile::NamedTempFile;1920use crate::{21	command::MyCommand,22	fleetdata::{FleetData, FleetSecret, FleetSharedSecret},23};2425pub struct FleetConfigInternals {26	pub local_system: String,27	pub directory: PathBuf,28	pub data: Mutex<FleetData>,29	pub nix_args: Vec<OsString>,30	/// fleet_config.config31	pub config_field: Value,32	// TODO: Remove with connectivity refactor33	pub localhost: String,3435	/// import nixpkgs {system = local};36	pub default_pkgs: Value,3738	pub nix_session: NixSession,39}4041// TODO: Make field not pub42#[derive(Clone)]43pub struct Config(pub Arc<FleetConfigInternals>);4445impl Deref for Config {46	type Target = FleetConfigInternals;4748	fn deref(&self) -> &Self::Target {49		&self.050	}51}5253#[derive(Clone, Copy, Debug)]54pub enum EscalationStrategy {55	Sudo,56	Run0,57	Su,58}5960pub struct ConfigHost {61	config: Config,62	pub name: String,63	groups: OnceCell<Vec<String>>,6465	pub host_config: Option<Value>,66	pub nixos_config: OnceCell<Value>,67	pub pkgs_override: Option<Value>,6869	// TODO: Move command helpers away with connectivity refactor70	pub local: bool,71	pub session: OnceLock<Arc<openssh::Session>>,72}73// TODO: Move command helpers away with connectivity refactor74impl ConfigHost {75	pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {76		// Prefer sudo, as run0 has some gotchas with polkit77		// and too many repeating prompts.78		if (self.find_in_path("sudo").await).is_ok() {79			return Ok(EscalationStrategy::Sudo);80		}81		if (self.find_in_path("run0").await).is_ok() {82			return Ok(EscalationStrategy::Run0);83		}84		Ok(EscalationStrategy::Su)85	}86	async fn open_session(&self) -> Result<Arc<openssh::Session>> {87		assert!(!self.local, "do not open ssh connection to local session");88		// FIXME: TOCTOU89		if let Some(session) = &self.session.get() {90			return Ok((*session).clone());91		};92		let session = SessionBuilder::default();93		let session = session94			.connect(&self.name)95			.await96			.map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;97		let session = Arc::new(session);98		self.session.set(session.clone()).expect("TOCTOU happened");99		Ok(session)100	}101	pub async fn mktemp_dir(&self) -> Result<String> {102		let mut cmd = self.cmd("mktemp").await?;103		cmd.arg("-d");104		let path = cmd.run_string().await?;105		Ok(path.trim_end().to_owned())106	}107	pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {108		let mut cmd = self.cmd("cat").await?;109		cmd.arg(path);110		cmd.run_bytes().await111	}112	pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {113		let mut cmd = self.cmd("cat").await?;114		cmd.arg(path);115		cmd.run_string().await116	}117	pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {118		let mut cmd = self.cmd("ls").await?;119		cmd.arg(path);120		let out = cmd.run_string().await?;121		let mut lines = out.split('\n');122		if let Some(last) = lines.next_back() {123			ensure!(last.is_empty(), "output of ls should end with newline");124		}125		Ok(lines.map(ToOwned::to_owned).collect())126	}127	#[allow(dead_code)]128	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {129		let text = self.read_file_text(path).await?;130		Ok(serde_json::from_str(&text)?)131	}132	pub async fn read_env(&self, env: &str) -> Result<String> {133		let mut cmd = self.cmd("printenv").await?;134		cmd.arg(env);135		cmd.run_string().await136	}137	pub async fn find_in_path(&self, command: &str) -> Result<String> {138		// // `which` is not a part of coreutils, and it might not exist on machine.139		// let path = self.read_env("PATH").await?;140		// // Assuming delimiter is :, we don't work with windows host, this check will be much141		// // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)142		// for ele in path.split(':') {143		// 	let test_path = format!("{ele}/{cmd}");144		// 	test -x etc145		// }146		// let mut cmd = self.cmd("printenv").await?;147		// cmd.arg(env);148		// Ok(cmd.run_string().await?)149		// Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.150		let mut cmd = self151			.cmd_escalation(152				// Not used153				EscalationStrategy::Su,154				"which",155			)156			.await?;157		cmd.arg(command);158		cmd.run_string().await159	}160	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>161	where162		<D as FromStr>::Err: Display,163	{164		let text = self.read_file_text(path).await?;165		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))166	}167	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {168		self.cmd_escalation(self.escalation_strategy().await?, cmd)169			.await170	}171	pub async fn cmd_escalation(172		&self,173		escalation: EscalationStrategy,174		cmd: impl AsRef<OsStr>,175	) -> Result<MyCommand> {176		if self.local {177			Ok(MyCommand::new(escalation, cmd))178		} else {179			let session = self.open_session().await?;180			Ok(MyCommand::new_on(escalation, cmd, session))181		}182	}183184	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {185		ensure!(data.encrypted, "secret is not encrypted");186		let mut cmd = self.cmd("fleet-install-secrets").await?;187		cmd.arg("decrypt").eqarg("--secret", data.to_string());188		let encoded = cmd189			.sudo()190			.run_string()191			.await192			.context("failed to call remote host for decrypt")?;193		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;194		ensure!(!data.encrypted, "secret came out encrypted");195		Ok(data.data)196	}197	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {198		ensure!(data.encrypted, "secret is not encrypted");199		let mut cmd = self.cmd("fleet-install-secrets").await?;200		cmd.arg("reencrypt").eqarg("--secret", data.to_string());201		for target in targets {202			let key = self.config.key(&target).await?;203			cmd.eqarg("--targets", key);204		}205		let encoded = cmd206			.sudo()207			.run_string()208			.await209			.context("failed to call remote host for decrypt")?;210		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;211		ensure!(data.encrypted, "secret came out not encrypted");212		Ok(data)213	}214	/// Returns path for futureproofing, as path might change i.e on conversion to CA215	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {216		if self.local {217			// Path is located locally, thus already trusted.218			return Ok(path.to_owned());219		}220		let mut nix = MyCommand::new(221			// Not used222			EscalationStrategy::Su,223			"nix",224		);225		nix.arg("copy")226			.arg("--substitute-on-destination")227			.comparg("--to", format!("ssh-ng://{}", self.name))228			.arg(path);229		nix.run_nix().await.context("nix copy")?;230		Ok(path.to_owned())231	}232	pub async fn systemctl_stop(&self, name: &str) -> Result<()> {233		let mut cmd = self.cmd("systemctl").await?;234		cmd.arg("stop").arg(name);235		cmd.sudo().run().await236	}237	pub async fn systemctl_start(&self, name: &str) -> Result<()> {238		let mut cmd = self.cmd("systemctl").await?;239		cmd.arg("start").arg(name);240		cmd.sudo().run().await241	}242243	pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {244		let mut cmd = self.cmd("rm").await?;245		cmd.arg("-f").arg(path);246		if sudo {247			cmd = cmd.sudo()248		}249		cmd.run().await250	}251}252impl ConfigHost {253	// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,254	// assuming getting tags always returns the same value.255	pub async fn tags(&self) -> Result<Vec<String>> {256		if let Some(v) = self.groups.get() {257			return Ok(v.clone());258		}259		let Some(host_config) = &self.host_config else {260			return Ok(vec![]);261		};262		let tags: Vec<String> = nix_go_json!(host_config.tags);263264		let _ = self.groups.set(tags.clone());265266		Ok(tags)267	}268	pub async fn nixos_config(&self) -> Result<Value> {269		if let Some(v) = self.nixos_config.get() {270			return Ok(v.clone());271		}272		let Some(host_config) = &self.host_config else {273			bail!("local host has no nixos_config");274		};275		let nixos_config = nix_go!(host_config.nixos.config);276		assert_warn("nixos config evaluation", &nixos_config).await?;277278		let _ = self.nixos_config.set(nixos_config.clone());279280		Ok(nixos_config)281	}282283	pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {284		let nixos = self.nixos_config().await?;285		let secrets = nix_go!(nixos.secrets);286		let mut out = Vec::new();287		for name in secrets.list_fields().await? {288			let secret = nix_go!(secrets[{ name }]);289			let is_shared: bool = nix_go_json!(secret.shared);290			if is_shared {291				continue;292			}293			out.push(name);294		}295		Ok(out)296	}297	pub async fn secret_field(&self, name: &str) -> Result<Value> {298		let nixos = self.nixos_config().await?;299		Ok(nix_go!(nixos.secrets[{ name }]))300	}301302	/// Packages for this host, resolved with nixpkgs overlays303	pub async fn pkgs(&self) -> Result<Value> {304		if let Some(value) = &self.pkgs_override {305			return Ok(value.clone());306		}307		let Some(host_config) = &self.host_config else {308			bail!("local host has no host_config");309		};310		// TODO: Should nixos.options be cached?311		Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))312	}313}314315impl Config {316	pub async fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {317		let config = &self.config_field;318		let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);319		Ok(tagged)320	}321	pub async fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {322		let mut out = BTreeSet::new();323		for owner in owners {324			if let Some(tag) = owner.strip_prefix('@') {325				let hosts = self.tagged_hostnames(tag).await?;326				out.extend(hosts);327			} else {328				out.insert(owner);329			}330		}331		Ok(out)332	}333	pub fn local_host(&self) -> ConfigHost {334		ConfigHost {335			config: self.clone(),336			name: "<virtual localhost>".to_owned(),337			host_config: None,338			nixos_config: OnceCell::new(),339			groups: {340				let cell = OnceCell::new();341				let _ = cell.set(vec![]);342				cell343			},344			pkgs_override: Some(self.default_pkgs.clone()),345346			local: true,347			session: OnceLock::new(),348		}349	}350351	pub async fn host(&self, name: &str) -> Result<ConfigHost> {352		let config = &self.config_field;353		let host_config = nix_go!(config.hosts[{ name }]);354355		Ok(ConfigHost {356			config: self.clone(),357			name: name.to_owned(),358			host_config: Some(host_config),359			nixos_config: OnceCell::new(),360			groups: OnceCell::new(),361			pkgs_override: None,362363			// TODO: Remove with connectivit refactor364			local: self.localhost == name,365			session: OnceLock::new(),366		})367	}368	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {369		let config = &self.config_field;370		let names = nix_go!(config.hosts).list_fields().await?;371		let mut out = vec![];372		for name in names {373			out.push(self.host(&name).await?);374		}375		Ok(out)376	}377	// TODO: Replace usages with .host().nixos_config378	pub async fn system_config(&self, host: &str) -> Result<Value> {379		let fleet_field = &self.config_field;380		Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))381	}382383	/// Shared secrets configured in fleet.nix or in flake384	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {385		let config_field = &self.config_field;386		Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)387	}388	/// Shared secrets configured in fleet.nix389	pub fn list_shared(&self) -> Vec<String> {390		let data = self.data();391		data.shared_secrets.keys().cloned().collect()392	}393	pub fn has_shared(&self, name: &str) -> bool {394		let data = self.data();395		data.shared_secrets.contains_key(name)396	}397	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {398		let mut data = self.data_mut();399		data.shared_secrets.insert(name.to_owned(), shared);400	}401	pub fn remove_shared(&self, secret: &str) {402		let mut data = self.data_mut();403		data.shared_secrets.remove(secret);404	}405406	pub fn list_secrets(&self, host: &str) -> Vec<String> {407		let data = self.data();408		let Some(secrets) = data.host_secrets.get(host) else {409			return Vec::new();410		};411		secrets.keys().cloned().collect()412	}413414	pub fn has_secret(&self, host: &str, secret: &str) -> bool {415		let data = self.data();416		let Some(host_secrets) = data.host_secrets.get(host) else {417			return false;418		};419		host_secrets.contains_key(secret)420	}421	pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {422		let mut data = self.data_mut();423		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();424		host_secrets.insert(secret, value);425	}426427	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {428		let data = self.data();429		let Some(host_secrets) = data.host_secrets.get(host) else {430			bail!("no secrets for machine {host}");431		};432		let Some(secret) = host_secrets.get(secret) else {433			bail!("machine {host} has no secret {secret}");434		};435		Ok(secret.clone())436	}437	pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {438		let data = self.data();439		let Some(secret) = data.shared_secrets.get(secret) else {440			bail!("no shared secret {secret}");441		};442		Ok(secret.clone())443	}444	pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {445		let config_field = &self.config_field;446		Ok(nix_go_json!(447			config_field.sharedSecrets[{ secret }].expectedOwners448		))449	}450451	// TODO: Should this be something modifiable from other processes?452	// E.g terraform provider might want to update FleetData (e.g secrets),453	// and current implementation assumes only one process holds current fleet.nix454	// Given that it is no longer needs to be a file for nix evaluation,455	// maybe it can be a .nix file for persistence, but accessible only456	// thru some shared state controller? Might it be stored in terraform457	// state provider?458	pub fn data(&self) -> MutexGuard<FleetData> {459		self.data.lock().unwrap()460	}461	pub fn data_mut(&self) -> MutexGuard<FleetData> {462		self.data.lock().unwrap()463	}464	pub fn save(&self) -> Result<()> {465		let mut tempfile = NamedTempFile::new_in(self.directory.clone()).context("failed to create updated version of fleet.nix in the same directory as original.\nDo you have write access to it? Access only to the fleet.nix won't be enough, the directory is used for atomic overwrite operation.\nIt is not recommended to use fleet by root anyway, move fleet project to your home directory.")?;466		let data = nixlike::serialize(&self.data() as &FleetData)?;467		tempfile.write_all(468			format!(469				"# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",470				data471			)472			.as_bytes(),473		)?;474		let mut fleet_data_path = self.directory.clone();475		fleet_data_path.push("fleet.nix");476		tempfile.persist(fleet_data_path)?;477		Ok(())478	}479}
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
--- a/modules/nixos/secrets.nix
+++ b/modules/nixos/secrets.nix
@@ -41,17 +41,6 @@
           type = str;
           description = "Secret public data (only available for plaintext)";
         };
-
-        expectedGenerationData = mkOption {
-          type = unspecified;
-          description = "Data that gets embedded into secret part";
-          default = null;
-        };
-        generationData = mkOption {
-          type = unspecified;
-          description = "Data that is embedded into secret part";
-          default = null;
-        };
       };
       config = {
         hash = hashString "sha1" config.raw;
@@ -91,6 +80,11 @@
         default = sysConfig.users.users.${config.owner}.group;
         defaultText = literalExpression "config.users.users.$${owner}.group";
       };
+      expectedGenerationData = mkOption {
+        type = unspecified;
+        description = "Data that gets embedded into secret part";
+        default = null;
+      };
     };
   });
   processPart = part: {
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 {