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
--- a/cmds/fleet/src/cmds/secrets/mod.rs
+++ b/cmds/fleet/src/cmds/secrets/mod.rs
@@ -1,6 +1,6 @@
 use crate::{
-	command::MyCommand,
-	fleetdata::{FleetSecret, FleetSharedSecret},
+	better_nix_eval::Field,
+	fleetdata::{FleetSecret, FleetSharedSecret, SecretData},
 	host::Config,
 	nix_go, nix_go_json,
 };
@@ -8,16 +8,16 @@
 use chrono::{DateTime, Utc};
 use clap::Parser;
 use futures::{StreamExt, TryStreamExt};
+use itertools::Itertools;
 use owo_colors::OwoColorize;
 use std::{
 	collections::HashSet,
 	io::{self, Cursor, Read},
 	path::PathBuf,
-	sync::Arc,
 };
 use tabled::{Table, Tabled};
 use tokio::fs::read_to_string;
-use tracing::{error, info, info_span, warn};
+use tracing::{info, info_span, warn};
 
 #[derive(Parser)]
 pub enum Secret {
@@ -90,82 +90,153 @@
 		prefer_identities: Vec<String>,
 	},
 	List {},
-	InvokeGenerator,
 }
 
-impl Secret {
-	pub async fn run(self, config: &Config) -> Result<()> {
-		match self {
-			Secret::InvokeGenerator => {
-				let config_field = &config.config_unchecked_field;
+async fn generate_shared(
+	config: &Config,
+	display_name: &str,
+	secret: Field,
+) -> Result<FleetSharedSecret> {
+	Ok(if secret.has_field("generateImpure").await? {
+		let config_field = &config.config_unchecked_field;
+		let generate = nix_go!(secret.generateImpure);
+		let owners: Vec<String> = nix_go_json!(secret.expectedOwners);
 
-				let secret =
-					nix_go!(config_field.configUnchecked.sharedSecrets["kube-apiserver.pem"]);
-				let generate_impure = nix_go!(secret.generateImpure);
-				let on = nix_go!(generate_impure.on);
-				let call_package = nix_go!(
-					config_field.buildableSystems(Obj {
-						localSystem: { config.local_system.clone() }
-					})[on]
-						.config
-						.nixpkgs
-						.resolvedPkgs
-						.callPackage
-				);
-				let generator = nix_go!(call_package(generate_impure.generator)(Obj {}));
-				let built = &generator.build().await?["out"];
-				let mut nix = MyCommand::new("nix");
-				let on: String = on.as_json().await?;
-				nix.arg("copy")
-					.arg("--substitute-on-destination")
-					.comparg("--to", format!("ssh-ng://{on}"))
-					.arg(built);
-				nix.run_nix().await?;
+		let on: String = nix_go_json!(generate.on);
+		let call_package = nix_go!(
+			config_field.buildableSystems(Obj {
+				localSystem: { config.local_system.clone() }
+			})[{ on }]
+			.config
+			.nixpkgs
+			.resolvedPkgs
+			.callPackage
+		);
 
-				let session = config.host(&on).await?;
+		let host = config.host(&on).await?;
 
-				let owners: Vec<String> = nix_go_json!(secret.expectedOwners);
-				dbg!(&owners);
+		let generator = nix_go!(call_package(generate.generator)(Obj {}));
+		let generator = generator.build().await?;
+		let generator = generator
+			.get("out")
+			.ok_or_else(|| anyhow!("missing generateImpure out"))?;
+		let generator = host.remote_derivation(generator).await?;
 
-				let mut recipients = String::new();
-				for owner in owners {
-					let key = config.key(&owner).await?;
-					recipients.push_str(&format!("-r \"{key}\" "));
-				}
-				recipients.push_str("-e");
+		let mut recipients = String::new();
+		for owner in &owners {
+			let key = config.key(owner).await?;
+			recipients.push_str(&format!("-r \"{key}\" "));
+		}
+		recipients.push_str("-e");
 
-				// FIXME: security: created directory might be accessible to other users
-				// This shouldn't be much of a concern, as data is encrypted right after creation, yet
-				// still better to have.
-				let tempdir = session.mktemp_dir().await?;
+		let out = host.mktemp_dir().await?;
 
-				let mut gen = session.cmd(built).await?;
-				gen.env("rageArgs", recipients).env("out", &tempdir);
-				gen.run().await?;
+		let mut gen = host.cmd(generator).await?;
+		gen.env("rageArgs", recipients).env("out", &out);
+		gen.run().await?;
 
-				{
-					let marker = session.read_file_text(format!("{tempdir}/marker")).await?;
-					ensure!(marker == "SUCCESS", "generation not succeeded");
-				}
+		{
+			let marker = host.read_file_text(format!("{out}/marker")).await?;
+			ensure!(marker == "SUCCESS", "generation not succeeded");
+		}
 
-				let public = session
-					.read_file_bin(format!("{tempdir}/public"))
-					.await
-					.ok();
-				let secret = session
-					.read_file_bin(format!("{tempdir}/secret"))
-					.await
-					.ok();
-				if let Some(secret) = &secret {
-					ensure!(
-						age::Decryptor::new(Cursor::new(&secret)).is_ok(),
-						"builder produced non-encrypted value as secret, this is highly insecure"
-					);
-				}
-				dbg!(&secret);
-				// // .as_json().await?;
-				// dbg!(&built);
-			}
+		let public = host.read_file_text(format!("{out}/public")).await.ok();
+		let secret = host.read_file_bin(format!("{out}/secret")).await.ok();
+		if let Some(secret) = &secret {
+			ensure!(
+				age::Decryptor::new(Cursor::new(&secret)).is_ok(),
+				"builder produced non-encrypted value as secret, this is highly insecure"
+			);
+		}
+
+		let created_at = host.read_file_value(format!("{out}/created_at")).await?;
+		let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();
+
+		FleetSharedSecret {
+			owners,
+			secret: FleetSecret {
+				created_at,
+				expires_at,
+				public,
+				secret: secret.map(SecretData),
+			},
+		}
+	} else {
+		bail!("no generator defined for {display_name}")
+	})
+}
+
+async fn parse_public(
+	public: Option<String>,
+	public_file: Option<PathBuf>,
+) -> Result<Option<String>> {
+	Ok(match (public, public_file) {
+		(Some(v), None) => Some(v),
+		(None, Some(v)) => Some(read_to_string(v).await?),
+		(Some(_), Some(_)) => {
+			bail!("only public or public_file should be set")
+		}
+		(None, None) => None,
+	})
+}
+
+fn parse_machines(
+	initial: Vec<String>,
+	machines: Option<Vec<String>>,
+	mut add_machines: Vec<String>,
+	mut remove_machines: Vec<String>,
+) -> Result<Vec<String>> {
+	if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {
+		bail!("no operation");
+	}
+
+	let initial_machines = initial.clone();
+	let mut target_machines = initial;
+	info!("Currently encrypted for {initial_machines:?}");
+
+	// ensure!(machines.is_some() || !add_machines.is_empty() || )
+	if let Some(machines) = machines {
+		ensure!(
+			add_machines.is_empty() && remove_machines.is_empty(),
+			"can't combine --machines and --add-machines/--remove-machines"
+		);
+		let target = initial_machines.iter().collect::<HashSet<_>>();
+		let source = machines.iter().collect::<HashSet<_>>();
+		for removed in target.difference(&source) {
+			remove_machines.push((*removed).clone());
+		}
+		for added in source.difference(&target) {
+			add_machines.push((*added).clone());
+		}
+	}
+
+	for machine in &remove_machines {
+		let mut removed = false;
+		while let Some(pos) = target_machines.iter().position(|m| m == machine) {
+			target_machines.swap_remove(pos);
+			removed = true;
+		}
+		if !removed {
+			warn!("secret is not enabled for {machine}");
+		}
+	}
+	for machine in &add_machines {
+		if target_machines.iter().any(|m| m == machine) {
+			warn!("secret is already added to {machine}");
+		} else {
+			target_machines.push(machine.to_owned());
+		}
+	}
+	if !remove_machines.is_empty() {
+		// TODO: maybe force secret regeneration?
+		// Not that useful without revokation.
+		warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");
+	}
+	Ok(target_machines)
+}
+impl Secret {
+	pub async fn run(self, config: &Config) -> Result<()> {
+		match self {
 			Secret::ForceKeys => {
 				for host in config.list_hosts().await? {
 					if config.should_skip(&host.name) {
@@ -199,9 +270,8 @@
 					machines = shared.owners;
 				}
 
-				let recipients = futures::stream::iter(machines.iter())
-					.then(|m| config.recipient(m))
-					.try_collect::<Vec<_>>()
+				let recipients = config
+					.recipients(&machines.iter().map(String::as_str).collect_vec())
 					.await?;
 
 				let secret = {
@@ -209,22 +279,15 @@
 					io::stdin().read_to_end(&mut input)?;
 
 					if input.is_empty() {
-						input
+						None
 					} else {
-						let mut encrypted = vec![];
-						let recipients = recipients
-							.iter()
-							.cloned()
-							.map(|r| Box::new(r) as Box<dyn age::Recipient + Send>)
-							.collect();
-						let mut encryptor = age::Encryptor::with_recipients(recipients)
-							.ok_or_else(|| anyhow!("no recipients provided"))?
-							.wrap_output(&mut encrypted)?;
-						io::copy(&mut Cursor::new(input), &mut encryptor)?;
-						encryptor.finish()?;
-						encrypted
+						Some(
+							SecretData::encrypt(recipients, input)
+								.ok_or_else(|| anyhow!("no recipients provided"))?,
+						)
 					}
 				};
+				let public = parse_public(public, public_file).await?;
 				config.replace_shared(
 					name,
 					FleetSharedSecret {
@@ -233,14 +296,7 @@
 							created_at: Utc::now(),
 							expires_at,
 							secret,
-							public: match (public, public_file) {
-								(Some(v), None) => Some(v),
-								(None, Some(v)) => Some(read_to_string(v).await?),
-								(Some(_), Some(_)) => {
-									bail!("only public or public_file should be set")
-								}
-								(None, None) => None,
-							},
+							public,
 						},
 					},
 				);
@@ -261,19 +317,14 @@
 						bail!("no data provided")
 					}
 
-					let mut encrypted = vec![];
-					let recipient = Box::new(recipient) as Box<dyn age::Recipient + Send>;
-					let mut encryptor = age::Encryptor::with_recipients(vec![recipient])
-						.expect("recipients provided")
-						.wrap_output(&mut encrypted)?;
-					io::copy(&mut Cursor::new(input), &mut encryptor)?;
-					encryptor.finish()?;
-					encrypted
+					Some(SecretData::encrypt(vec![recipient], input).expect("recipient provided"))
 				};
 
 				if config.has_secret(&machine, &name) && !force {
 					bail!("secret already defined");
 				}
+				let public = parse_public(public, public_file).await?;
+
 				config.insert_secret(
 					&machine,
 					name,
@@ -281,16 +332,10 @@
 						created_at: Utc::now(),
 						expires_at: None,
 						secret,
-						public: match (public, public_file) {
-							(Some(v), None) => Some(v),
-							(None, Some(v)) => Some(std::fs::read_to_string(v)?),
-							(Some(_), Some(_)) => bail!("only public or public_file should be set"),
-							(None, None) => None,
-						},
+						public,
 					},
 				);
 			}
-			// TODO: Instead of using sudo, decode secret on remote machine
 			#[allow(clippy::await_holding_refcell_ref)]
 			Secret::Read {
 				name,
@@ -298,11 +343,11 @@
 				plaintext,
 			} => {
 				let secret = config.host_secret(&machine, &name)?;
-				if secret.secret.is_empty() {
+				let Some(secret) = secret.secret else {
 					bail!("no secret {name}");
-				}
+				};
 				let host = config.host(&machine).await?;
-				let data = host.decrypt(secret.secret).await?;
+				let data = host.decrypt(secret).await?;
 				if plaintext {
 					let s = String::from_utf8(data).context("output is not utf8")?;
 					print!("{s}");
@@ -313,59 +358,22 @@
 			Secret::UpdateShared {
 				name,
 				machines,
-				mut add_machines,
-				mut remove_machines,
+				add_machines,
+				remove_machines,
 				prefer_identities,
 			} => {
-				if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {
-					bail!("no operation");
-				}
-
 				let mut secret = config.shared_secret(&name)?;
-				if secret.secret.secret.is_empty() {
+				if secret.secret.secret.is_none() {
 					bail!("no secret");
 				}
 
 				let initial_machines = secret.owners.clone();
-				let mut target_machines = secret.owners.clone();
-				info!("Currently encrypted for {initial_machines:?}");
-
-				// ensure!(machines.is_some() || !add_machines.is_empty() || )
-				if let Some(machines) = machines {
-					ensure!(
-						add_machines.is_empty() && remove_machines.is_empty(),
-						"can't combine --machines and --add-machines/--remove-machines"
-					);
-					let target = initial_machines.iter().collect::<HashSet<_>>();
-					let source = machines.iter().collect::<HashSet<_>>();
-					for removed in target.difference(&source) {
-						remove_machines.push((*removed).clone());
-					}
-					for added in source.difference(&target) {
-						add_machines.push((*added).clone());
-					}
-				}
-
-				for machine in &remove_machines {
-					let mut removed = false;
-					while let Some(pos) = target_machines.iter().position(|m| m == machine) {
-						target_machines.swap_remove(pos);
-						removed = true;
-					}
-					if !removed {
-						warn!("secret is not enabled for {machine}");
-					}
-				}
-				for machine in &add_machines {
-					if target_machines.iter().any(|m| m == machine) {
-						warn!("secret is already added to {machine}");
-					} else {
-						target_machines.push(machine.to_owned());
-					}
-				}
-				if !remove_machines.is_empty() {
-					warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");
-				}
+				let target_machines = parse_machines(
+					initial_machines.clone(),
+					machines,
+					add_machines,
+					remove_machines,
+				)?;
 
 				if target_machines.is_empty() {
 					info!("no machines left for secret, removing it");
@@ -395,12 +403,14 @@
 				let target_recipients =
 					target_recipients.into_iter().collect::<Result<Vec<_>>>()?;
 
-				let encrypted = config
-					.reencrypt_on_host(identity_holder, secret.secret.secret, target_recipients)
-					.await?;
+				if let Some(data) = secret.secret.secret {
+					let encrypted = config
+						.reencrypt_on_host(identity_holder, data, target_recipients)
+						.await?;
+					secret.secret.secret = Some(encrypted);
+				}
 
 				secret.owners = target_machines;
-				secret.secret.secret = encrypted;
 				config.replace_shared(name, secret);
 			}
 			Secret::Regenerate { prefer_identities } => {
@@ -412,14 +422,20 @@
 						.collect::<HashSet<_>>();
 					let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();
 					for removed in expected_shared_set.difference(&shared_set) {
-						error!("secret needs to be generated: {removed}")
+						info!("generating secret: {removed}");
+						let config_field = &config.config_unchecked_field;
+						let config_field = nix_go!(config_field.configUnchecked);
+						let secret = nix_go!(config_field.sharedSecrets[{ removed }]);
+						let shared = generate_shared(config, removed, secret).await?;
+						config.replace_shared(removed.to_string(), shared)
 					}
 				}
 				let mut to_remove = Vec::new();
 				for name in &config.list_shared() {
 					info!("updating secret: {name}");
 					let mut data = config.shared_secret(name)?;
-					let config_field = &config.config_field;
+					let config_field = &config.config_unchecked_field;
+					let config_field = nix_go!(config_field.configUnchecked);
 					let expected_owners: Vec<String> =
 						nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);
 					if expected_owners.is_empty() {
@@ -430,50 +446,52 @@
 					let set = data.owners.iter().collect::<HashSet<_>>();
 					let expected_set = expected_owners.iter().collect::<HashSet<_>>();
 					let should_remove = set.difference(&expected_set).next().is_some();
-					if set != expected_set {
-						let owner_dependent: bool =
-							nix_go_json!(config_field.sharedSecrets[{ name }].ownerDependent);
-						if !owner_dependent {
-							warn!("reencrypting secret '{name}' for new owner set");
-							// TODO: force regeneration
-							if should_remove {
-								warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");
-							}
+					if set == expected_set {
+						info!("secret data is ok");
+						continue;
+					}
+
+					let secret = nix_go!(config_field.sharedSecrets[{ name }]);
+					let owner_dependent: bool = nix_go_json!(secret.ownerDependent);
+					let regenerate_on_remove: bool = nix_go_json!(secret.regenerateOnOwnerRemoved);
+					#[allow(clippy::nonminimal_bool)]
+					if !owner_dependent && !(should_remove && regenerate_on_remove) {
+						warn!("reencrypting secret '{name}' for new owner set");
+						// TODO: force regeneration
+						if should_remove {
+							warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");
+						}
 
-							let identity_holder = if !prefer_identities.is_empty() {
-								prefer_identities
-									.iter()
-									.find(|i| data.owners.iter().any(|s| s == *i))
-							} else {
-								data.owners.first()
-							};
-							let Some(identity_holder) = identity_holder else {
-								bail!("no available holder found");
-							};
+						let identity_holder = if !prefer_identities.is_empty() {
+							prefer_identities
+								.iter()
+								.find(|i| data.owners.iter().any(|s| s == *i))
+						} else {
+							data.owners.first()
+						};
+						let Some(identity_holder) = identity_holder else {
+							bail!("no available holder found");
+						};
 
-							let target_recipients = futures::stream::iter(&expected_owners)
-								.then(|m| async { config.key(m).await })
-								.collect::<Vec<_>>()
-								.await;
-							let target_recipients =
-								target_recipients.into_iter().collect::<Result<Vec<_>>>()?;
+						let target_recipients = futures::stream::iter(&expected_owners)
+							.then(|m| async { config.key(m).await })
+							.collect::<Vec<_>>()
+							.await;
+						let target_recipients =
+							target_recipients.into_iter().collect::<Result<Vec<_>>>()?;
 
+						if let Some(secret) = data.secret.secret {
 							let encrypted = config
-								.reencrypt_on_host(
-									identity_holder,
-									data.secret.secret,
-									target_recipients,
-								)
+								.reencrypt_on_host(identity_holder, secret, target_recipients)
 								.await?;
 
-							data.secret.secret = encrypted;
-							data.owners = expected_owners;
-							config.replace_shared(name.to_owned(), data);
-						} else {
-							error!("secret '{name}' should be regenerated manually");
+							data.secret.secret = Some(encrypted);
 						}
+						data.owners = expected_owners;
+						config.replace_shared(name.to_owned(), data);
 					} else {
-						info!("secret data is ok")
+						let shared = generate_shared(config, name, secret).await?;
+						config.replace_shared(name.to_owned(), shared)
 					}
 				}
 				for k in to_remove {
modifiedcmds/fleet/src/fleetdata.rsdiffbeforeafterboth
1use age::Recipient;
1use anyhow::Result;2use anyhow::Result;
2use chrono::{DateTime, Utc};3use chrono::{DateTime, Utc};
4use itertools::Itertools;
3use nixlike::format_nix;5use nixlike::format_nix;
4use serde::{Deserialize, Deserializer, Serialize, Serializer};6use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use std::collections::BTreeMap;7use std::{
8 collections::BTreeMap,
9 io::{self, Cursor},
10};
6use tempfile::TempDir;11use tempfile::TempDir;
7use tokio::{12use tokio::{
8 fs::{self, File},13 fs::{self, File},
40 pub secret: FleetSecret,45 pub secret: FleetSecret,
41}46}
47
48#[derive(Serialize, Deserialize, Clone)]
49pub struct SecretData(
50 #[serde(
51 default,
52 skip_serializing_if = "Vec::is_empty",
53 serialize_with = "as_z85",
54 deserialize_with = "from_z85"
55 )]
56 pub Vec<u8>,
57);
58impl SecretData {
59 /// Returns None if recipients.is_empty()
60 pub fn encrypt(
61 recipients: impl IntoIterator<Item = impl Recipient + Send + 'static>,
62 data: Vec<u8>,
63 ) -> Option<Self> {
64 let mut encrypted = vec![];
65 let recipients = recipients
66 .into_iter()
67 .map(|v| Box::new(v) as Box<dyn Recipient + Send>)
68 .collect_vec();
69 let mut encryptor = age::Encryptor::with_recipients(recipients)?
70 .wrap_output(&mut encrypted)
71 .expect("in memory write");
72 io::copy(&mut Cursor::new(data), &mut encryptor).expect("in memory copy");
73 encryptor.finish().expect("in memory flush");
74 Some(Self(encrypted))
75 }
76 pub fn encode_z85(&self) -> String {
77 z85::encode(&self.0)
78 }
79 pub fn decode_z85(v: &str) -> Result<Self> {
80 let v = z85::decode(v)?;
81 Ok(Self(v))
82 }
83}
4284
43#[derive(Serialize, Deserialize, Clone)]85#[derive(Serialize, Deserialize, Clone)]
44#[serde(rename_all = "camelCase")]86#[serde(rename_all = "camelCase")]
51 pub expires_at: Option<DateTime<Utc>>,93 pub expires_at: Option<DateTime<Utc>>,
52 #[serde(skip_serializing_if = "Option::is_none")]94 #[serde(skip_serializing_if = "Option::is_none")]
53 pub public: Option<String>,95 pub public: Option<String>,
54 #[serde(96 #[serde(skip_serializing_if = "Option::is_none")]
55 default,
56 skip_serializing_if = "Vec::is_empty",
57 serialize_with = "as_z85",
58 deserialize_with = "from_z85"
59 )]
60 pub secret: Vec<u8>,97 pub secret: Option<SecretData>,
61}98}
6299
63fn as_z85<S>(key: &[u8], serializer: S) -> Result<S::Ok, S::Error>100fn 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) {