git.delta.rocks / jrsonnet / refs/commits / 904d12180e52

difftreelog

refactor minor rewrites

Yaroslav Bolyukin2023-12-28parent: #a369041.patch.diff
in: trunk

7 files changed

modifiedcmds/fleet/src/better_nix_eval.rsdiffbeforeafterboth
--- a/cmds/fleet/src/better_nix_eval.rs
+++ b/cmds/fleet/src/better_nix_eval.rs
@@ -247,7 +247,7 @@
 		Ok(())
 	}
 	async fn send_command(&mut self, cmd: impl AsRef<[u8]>) -> Result<()> {
-		if tracing::enabled!(Level::DEBUG) {
+		if tracing::enabled!(Level::DEBUG) && cmd.as_ref() != REPL_DELIMITER.as_bytes() {
 			let cmd_str = String::from_utf8_lossy(cmd.as_ref());
 			tracing::debug!("{cmd_str}");
 		};
@@ -627,13 +627,6 @@
 	}
 	pub async fn field(session: NixSession, field: &str) -> Result<Self> {
 		Self::root(session).select([Index::var(field)]).await
-	}
-	pub async fn get_json_deep<'a, V: DeserializeOwned>(
-		&self,
-		name: impl IntoIterator<Item = Index>,
-	) -> Result<V> {
-		let field = self.select(name).await?;
-		field.as_json().await
 	}
 	pub async fn select<'a>(&self, name: impl IntoIterator<Item = Index>) -> Result<Self> {
 		let mut used_fields = Vec::new();
@@ -719,6 +712,19 @@
 			.await
 			.with_context(|| context(self.0.full_path.as_deref(), &query))
 	}
+	pub async fn has_field(&self, name: &str) -> Result<bool> {
+		let id = self.0.value.expect("can't list root fields");
+		let key = nixlike::escape_string(name);
+		let query = format!("sess_field_{id} ? {key}");
+		self.0
+			.session
+			.0
+			.lock()
+			.await
+			.execute_expression_to_json(&query)
+			.await
+			.with_context(|| context(self.0.full_path.as_deref(), &query))
+	}
 	pub async fn list_fields(&self) -> Result<Vec<String>> {
 		let id = self.0.value.expect("can't list root fields");
 		let query = format!("builtins.attrNames sess_field_{id}");
@@ -731,6 +737,18 @@
 			.await
 			.with_context(|| context(self.0.full_path.as_deref(), &query))
 	}
+	pub async fn type_of(&self) -> Result<String> {
+		let id = self.0.value.expect("can't list root fields");
+		let query = format!("builtins.typeOf sess_field_{id}");
+		self.0
+			.session
+			.0
+			.lock()
+			.await
+			.execute_expression_to_json(&query)
+			.await
+			.with_context(|| context(self.0.full_path.as_deref(), &query))
+	}
 	pub async fn build(&self) -> Result<HashMap<String, PathBuf>> {
 		let id = self.0.value.expect("can't use build on not-value");
 		let query = format!(":b sess_field_{id}");
modifiedcmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth
before · cmds/fleet/src/cmds/secrets/mod.rs
1use crate::{2	command::MyCommand,3	fleetdata::{FleetSecret, FleetSharedSecret},4	host::Config,5	nix_go, nix_go_json,6};7use anyhow::{anyhow, bail, ensure, Context, Result};8use chrono::{DateTime, Utc};9use clap::Parser;10use futures::{StreamExt, TryStreamExt};11use owo_colors::OwoColorize;12use std::{13	collections::HashSet,14	io::{self, Cursor, Read},15	path::PathBuf,16	sync::Arc,17};18use tabled::{Table, Tabled};19use tokio::fs::read_to_string;20use tracing::{error, info, info_span, warn};2122#[derive(Parser)]23pub enum Secret {24	/// Force load host keys for all defined hosts25	ForceKeys,26	/// Add secret, data should be provided in stdin27	AddShared {28		/// Secret name29		name: String,30		/// Secret owners31		machines: Vec<String>,32		/// Override secret if already present33		#[clap(long)]34		force: bool,35		/// Secret public part36		#[clap(long)]37		public: Option<String>,38		/// Load public part from specified file39		#[clap(long)]40		public_file: Option<PathBuf>,4142		/// Create a notification on secret expiration43		#[clap(long)]44		expires_at: Option<DateTime<Utc>>,4546		/// Secret with this name already exists, override its value while keeping the same owners.47		#[clap(long)]48		re_add: bool,49	},50	/// Add secret, data should be provided in stdin51	Add {52		/// Secret name53		name: String,54		/// Secret owners55		machine: String,56		/// Override secret if already present57		#[clap(long)]58		force: bool,59		#[clap(long)]60		public: Option<String>,61		#[clap(long)]62		public_file: Option<PathBuf>,63	},64	/// Read secret from remote host, requires sudo on said host65	Read {66		name: String,67		machine: String,68		#[clap(long)]69		plaintext: bool,70	},71	UpdateShared {72		name: String,7374		#[clap(long)]75		machines: Option<Vec<String>>,7677		#[clap(long)]78		add_machines: Vec<String>,79		#[clap(long)]80		remove_machines: Vec<String>,8182		/// Which host should we use to decrypt83		#[clap(long)]84		prefer_identities: Vec<String>,85	},86	Regenerate {87		/// Which host should we use to decrypt, in case if reencryption is required, without88		/// regeneration89		#[clap(long)]90		prefer_identities: Vec<String>,91	},92	List {},93	InvokeGenerator,94}9596impl Secret {97	pub async fn run(self, config: &Config) -> Result<()> {98		match self {99			Secret::InvokeGenerator => {100				let config_field = &config.config_unchecked_field;101102				let secret =103					nix_go!(config_field.configUnchecked.sharedSecrets["kube-apiserver.pem"]);104				let generate_impure = nix_go!(secret.generateImpure);105				let on = nix_go!(generate_impure.on);106				let call_package = nix_go!(107					config_field.buildableSystems(Obj {108						localSystem: { config.local_system.clone() }109					})[on]110						.config111						.nixpkgs112						.resolvedPkgs113						.callPackage114				);115				let generator = nix_go!(call_package(generate_impure.generator)(Obj {}));116				let built = &generator.build().await?["out"];117				let mut nix = MyCommand::new("nix");118				let on: String = on.as_json().await?;119				nix.arg("copy")120					.arg("--substitute-on-destination")121					.comparg("--to", format!("ssh-ng://{on}"))122					.arg(built);123				nix.run_nix().await?;124125				let session = config.host(&on).await?;126127				let owners: Vec<String> = nix_go_json!(secret.expectedOwners);128				dbg!(&owners);129130				let mut recipients = String::new();131				for owner in owners {132					let key = config.key(&owner).await?;133					recipients.push_str(&format!("-r \"{key}\" "));134				}135				recipients.push_str("-e");136137				// FIXME: security: created directory might be accessible to other users138				// This shouldn't be much of a concern, as data is encrypted right after creation, yet139				// still better to have.140				let tempdir = session.mktemp_dir().await?;141142				let mut gen = session.cmd(built).await?;143				gen.env("rageArgs", recipients).env("out", &tempdir);144				gen.run().await?;145146				{147					let marker = session.read_file_text(format!("{tempdir}/marker")).await?;148					ensure!(marker == "SUCCESS", "generation not succeeded");149				}150151				let public = session152					.read_file_bin(format!("{tempdir}/public"))153					.await154					.ok();155				let secret = session156					.read_file_bin(format!("{tempdir}/secret"))157					.await158					.ok();159				if let Some(secret) = &secret {160					ensure!(161						age::Decryptor::new(Cursor::new(&secret)).is_ok(),162						"builder produced non-encrypted value as secret, this is highly insecure"163					);164				}165				dbg!(&secret);166				// // .as_json().await?;167				// dbg!(&built);168			}169			Secret::ForceKeys => {170				for host in config.list_hosts().await? {171					if config.should_skip(&host.name) {172						continue;173					}174					config.key(&host.name).await?;175				}176			}177			Secret::AddShared {178				mut machines,179				name,180				force,181				public,182				public_file,183				expires_at,184				re_add,185			} => {186				let exists = config.has_shared(&name);187				if exists && !force && !re_add {188					bail!("secret already defined");189				}190				if re_add {191					// Fixme: use clap to limit this usage192					ensure!(!force, "--force and --readd are not compatible");193					ensure!(exists, "secret doesn't exists");194					ensure!(195						machines.is_empty(),196						"you can't use machines argument for --readd"197					);198					let shared = config.shared_secret(&name)?;199					machines = shared.owners;200				}201202				let recipients = futures::stream::iter(machines.iter())203					.then(|m| config.recipient(m))204					.try_collect::<Vec<_>>()205					.await?;206207				let secret = {208					let mut input = vec![];209					io::stdin().read_to_end(&mut input)?;210211					if input.is_empty() {212						input213					} else {214						let mut encrypted = vec![];215						let recipients = recipients216							.iter()217							.cloned()218							.map(|r| Box::new(r) as Box<dyn age::Recipient + Send>)219							.collect();220						let mut encryptor = age::Encryptor::with_recipients(recipients)221							.ok_or_else(|| anyhow!("no recipients provided"))?222							.wrap_output(&mut encrypted)?;223						io::copy(&mut Cursor::new(input), &mut encryptor)?;224						encryptor.finish()?;225						encrypted226					}227				};228				config.replace_shared(229					name,230					FleetSharedSecret {231						owners: machines,232						secret: FleetSecret {233							created_at: Utc::now(),234							expires_at,235							secret,236							public: match (public, public_file) {237								(Some(v), None) => Some(v),238								(None, Some(v)) => Some(read_to_string(v).await?),239								(Some(_), Some(_)) => {240									bail!("only public or public_file should be set")241								}242								(None, None) => None,243							},244						},245					},246				);247			}248			Secret::Add {249				machine,250				name,251				force,252				public,253				public_file,254			} => {255				let recipient = config.recipient(&machine).await?;256257				let secret = {258					let mut input = vec![];259					io::stdin().read_to_end(&mut input)?;260					if input.is_empty() {261						bail!("no data provided")262					}263264					let mut encrypted = vec![];265					let recipient = Box::new(recipient) as Box<dyn age::Recipient + Send>;266					let mut encryptor = age::Encryptor::with_recipients(vec![recipient])267						.expect("recipients provided")268						.wrap_output(&mut encrypted)?;269					io::copy(&mut Cursor::new(input), &mut encryptor)?;270					encryptor.finish()?;271					encrypted272				};273274				if config.has_secret(&machine, &name) && !force {275					bail!("secret already defined");276				}277				config.insert_secret(278					&machine,279					name,280					FleetSecret {281						created_at: Utc::now(),282						expires_at: None,283						secret,284						public: match (public, public_file) {285							(Some(v), None) => Some(v),286							(None, Some(v)) => Some(std::fs::read_to_string(v)?),287							(Some(_), Some(_)) => bail!("only public or public_file should be set"),288							(None, None) => None,289						},290					},291				);292			}293			// TODO: Instead of using sudo, decode secret on remote machine294			#[allow(clippy::await_holding_refcell_ref)]295			Secret::Read {296				name,297				machine,298				plaintext,299			} => {300				let secret = config.host_secret(&machine, &name)?;301				if secret.secret.is_empty() {302					bail!("no secret {name}");303				}304				let host = config.host(&machine).await?;305				let data = host.decrypt(secret.secret).await?;306				if plaintext {307					let s = String::from_utf8(data).context("output is not utf8")?;308					print!("{s}");309				} else {310					println!("{}", z85::encode(&data));311				}312			}313			Secret::UpdateShared {314				name,315				machines,316				mut add_machines,317				mut remove_machines,318				prefer_identities,319			} => {320				if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {321					bail!("no operation");322				}323324				let mut secret = config.shared_secret(&name)?;325				if secret.secret.secret.is_empty() {326					bail!("no secret");327				}328329				let initial_machines = secret.owners.clone();330				let mut target_machines = secret.owners.clone();331				info!("Currently encrypted for {initial_machines:?}");332333				// ensure!(machines.is_some() || !add_machines.is_empty() || )334				if let Some(machines) = machines {335					ensure!(336						add_machines.is_empty() && remove_machines.is_empty(),337						"can't combine --machines and --add-machines/--remove-machines"338					);339					let target = initial_machines.iter().collect::<HashSet<_>>();340					let source = machines.iter().collect::<HashSet<_>>();341					for removed in target.difference(&source) {342						remove_machines.push((*removed).clone());343					}344					for added in source.difference(&target) {345						add_machines.push((*added).clone());346					}347				}348349				for machine in &remove_machines {350					let mut removed = false;351					while let Some(pos) = target_machines.iter().position(|m| m == machine) {352						target_machines.swap_remove(pos);353						removed = true;354					}355					if !removed {356						warn!("secret is not enabled for {machine}");357					}358				}359				for machine in &add_machines {360					if target_machines.iter().any(|m| m == machine) {361						warn!("secret is already added to {machine}");362					} else {363						target_machines.push(machine.to_owned());364					}365				}366				if !remove_machines.is_empty() {367					warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");368				}369370				if target_machines.is_empty() {371					info!("no machines left for secret, removing it");372					config.remove_shared(&name);373					return Ok(());374				}375376				if target_machines == initial_machines {377					warn!("secret owners are already correct");378					return Ok(());379				}380381				let identity_holder = if !prefer_identities.is_empty() {382					prefer_identities383						.iter()384						.find(|i| initial_machines.iter().any(|s| s == *i))385				} else {386					secret.owners.first()387				};388				let Some(identity_holder) = identity_holder else {389					bail!("no available holder found");390				};391				let target_recipients = futures::stream::iter(&target_machines)392					.then(|m| async { config.key(m).await })393					.collect::<Vec<_>>()394					.await;395				let target_recipients =396					target_recipients.into_iter().collect::<Result<Vec<_>>>()?;397398				let encrypted = config399					.reencrypt_on_host(identity_holder, secret.secret.secret, target_recipients)400					.await?;401402				secret.owners = target_machines;403				secret.secret.secret = encrypted;404				config.replace_shared(name, secret);405			}406			Secret::Regenerate { prefer_identities } => {407				{408					let expected_shared_set = config409						.list_configured_shared()410						.await?411						.into_iter()412						.collect::<HashSet<_>>();413					let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();414					for removed in expected_shared_set.difference(&shared_set) {415						error!("secret needs to be generated: {removed}")416					}417				}418				let mut to_remove = Vec::new();419				for name in &config.list_shared() {420					info!("updating secret: {name}");421					let mut data = config.shared_secret(name)?;422					let config_field = &config.config_field;423					let expected_owners: Vec<String> =424						nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);425					if expected_owners.is_empty() {426						warn!("secret was removed from fleet config: {name}, removing from data");427						to_remove.push(name.to_string());428						continue;429					}430					let set = data.owners.iter().collect::<HashSet<_>>();431					let expected_set = expected_owners.iter().collect::<HashSet<_>>();432					let should_remove = set.difference(&expected_set).next().is_some();433					if set != expected_set {434						let owner_dependent: bool =435							nix_go_json!(config_field.sharedSecrets[{ name }].ownerDependent);436						if !owner_dependent {437							warn!("reencrypting secret '{name}' for new owner set");438							// TODO: force regeneration439							if should_remove {440								warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");441							}442443							let identity_holder = if !prefer_identities.is_empty() {444								prefer_identities445									.iter()446									.find(|i| data.owners.iter().any(|s| s == *i))447							} else {448								data.owners.first()449							};450							let Some(identity_holder) = identity_holder else {451								bail!("no available holder found");452							};453454							let target_recipients = futures::stream::iter(&expected_owners)455								.then(|m| async { config.key(m).await })456								.collect::<Vec<_>>()457								.await;458							let target_recipients =459								target_recipients.into_iter().collect::<Result<Vec<_>>>()?;460461							let encrypted = config462								.reencrypt_on_host(463									identity_holder,464									data.secret.secret,465									target_recipients,466								)467								.await?;468469							data.secret.secret = encrypted;470							data.owners = expected_owners;471							config.replace_shared(name.to_owned(), data);472						} else {473							error!("secret '{name}' should be regenerated manually");474						}475					} else {476						info!("secret data is ok")477					}478				}479				for k in to_remove {480					config.remove_shared(&k);481				}482			}483			Secret::List {} => {484				let _span = info_span!("loading secrets").entered();485				let configured = config.list_configured_shared().await?;486				#[derive(Tabled)]487				struct SecretDisplay {488					#[tabled(rename = "Name")]489					name: String,490					#[tabled(rename = "Owners")]491					owners: String,492				}493				let mut table = vec![];494				for name in configured.iter().cloned() {495					let config = config.clone();496					let expected_owners = config.shared_secret_expected_owners(&name).await?;497					let data = config.shared_secret(&name)?;498					let owners = data499						.owners500						.iter()501						.map(|o| {502							if expected_owners.contains(o) {503								o.green().to_string()504							} else {505								o.red().to_string()506							}507						})508						.collect::<Vec<_>>();509					table.push(SecretDisplay {510						owners: owners.join(", "),511						name,512					})513				}514				info!("loaded\n{}", Table::new(table).to_string())515			}516		}517		Ok(())518	}519}
after · cmds/fleet/src/cmds/secrets/mod.rs
1use crate::{2	better_nix_eval::Field,3	fleetdata::{FleetSecret, FleetSharedSecret, SecretData},4	host::Config,5	nix_go, nix_go_json,6};7use anyhow::{anyhow, bail, ensure, Context, Result};8use chrono::{DateTime, Utc};9use clap::Parser;10use futures::{StreamExt, TryStreamExt};11use itertools::Itertools;12use owo_colors::OwoColorize;13use std::{14	collections::HashSet,15	io::{self, Cursor, Read},16	path::PathBuf,17};18use tabled::{Table, Tabled};19use tokio::fs::read_to_string;20use tracing::{info, info_span, warn};2122#[derive(Parser)]23pub enum Secret {24	/// Force load host keys for all defined hosts25	ForceKeys,26	/// Add secret, data should be provided in stdin27	AddShared {28		/// Secret name29		name: String,30		/// Secret owners31		machines: Vec<String>,32		/// Override secret if already present33		#[clap(long)]34		force: bool,35		/// Secret public part36		#[clap(long)]37		public: Option<String>,38		/// Load public part from specified file39		#[clap(long)]40		public_file: Option<PathBuf>,4142		/// Create a notification on secret expiration43		#[clap(long)]44		expires_at: Option<DateTime<Utc>>,4546		/// Secret with this name already exists, override its value while keeping the same owners.47		#[clap(long)]48		re_add: bool,49	},50	/// Add secret, data should be provided in stdin51	Add {52		/// Secret name53		name: String,54		/// Secret owners55		machine: String,56		/// Override secret if already present57		#[clap(long)]58		force: bool,59		#[clap(long)]60		public: Option<String>,61		#[clap(long)]62		public_file: Option<PathBuf>,63	},64	/// Read secret from remote host, requires sudo on said host65	Read {66		name: String,67		machine: String,68		#[clap(long)]69		plaintext: bool,70	},71	UpdateShared {72		name: String,7374		#[clap(long)]75		machines: Option<Vec<String>>,7677		#[clap(long)]78		add_machines: Vec<String>,79		#[clap(long)]80		remove_machines: Vec<String>,8182		/// Which host should we use to decrypt83		#[clap(long)]84		prefer_identities: Vec<String>,85	},86	Regenerate {87		/// Which host should we use to decrypt, in case if reencryption is required, without88		/// regeneration89		#[clap(long)]90		prefer_identities: Vec<String>,91	},92	List {},93}9495async fn generate_shared(96	config: &Config,97	display_name: &str,98	secret: Field,99) -> Result<FleetSharedSecret> {100	Ok(if secret.has_field("generateImpure").await? {101		let config_field = &config.config_unchecked_field;102		let generate = nix_go!(secret.generateImpure);103		let owners: Vec<String> = nix_go_json!(secret.expectedOwners);104105		let on: String = nix_go_json!(generate.on);106		let call_package = nix_go!(107			config_field.buildableSystems(Obj {108				localSystem: { config.local_system.clone() }109			})[{ on }]110			.config111			.nixpkgs112			.resolvedPkgs113			.callPackage114		);115116		let host = config.host(&on).await?;117118		let generator = nix_go!(call_package(generate.generator)(Obj {}));119		let generator = generator.build().await?;120		let generator = generator121			.get("out")122			.ok_or_else(|| anyhow!("missing generateImpure out"))?;123		let generator = host.remote_derivation(generator).await?;124125		let mut recipients = String::new();126		for owner in &owners {127			let key = config.key(owner).await?;128			recipients.push_str(&format!("-r \"{key}\" "));129		}130		recipients.push_str("-e");131132		let out = host.mktemp_dir().await?;133134		let mut gen = host.cmd(generator).await?;135		gen.env("rageArgs", recipients).env("out", &out);136		gen.run().await?;137138		{139			let marker = host.read_file_text(format!("{out}/marker")).await?;140			ensure!(marker == "SUCCESS", "generation not succeeded");141		}142143		let public = host.read_file_text(format!("{out}/public")).await.ok();144		let secret = host.read_file_bin(format!("{out}/secret")).await.ok();145		if let Some(secret) = &secret {146			ensure!(147				age::Decryptor::new(Cursor::new(&secret)).is_ok(),148				"builder produced non-encrypted value as secret, this is highly insecure"149			);150		}151152		let created_at = host.read_file_value(format!("{out}/created_at")).await?;153		let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();154155		FleetSharedSecret {156			owners,157			secret: FleetSecret {158				created_at,159				expires_at,160				public,161				secret: secret.map(SecretData),162			},163		}164	} else {165		bail!("no generator defined for {display_name}")166	})167}168169async fn parse_public(170	public: Option<String>,171	public_file: Option<PathBuf>,172) -> Result<Option<String>> {173	Ok(match (public, public_file) {174		(Some(v), None) => Some(v),175		(None, Some(v)) => Some(read_to_string(v).await?),176		(Some(_), Some(_)) => {177			bail!("only public or public_file should be set")178		}179		(None, None) => None,180	})181}182183fn parse_machines(184	initial: Vec<String>,185	machines: Option<Vec<String>>,186	mut add_machines: Vec<String>,187	mut remove_machines: Vec<String>,188) -> Result<Vec<String>> {189	if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {190		bail!("no operation");191	}192193	let initial_machines = initial.clone();194	let mut target_machines = initial;195	info!("Currently encrypted for {initial_machines:?}");196197	// ensure!(machines.is_some() || !add_machines.is_empty() || )198	if let Some(machines) = machines {199		ensure!(200			add_machines.is_empty() && remove_machines.is_empty(),201			"can't combine --machines and --add-machines/--remove-machines"202		);203		let target = initial_machines.iter().collect::<HashSet<_>>();204		let source = machines.iter().collect::<HashSet<_>>();205		for removed in target.difference(&source) {206			remove_machines.push((*removed).clone());207		}208		for added in source.difference(&target) {209			add_machines.push((*added).clone());210		}211	}212213	for machine in &remove_machines {214		let mut removed = false;215		while let Some(pos) = target_machines.iter().position(|m| m == machine) {216			target_machines.swap_remove(pos);217			removed = true;218		}219		if !removed {220			warn!("secret is not enabled for {machine}");221		}222	}223	for machine in &add_machines {224		if target_machines.iter().any(|m| m == machine) {225			warn!("secret is already added to {machine}");226		} else {227			target_machines.push(machine.to_owned());228		}229	}230	if !remove_machines.is_empty() {231		// TODO: maybe force secret regeneration?232		// Not that useful without revokation.233		warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");234	}235	Ok(target_machines)236}237impl Secret {238	pub async fn run(self, config: &Config) -> Result<()> {239		match self {240			Secret::ForceKeys => {241				for host in config.list_hosts().await? {242					if config.should_skip(&host.name) {243						continue;244					}245					config.key(&host.name).await?;246				}247			}248			Secret::AddShared {249				mut machines,250				name,251				force,252				public,253				public_file,254				expires_at,255				re_add,256			} => {257				let exists = config.has_shared(&name);258				if exists && !force && !re_add {259					bail!("secret already defined");260				}261				if re_add {262					// Fixme: use clap to limit this usage263					ensure!(!force, "--force and --readd are not compatible");264					ensure!(exists, "secret doesn't exists");265					ensure!(266						machines.is_empty(),267						"you can't use machines argument for --readd"268					);269					let shared = config.shared_secret(&name)?;270					machines = shared.owners;271				}272273				let recipients = config274					.recipients(&machines.iter().map(String::as_str).collect_vec())275					.await?;276277				let secret = {278					let mut input = vec![];279					io::stdin().read_to_end(&mut input)?;280281					if input.is_empty() {282						None283					} else {284						Some(285							SecretData::encrypt(recipients, input)286								.ok_or_else(|| anyhow!("no recipients provided"))?,287						)288					}289				};290				let public = parse_public(public, public_file).await?;291				config.replace_shared(292					name,293					FleetSharedSecret {294						owners: machines,295						secret: FleetSecret {296							created_at: Utc::now(),297							expires_at,298							secret,299							public,300						},301					},302				);303			}304			Secret::Add {305				machine,306				name,307				force,308				public,309				public_file,310			} => {311				let recipient = config.recipient(&machine).await?;312313				let secret = {314					let mut input = vec![];315					io::stdin().read_to_end(&mut input)?;316					if input.is_empty() {317						bail!("no data provided")318					}319320					Some(SecretData::encrypt(vec![recipient], input).expect("recipient provided"))321				};322323				if config.has_secret(&machine, &name) && !force {324					bail!("secret already defined");325				}326				let public = parse_public(public, public_file).await?;327328				config.insert_secret(329					&machine,330					name,331					FleetSecret {332						created_at: Utc::now(),333						expires_at: None,334						secret,335						public,336					},337				);338			}339			#[allow(clippy::await_holding_refcell_ref)]340			Secret::Read {341				name,342				machine,343				plaintext,344			} => {345				let secret = config.host_secret(&machine, &name)?;346				let Some(secret) = secret.secret else {347					bail!("no secret {name}");348				};349				let host = config.host(&machine).await?;350				let data = host.decrypt(secret).await?;351				if plaintext {352					let s = String::from_utf8(data).context("output is not utf8")?;353					print!("{s}");354				} else {355					println!("{}", z85::encode(&data));356				}357			}358			Secret::UpdateShared {359				name,360				machines,361				add_machines,362				remove_machines,363				prefer_identities,364			} => {365				let mut secret = config.shared_secret(&name)?;366				if secret.secret.secret.is_none() {367					bail!("no secret");368				}369370				let initial_machines = secret.owners.clone();371				let target_machines = parse_machines(372					initial_machines.clone(),373					machines,374					add_machines,375					remove_machines,376				)?;377378				if target_machines.is_empty() {379					info!("no machines left for secret, removing it");380					config.remove_shared(&name);381					return Ok(());382				}383384				if target_machines == initial_machines {385					warn!("secret owners are already correct");386					return Ok(());387				}388389				let identity_holder = if !prefer_identities.is_empty() {390					prefer_identities391						.iter()392						.find(|i| initial_machines.iter().any(|s| s == *i))393				} else {394					secret.owners.first()395				};396				let Some(identity_holder) = identity_holder else {397					bail!("no available holder found");398				};399				let target_recipients = futures::stream::iter(&target_machines)400					.then(|m| async { config.key(m).await })401					.collect::<Vec<_>>()402					.await;403				let target_recipients =404					target_recipients.into_iter().collect::<Result<Vec<_>>>()?;405406				if let Some(data) = secret.secret.secret {407					let encrypted = config408						.reencrypt_on_host(identity_holder, data, target_recipients)409						.await?;410					secret.secret.secret = Some(encrypted);411				}412413				secret.owners = target_machines;414				config.replace_shared(name, secret);415			}416			Secret::Regenerate { prefer_identities } => {417				{418					let expected_shared_set = config419						.list_configured_shared()420						.await?421						.into_iter()422						.collect::<HashSet<_>>();423					let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();424					for removed in expected_shared_set.difference(&shared_set) {425						info!("generating secret: {removed}");426						let config_field = &config.config_unchecked_field;427						let config_field = nix_go!(config_field.configUnchecked);428						let secret = nix_go!(config_field.sharedSecrets[{ removed }]);429						let shared = generate_shared(config, removed, secret).await?;430						config.replace_shared(removed.to_string(), shared)431					}432				}433				let mut to_remove = Vec::new();434				for name in &config.list_shared() {435					info!("updating secret: {name}");436					let mut data = config.shared_secret(name)?;437					let config_field = &config.config_unchecked_field;438					let config_field = nix_go!(config_field.configUnchecked);439					let expected_owners: Vec<String> =440						nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);441					if expected_owners.is_empty() {442						warn!("secret was removed from fleet config: {name}, removing from data");443						to_remove.push(name.to_string());444						continue;445					}446					let set = data.owners.iter().collect::<HashSet<_>>();447					let expected_set = expected_owners.iter().collect::<HashSet<_>>();448					let should_remove = set.difference(&expected_set).next().is_some();449					if set == expected_set {450						info!("secret data is ok");451						continue;452					}453454					let secret = nix_go!(config_field.sharedSecrets[{ name }]);455					let owner_dependent: bool = nix_go_json!(secret.ownerDependent);456					let regenerate_on_remove: bool = nix_go_json!(secret.regenerateOnOwnerRemoved);457					#[allow(clippy::nonminimal_bool)]458					if !owner_dependent && !(should_remove && regenerate_on_remove) {459						warn!("reencrypting secret '{name}' for new owner set");460						// TODO: force regeneration461						if should_remove {462							warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");463						}464465						let identity_holder = if !prefer_identities.is_empty() {466							prefer_identities467								.iter()468								.find(|i| data.owners.iter().any(|s| s == *i))469						} else {470							data.owners.first()471						};472						let Some(identity_holder) = identity_holder else {473							bail!("no available holder found");474						};475476						let target_recipients = futures::stream::iter(&expected_owners)477							.then(|m| async { config.key(m).await })478							.collect::<Vec<_>>()479							.await;480						let target_recipients =481							target_recipients.into_iter().collect::<Result<Vec<_>>>()?;482483						if let Some(secret) = data.secret.secret {484							let encrypted = config485								.reencrypt_on_host(identity_holder, secret, target_recipients)486								.await?;487488							data.secret.secret = Some(encrypted);489						}490						data.owners = expected_owners;491						config.replace_shared(name.to_owned(), data);492					} else {493						let shared = generate_shared(config, name, secret).await?;494						config.replace_shared(name.to_owned(), shared)495					}496				}497				for k in to_remove {498					config.remove_shared(&k);499				}500			}501			Secret::List {} => {502				let _span = info_span!("loading secrets").entered();503				let configured = config.list_configured_shared().await?;504				#[derive(Tabled)]505				struct SecretDisplay {506					#[tabled(rename = "Name")]507					name: String,508					#[tabled(rename = "Owners")]509					owners: String,510				}511				let mut table = vec![];512				for name in configured.iter().cloned() {513					let config = config.clone();514					let expected_owners = config.shared_secret_expected_owners(&name).await?;515					let data = config.shared_secret(&name)?;516					let owners = data517						.owners518						.iter()519						.map(|o| {520							if expected_owners.contains(o) {521								o.green().to_string()522							} else {523								o.red().to_string()524							}525						})526						.collect::<Vec<_>>();527					table.push(SecretDisplay {528						owners: owners.join(", "),529						name,530					})531				}532				info!("loaded\n{}", Table::new(table).to_string())533			}534		}535		Ok(())536	}537}
modifiedcmds/fleet/src/fleetdata.rsdiffbeforeafterboth
--- a/cmds/fleet/src/fleetdata.rs
+++ b/cmds/fleet/src/fleetdata.rs
@@ -1,8 +1,13 @@
+use age::Recipient;
 use anyhow::Result;
 use chrono::{DateTime, Utc};
+use itertools::Itertools;
 use nixlike::format_nix;
 use serde::{Deserialize, Deserializer, Serialize, Serializer};
-use std::collections::BTreeMap;
+use std::{
+	collections::BTreeMap,
+	io::{self, Cursor},
+};
 use tempfile::TempDir;
 use tokio::{
 	fs::{self, File},
@@ -41,6 +46,43 @@
 }
 
 #[derive(Serialize, Deserialize, Clone)]
+pub struct SecretData(
+	#[serde(
+		default,
+		skip_serializing_if = "Vec::is_empty",
+		serialize_with = "as_z85",
+		deserialize_with = "from_z85"
+	)]
+	pub Vec<u8>,
+);
+impl SecretData {
+	/// Returns None if recipients.is_empty()
+	pub fn encrypt(
+		recipients: impl IntoIterator<Item = impl Recipient + Send + 'static>,
+		data: Vec<u8>,
+	) -> Option<Self> {
+		let mut encrypted = vec![];
+		let recipients = recipients
+			.into_iter()
+			.map(|v| Box::new(v) as Box<dyn Recipient + Send>)
+			.collect_vec();
+		let mut encryptor = age::Encryptor::with_recipients(recipients)?
+			.wrap_output(&mut encrypted)
+			.expect("in memory write");
+		io::copy(&mut Cursor::new(data), &mut encryptor).expect("in memory copy");
+		encryptor.finish().expect("in memory flush");
+		Some(Self(encrypted))
+	}
+	pub fn encode_z85(&self) -> String {
+		z85::encode(&self.0)
+	}
+	pub fn decode_z85(v: &str) -> Result<Self> {
+		let v = z85::decode(v)?;
+		Ok(Self(v))
+	}
+}
+
+#[derive(Serialize, Deserialize, Clone)]
 #[serde(rename_all = "camelCase")]
 #[must_use]
 pub struct FleetSecret {
@@ -51,13 +93,8 @@
 	pub expires_at: Option<DateTime<Utc>>,
 	#[serde(skip_serializing_if = "Option::is_none")]
 	pub public: Option<String>,
-	#[serde(
-		default,
-		skip_serializing_if = "Vec::is_empty",
-		serialize_with = "as_z85",
-		deserialize_with = "from_z85"
-	)]
-	pub secret: Vec<u8>,
+	#[serde(skip_serializing_if = "Option::is_none")]
+	pub secret: Option<SecretData>,
 }
 
 fn as_z85<S>(key: &[u8], serializer: S) -> Result<S::Ok, S::Error>
modifiedcmds/fleet/src/host.rsdiffbeforeafterboth
--- a/cmds/fleet/src/host.rs
+++ b/cmds/fleet/src/host.rs
@@ -1,21 +1,25 @@
 use std::{
 	env::current_dir,
 	ffi::{OsStr, OsString},
+	fmt::Display,
 	io::Write,
 	ops::Deref,
 	path::PathBuf,
+	str::FromStr,
 	sync::{Arc, Mutex, MutexGuard, OnceLock},
 };
 
+use age::Recipient;
 use anyhow::{anyhow, bail, Context, Result};
 use clap::{ArgGroup, Parser};
 use openssh::SessionBuilder;
+use serde::de::DeserializeOwned;
 use tempfile::NamedTempFile;
 
 use crate::{
 	better_nix_eval::{Field, NixSessionPool},
 	command::MyCommand,
-	fleetdata::{FleetData, FleetSecret, FleetSharedSecret},
+	fleetdata::{FleetData, FleetSecret, FleetSharedSecret, SecretData},
 	nix_go, nix_go_json,
 };
 
@@ -80,14 +84,25 @@
 		cmd.arg(path);
 		cmd.run_string().await
 	}
+	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {
+		let text = self.read_file_text(path).await?;
+		Ok(serde_json::from_str(&text)?)
+	}
+	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>
+	where
+		<D as FromStr>::Err: Display,
+	{
+		let text = self.read_file_text(path).await?;
+		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))
+	}
 	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {
 		let session = self.open_session().await?;
 		Ok(MyCommand::new_on(cmd, session))
 	}
 
-	pub async fn decrypt(&self, data: Vec<u8>) -> Result<Vec<u8>> {
+	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {
 		let mut cmd = self.cmd("fleet-install-secrets").await?;
-		cmd.arg("decrypt").eqarg("--secret", z85::encode(&data));
+		cmd.arg("decrypt").eqarg("--secret", data.encode_z85());
 		let encoded = cmd
 			.sudo()
 			.run_string()
@@ -95,6 +110,16 @@
 			.context("failed to call remote host for decrypt")?;
 		z85::decode(encoded.trim_end()).context("bad encoded data? outdated host?")
 	}
+	/// Returns path for futureproofing, as path might change i.e on conversion to CA
+	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {
+		let mut nix = MyCommand::new("nix");
+		nix.arg("copy")
+			.arg("--substitute-on-destination")
+			.comparg("--to", format!("ssh-ng://{}", self.name))
+			.arg(path);
+		nix.run_nix().await?;
+		Ok(path.to_owned())
+	}
 }
 
 impl Config {
@@ -166,8 +191,10 @@
 	}
 	/// Shared secrets configured in fleet.nix or in flake
 	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {
-		let config_field = &self.config_field;
-		nix_go!(config_field.sharedSecrets).list_fields().await
+		let config_field = &self.config_unchecked_field;
+		nix_go!(config_field.configUnchecked.sharedSecrets)
+			.list_fields()
+			.await
 	}
 	/// Shared secrets configured in fleet.nix
 	pub fn list_shared(&self) -> Vec<String> {
@@ -203,12 +230,11 @@
 	pub async fn reencrypt_on_host(
 		&self,
 		host: &str,
-		data: Vec<u8>,
+		data: SecretData,
 		targets: Vec<String>,
-	) -> Result<Vec<u8>> {
-		let data = z85::encode(&data);
+	) -> Result<SecretData> {
 		let mut recmd = MyCommand::new("fleet-install-secrets");
-		recmd.arg("reencrypt").eqarg("--secret", data);
+		recmd.arg("reencrypt").eqarg("--secret", data.encode_z85());
 		for target in targets {
 			recmd.eqarg("--targets", target);
 		}
@@ -219,7 +245,7 @@
 			.context("failed to call remote host for decrypt")?
 			.trim()
 			.to_owned();
-		z85::decode(encoded).context("bad encoded data? outdated host?")
+		SecretData::decode_z85(&encoded)
 	}
 
 	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {
@@ -240,9 +266,9 @@
 		Ok(secret.clone())
 	}
 	pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {
-		let config_field = &self.config_field;
+		let config_field = &self.config_unchecked_field;
 		Ok(nix_go_json!(
-			config_field.sharedSecrets[{ secret }].expectedOwners
+			config_field.configUnchecked.sharedSecrets[{ secret }].expectedOwners
 		))
 	}
 
modifiedcmds/fleet/src/keys.rsdiffbeforeafterboth
--- a/cmds/fleet/src/keys.rs
+++ b/cmds/fleet/src/keys.rs
@@ -2,7 +2,9 @@
 
 use crate::command::MyCommand;
 use crate::host::Config;
+use age::Recipient;
 use anyhow::{anyhow, Result};
+use futures::{StreamExt, TryStreamExt};
 use itertools::Itertools;
 use tracing::warn;
 
@@ -36,11 +38,18 @@
 		}
 	}
 	/// Insecure, requires root
-	pub async fn recipient(&self, host: &str) -> anyhow::Result<age::ssh::Recipient> {
+	pub async fn recipient(&self, host: &str) -> anyhow::Result<impl Recipient> {
 		let key = self.key(host).await?;
 		age::ssh::Recipient::from_str(&key).map_err(|e| anyhow!("parse recipient error: {:?}", e))
 	}
 
+	pub async fn recipients(&self, hosts: &[&str]) -> Result<Vec<impl Recipient>> {
+		futures::stream::iter(hosts.iter())
+			.then(|m| self.recipient(m))
+			.try_collect::<Vec<_>>()
+			.await
+	}
+
 	#[allow(dead_code)]
 	pub async fn orphaned_data(&self) -> Result<Vec<String>> {
 		let mut out = Vec::new();
modifiedcrates/nixlike/src/lib.rsdiffbeforeafterboth
--- a/crates/nixlike/src/lib.rs
+++ b/crates/nixlike/src/lib.rs
@@ -10,6 +10,8 @@
 mod se_impl;
 mod to_string;
 
+pub use to_string::escape_string;
+
 #[derive(thiserror::Error, Debug)]
 pub enum Error {
 	#[error("bad number")]
modifiedcrates/nixlike/src/to_string.rsdiffbeforeafterboth
--- a/crates/nixlike/src/to_string.rs
+++ b/crates/nixlike/src/to_string.rs
@@ -25,8 +25,8 @@
 	}
 }
 
-fn write_nix_str(str: &str, out: &mut String) {
-	out.push_str(&format!(
+pub fn escape_string(str: &str) -> String {
+	format!(
 		"\"{}\"",
 		str.replace('\\', "\\\\")
 			.replace('"', "\\\"")
@@ -34,7 +34,11 @@
 			.replace('\t', "\\t")
 			.replace('\r', "\\r")
 			.replace('$', "\\$")
-	))
+	)
+}
+
+pub fn write_nix_str(str: &str, out: &mut String) {
+	out.push_str(&escape_string(str))
 }
 
 fn write_nix_buf(value: &Value, out: &mut String) {