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
1use crate::{1use crate::{
2 command::MyCommand,2 better_nix_eval::Field,
3 fleetdata::{FleetSecret, FleetSharedSecret},3 fleetdata::{FleetSecret, FleetSharedSecret, SecretData},
4 host::Config,4 host::Config,
5 nix_go, nix_go_json,5 nix_go, nix_go_json,
6};6};
7use anyhow::{anyhow, bail, ensure, Context, Result};7use anyhow::{anyhow, bail, ensure, Context, Result};
8use chrono::{DateTime, Utc};8use chrono::{DateTime, Utc};
9use clap::Parser;9use clap::Parser;
10use futures::{StreamExt, TryStreamExt};10use futures::{StreamExt, TryStreamExt};
11use itertools::Itertools;
11use owo_colors::OwoColorize;12use owo_colors::OwoColorize;
12use std::{13use std::{
13 collections::HashSet,14 collections::HashSet,
14 io::{self, Cursor, Read},15 io::{self, Cursor, Read},
15 path::PathBuf,16 path::PathBuf,
16 sync::Arc,
17};17};
18use tabled::{Table, Tabled};18use tabled::{Table, Tabled};
19use tokio::fs::read_to_string;19use tokio::fs::read_to_string;
20use tracing::{error, info, info_span, warn};20use tracing::{info, info_span, warn};
2121
22#[derive(Parser)]22#[derive(Parser)]
23pub enum Secret {23pub enum Secret {
90 prefer_identities: Vec<String>,90 prefer_identities: Vec<String>,
91 },91 },
92 List {},92 List {},
93 InvokeGenerator,
94}93}
9594
96impl Secret {
97 pub async fn run(self, config: &Config) -> Result<()> {95async fn generate_shared(
96 config: &Config,
97 display_name: &str,
98 secret: Field,
99) -> Result<FleetSharedSecret> {
98 match self {100 Ok(if secret.has_field("generateImpure").await? {
99 Secret::InvokeGenerator => {101 let config_field = &config.config_unchecked_field;
100 let config_field = &config.config_unchecked_field;102 let generate = nix_go!(secret.generateImpure);
103 let owners: Vec<String> = nix_go_json!(secret.expectedOwners);
101104
102 let secret =105 let on: String = nix_go_json!(generate.on);
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!(106 let call_package = nix_go!(
107 config_field.buildableSystems(Obj {107 config_field.buildableSystems(Obj {
108 localSystem: { config.local_system.clone() }108 localSystem: { config.local_system.clone() }
109 })[on]109 })[{ on }]
110 .config110 .config
111 .nixpkgs111 .nixpkgs
112 .resolvedPkgs112 .resolvedPkgs
113 .callPackage113 .callPackage
114 );114 );
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?;
124115
125 let session = config.host(&on).await?;116 let host = config.host(&on).await?;
126117
127 let owners: Vec<String> = nix_go_json!(secret.expectedOwners);118 let generator = nix_go!(call_package(generate.generator)(Obj {}));
128 dbg!(&owners);119 let generator = generator.build().await?;
120 let generator = generator
121 .get("out")
122 .ok_or_else(|| anyhow!("missing generateImpure out"))?;
123 let generator = host.remote_derivation(generator).await?;
129124
130 let mut recipients = String::new();125 let mut recipients = String::new();
131 for owner in owners {126 for owner in &owners {
132 let key = config.key(&owner).await?;127 let key = config.key(owner).await?;
133 recipients.push_str(&format!("-r \"{key}\" "));128 recipients.push_str(&format!("-r \"{key}\" "));
134 }129 }
135 recipients.push_str("-e");130 recipients.push_str("-e");
136131
137 // FIXME: security: created directory might be accessible to other users132 let out = host.mktemp_dir().await?;
138 // This shouldn't be much of a concern, as data is encrypted right after creation, yet
139 // still better to have.
140 let tempdir = session.mktemp_dir().await?;
141133
142 let mut gen = session.cmd(built).await?;134 let mut gen = host.cmd(generator).await?;
143 gen.env("rageArgs", recipients).env("out", &tempdir);135 gen.env("rageArgs", recipients).env("out", &out);
144 gen.run().await?;136 gen.run().await?;
145137
146 {138 {
147 let marker = session.read_file_text(format!("{tempdir}/marker")).await?;139 let marker = host.read_file_text(format!("{out}/marker")).await?;
148 ensure!(marker == "SUCCESS", "generation not succeeded");140 ensure!(marker == "SUCCESS", "generation not succeeded");
149 }141 }
150142
151 let public = session143 let public = host.read_file_text(format!("{out}/public")).await.ok();
152 .read_file_bin(format!("{tempdir}/public"))144 let secret = host.read_file_bin(format!("{out}/secret")).await.ok();
153 .await145 if let Some(secret) = &secret {
154 .ok();146 ensure!(
155 let secret = session147 age::Decryptor::new(Cursor::new(&secret)).is_ok(),
156 .read_file_bin(format!("{tempdir}/secret"))148 "builder produced non-encrypted value as secret, this is highly insecure"
157 .await149 );
158 .ok();150 }
159 if let Some(secret) = &secret {151
160 ensure!(152 let created_at = host.read_file_value(format!("{out}/created_at")).await?;
161 age::Decryptor::new(Cursor::new(&secret)).is_ok(),153 let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();
162 "builder produced non-encrypted value as secret, this is highly insecure"154
163 );155 FleetSharedSecret {
164 }156 owners,
165 dbg!(&secret);157 secret: FleetSecret {
166 // // .as_json().await?;158 created_at,
167 // dbg!(&built);159 expires_at,
168 }160 public,
161 secret: secret.map(SecretData),
162 },
163 }
164 } else {
165 bail!("no generator defined for {display_name}")
166 })
167}
168
169async 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}
182
183fn 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 }
192
193 let initial_machines = initial.clone();
194 let mut target_machines = initial;
195 info!("Currently encrypted for {initial_machines:?}");
196
197 // 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 }
212
213 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 {
169 Secret::ForceKeys => {240 Secret::ForceKeys => {
170 for host in config.list_hosts().await? {241 for host in config.list_hosts().await? {
171 if config.should_skip(&host.name) {242 if config.should_skip(&host.name) {
199 machines = shared.owners;270 machines = shared.owners;
200 }271 }
201272
202 let recipients = futures::stream::iter(machines.iter())273 let recipients = config
203 .then(|m| config.recipient(m))274 .recipients(&machines.iter().map(String::as_str).collect_vec())
204 .try_collect::<Vec<_>>()
205 .await?;275 .await?;
206276
207 let secret = {277 let secret = {
208 let mut input = vec![];278 let mut input = vec![];
209 io::stdin().read_to_end(&mut input)?;279 io::stdin().read_to_end(&mut input)?;
210280
211 if input.is_empty() {281 if input.is_empty() {
212 input282 None
213 } else {283 } else {
214 let mut encrypted = vec![];284 Some(
215 let recipients = recipients
216 .iter()
217 .cloned()285 SecretData::encrypt(recipients, input)
218 .map(|r| Box::new(r) as Box<dyn age::Recipient + Send>)
219 .collect();286 .ok_or_else(|| anyhow!("no recipients provided"))?,
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()?;287 )
225 encrypted
226 }288 }
227 };289 };
290 let public = parse_public(public, public_file).await?;
228 config.replace_shared(291 config.replace_shared(
229 name,292 name,
230 FleetSharedSecret {293 FleetSharedSecret {
233 created_at: Utc::now(),296 created_at: Utc::now(),
234 expires_at,297 expires_at,
235 secret,298 secret,
236 public: match (public, public_file) {299 public,
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 },300 },
245 },301 },
246 );302 );
261 bail!("no data provided")317 bail!("no data provided")
262 }318 }
263319
264 let mut encrypted = vec![];320 Some(SecretData::encrypt(vec![recipient], input).expect("recipient provided"))
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 encrypted
272 };321 };
273322
274 if config.has_secret(&machine, &name) && !force {323 if config.has_secret(&machine, &name) && !force {
275 bail!("secret already defined");324 bail!("secret already defined");
276 }325 }
326 let public = parse_public(public, public_file).await?;
327
277 config.insert_secret(328 config.insert_secret(
278 &machine,329 &machine,
279 name,330 name,
280 FleetSecret {331 FleetSecret {
281 created_at: Utc::now(),332 created_at: Utc::now(),
282 expires_at: None,333 expires_at: None,
283 secret,334 secret,
284 public: match (public, public_file) {335 public,
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 },336 },
291 );337 );
292 }338 }
293 // TODO: Instead of using sudo, decode secret on remote machine
294 #[allow(clippy::await_holding_refcell_ref)]339 #[allow(clippy::await_holding_refcell_ref)]
295 Secret::Read {340 Secret::Read {
296 name,341 name,
297 machine,342 machine,
298 plaintext,343 plaintext,
299 } => {344 } => {
300 let secret = config.host_secret(&machine, &name)?;345 let secret = config.host_secret(&machine, &name)?;
301 if secret.secret.is_empty() {346 let Some(secret) = secret.secret else {
302 bail!("no secret {name}");347 bail!("no secret {name}");
303 }348 };
304 let host = config.host(&machine).await?;349 let host = config.host(&machine).await?;
305 let data = host.decrypt(secret.secret).await?;350 let data = host.decrypt(secret).await?;
306 if plaintext {351 if plaintext {
307 let s = String::from_utf8(data).context("output is not utf8")?;352 let s = String::from_utf8(data).context("output is not utf8")?;
308 print!("{s}");353 print!("{s}");
313 Secret::UpdateShared {358 Secret::UpdateShared {
314 name,359 name,
315 machines,360 machines,
316 mut add_machines,361 add_machines,
317 mut remove_machines,362 remove_machines,
318 prefer_identities,363 prefer_identities,
319 } => {364 } => {
320 if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {
321 bail!("no operation");
322 }
323
324 let mut secret = config.shared_secret(&name)?;365 let mut secret = config.shared_secret(&name)?;
325 if secret.secret.secret.is_empty() {366 if secret.secret.secret.is_none() {
326 bail!("no secret");367 bail!("no secret");
327 }368 }
328369
329 let initial_machines = secret.owners.clone();370 let initial_machines = secret.owners.clone();
330 let mut target_machines = secret.owners.clone();371 let target_machines = parse_machines(
372 initial_machines.clone(),
331 info!("Currently encrypted for {initial_machines:?}");373 machines,
374 add_machines,
375 remove_machines,
376 )?;
332377
333 // 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 }
348
349 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 }
369
370 if target_machines.is_empty() {378 if target_machines.is_empty() {
371 info!("no machines left for secret, removing it");379 info!("no machines left for secret, removing it");
372 config.remove_shared(&name);380 config.remove_shared(&name);
395 let target_recipients =403 let target_recipients =
396 target_recipients.into_iter().collect::<Result<Vec<_>>>()?;404 target_recipients.into_iter().collect::<Result<Vec<_>>>()?;
397405
398 let encrypted = config406 if let Some(data) = secret.secret.secret {
407 let encrypted = config
399 .reencrypt_on_host(identity_holder, secret.secret.secret, target_recipients)408 .reencrypt_on_host(identity_holder, data, target_recipients)
400 .await?;409 .await?;
410 secret.secret.secret = Some(encrypted);
411 }
401412
402 secret.owners = target_machines;413 secret.owners = target_machines;
403 secret.secret.secret = encrypted;
404 config.replace_shared(name, secret);414 config.replace_shared(name, secret);
405 }415 }
406 Secret::Regenerate { prefer_identities } => {416 Secret::Regenerate { prefer_identities } => {
412 .collect::<HashSet<_>>();422 .collect::<HashSet<_>>();
413 let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();423 let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();
414 for removed in expected_shared_set.difference(&shared_set) {424 for removed in expected_shared_set.difference(&shared_set) {
415 error!("secret needs to be generated: {removed}")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)
416 }431 }
417 }432 }
418 let mut to_remove = Vec::new();433 let mut to_remove = Vec::new();
419 for name in &config.list_shared() {434 for name in &config.list_shared() {
420 info!("updating secret: {name}");435 info!("updating secret: {name}");
421 let mut data = config.shared_secret(name)?;436 let mut data = config.shared_secret(name)?;
422 let config_field = &config.config_field;437 let config_field = &config.config_unchecked_field;
438 let config_field = nix_go!(config_field.configUnchecked);
423 let expected_owners: Vec<String> =439 let expected_owners: Vec<String> =
424 nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);440 nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);
425 if expected_owners.is_empty() {441 if expected_owners.is_empty() {
430 let set = data.owners.iter().collect::<HashSet<_>>();446 let set = data.owners.iter().collect::<HashSet<_>>();
431 let expected_set = expected_owners.iter().collect::<HashSet<_>>();447 let expected_set = expected_owners.iter().collect::<HashSet<_>>();
432 let should_remove = set.difference(&expected_set).next().is_some();448 let should_remove = set.difference(&expected_set).next().is_some();
433 if set != expected_set {449 if set == expected_set {
434 let owner_dependent: bool =450 info!("secret data is ok");
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 regeneration451 continue;
439 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 }452 }
442453
443 let identity_holder = if !prefer_identities.is_empty() {454 let secret = nix_go!(config_field.sharedSecrets[{ name }]);
444 prefer_identities455 let owner_dependent: bool = nix_go_json!(secret.ownerDependent);
445 .iter()456 let regenerate_on_remove: bool = nix_go_json!(secret.regenerateOnOwnerRemoved);
457 #[allow(clippy::nonminimal_bool)]
446 .find(|i| data.owners.iter().any(|s| s == *i))458 if !owner_dependent && !(should_remove && regenerate_on_remove) {
447 } else {459 warn!("reencrypting secret '{name}' for new owner set");
448 data.owners.first()
449 };460 // TODO: force regeneration
450 let Some(identity_holder) = identity_holder else {461 if should_remove {
451 bail!("no available holder found");462 warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");
452 };463 }
453464
454 let target_recipients = futures::stream::iter(&expected_owners)465 let identity_holder = if !prefer_identities.is_empty() {
466 prefer_identities
467 .iter()
455 .then(|m| async { config.key(m).await })468 .find(|i| data.owners.iter().any(|s| s == *i))
456 .collect::<Vec<_>>()469 } else {
470 data.owners.first()
457 .await;471 };
458 let target_recipients =472 let Some(identity_holder) = identity_holder else {
459 target_recipients.into_iter().collect::<Result<Vec<_>>>()?;473 bail!("no available holder found");
474 };
460475
476 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<_>>>()?;
482
483 if let Some(secret) = data.secret.secret {
461 let encrypted = config484 let encrypted = config
462 .reencrypt_on_host(485 .reencrypt_on_host(identity_holder, secret, target_recipients)
463 identity_holder,
464 data.secret.secret,
465 target_recipients,
466 )
467 .await?;486 .await?;
468487
469 data.secret.secret = encrypted;488 data.secret.secret = Some(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 }489 }
490 data.owners = expected_owners;
491 config.replace_shared(name.to_owned(), data);
475 } else {492 } else {
476 info!("secret data is ok")493 let shared = generate_shared(config, name, secret).await?;
494 config.replace_shared(name.to_owned(), shared)
477 }495 }
478 }496 }
479 for k in to_remove {497 for k in to_remove {
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) {