git.delta.rocks / jrsonnet / refs/commits / 757475fe4cab

difftreelog

feat manager identities

ytxvryoyYaroslav Bolyukin2025-07-29parent: #34f0c72.patch.diff
in: trunk

7 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -92,6 +92,8 @@
  "scrypt",
  "sha2",
  "subtle",
+ "which",
+ "wsl",
  "x25519-dalek",
  "zeroize",
 ]
@@ -111,6 +113,7 @@
  "rand 0.8.5",
  "secrecy",
  "sha2",
+ "tempfile",
 ]
 
 [[package]]
@@ -1286,6 +1289,15 @@
 ]
 
 [[package]]
+name = "home"
+version = "0.5.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
+[[package]]
 name = "hostname"
 version = "0.4.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3639,6 +3651,18 @@
 ]
 
 [[package]]
+name = "which"
+version = "4.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7"
+dependencies = [
+ "either",
+ "home",
+ "once_cell",
+ "rustix 0.38.40",
+]
+
+[[package]]
 name = "winapi"
 version = "0.3.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3776,6 +3800,12 @@
 ]
 
 [[package]]
+name = "wsl"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dab7ac864710bdea6594becbea5b5050333cf34fefb0dc319567eb347950d4"
+
+[[package]]
 name = "x25519-dalek"
 version = "2.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
modifiedCargo.tomldiffbeforeafterboth
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,7 +11,7 @@
 nix-eval = { path = "./crates/nix-eval" }
 nixlike = { path = "./crates/nixlike" }
 
-age = { version = "0.11", features = ["ssh"] }
+age = { version = "0.11", features = ["ssh", "plugin"] }
 anyhow = "1.0"
 clap = { version = "4.5", features = ["derive", "env", "unicode", "wrap_help"] }
 clap_complete = "4.5"
modifiedcmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth
before · cmds/fleet/src/cmds/secrets/mod.rs
1use std::{2	collections::{BTreeMap, BTreeSet, HashSet},3	io::{self, Read, Write, stdin, stdout},4	path::PathBuf,5};67use age::Recipient;8use anyhow::{Context, Result, anyhow, bail, ensure};9use chrono::{DateTime, Utc};10use clap::Parser;11use fleet_base::{12	fleetdata::{FleetSecret, FleetSecretPart, FleetSharedSecret, encrypt_secret_data},13	host::Config,14	opts::FleetOpts,15};16use fleet_shared::SecretData;17use nix_eval::{NixBuildBatch, Value, nix_go, nix_go_json};18use owo_colors::OwoColorize;19use serde::Deserialize;20use tabled::{Table, Tabled};21use tokio::fs::read;22use tracing::{Instrument, error, info, info_span, warn};2324#[derive(Parser)]25pub enum Secret {26	/// Force load host keys for all defined hosts27	ForceKeys,28	/// Add secret, data should be provided in stdin29	AddShared {30		/// Secret name31		name: String,32		/// Secret owners33		#[clap(long, short)]34		machines: Vec<String>,35		/// Override secret if already present36		#[clap(long)]37		force: bool,38		/// Secret public part39		#[clap(long)]40		public: Option<String>,41		/// Load public part from specified file42		#[clap(long)]43		public_file: Option<PathBuf>,4445		/// Create a notification on secret expiration46		#[clap(long)]47		expires_at: Option<DateTime<Utc>>,4849		/// Secret with this name already exists, override its value while keeping the same owners.50		#[clap(long)]51		re_add: bool,5253		/// How to name public secret part54		#[clap(long, short = 'p', default_value = "public")]55		public_part: String,56		/// How to name private secret part57		#[clap(short = 's', long, default_value = "secret")]58		part: String,59	},60	/// Add secret, data should be provided in stdin61	Add {62		/// Secret name63		name: String,64		/// Secret owner65		#[clap(short = 'm', long)]66		machine: String,67		/// Replace secret if already present68		#[clap(long)]69		replace: bool,70		/// Add new parts to existing secret71		#[clap(long)]72		merge: bool,73		/// Secret public part74		#[clap(long)]75		public: Option<String>,76		/// Load public part from specified file77		#[clap(long)]78		public_file: Option<PathBuf>,7980		/// How to name public secret part81		#[clap(short = 'p', long, default_value = "public")]82		public_part: String,83		/// How to name private secret part84		#[clap(short = 's', long, default_value = "secret")]85		part: String,86	},87	/// Read secret from remote host, requires sudo on said host88	Read {89		name: String,90		#[clap(short = 'm', long)]91		machine: String,9293		/// Which private secret part to read94		#[clap(short = 'p', long, default_value = "secret")]95		part: String,96	},97	/// Read secret from remote host, requires sudo on said host98	ReadShared {99		name: String,100		/// Which private secret part to read101		#[clap(short = 'p', long, default_value = "secret")]102		part: String,103		/// Which host should we use to decrypt, in case if reencryption is required, without104		/// regeneration105		#[clap(long)]106		prefer_identities: Vec<String>,107	},108	UpdateShared {109		name: String,110111		#[clap(short = 'm', long)]112		machine: Option<Vec<String>>,113114		#[clap(long)]115		add_machine: Vec<String>,116		#[clap(long)]117		remove_machine: Vec<String>,118119		/// Which host should we use to decrypt120		#[clap(long)]121		prefer_identities: Vec<String>,122	},123	Regenerate {124		/// Which host should we use to decrypt, in case if reencryption is required, without125		/// regeneration126		#[clap(long)]127		prefer_identities: Vec<String>,128		/// Only regenerate shared secrets129		#[clap(long)]130		skip_hosts: bool,131	},132	List {},133	Edit {134		name: String,135		#[clap(short = 'm', long)]136		machine: String,137138		#[clap(long)]139		add: bool,140141		/// Which private secret part to read142		#[clap(short = 'p', long, default_value = "secret")]143		part: String,144	},145}146147fn secret_needs_regeneration(148	secret: &FleetSecret,149	expected_generation_data: &serde_json::Value,150) -> bool {151	let data_is_expected = secret.generation_data == *expected_generation_data;152	// TODO: Leeway?153	let expired = secret154		.expires_at155		.map(|expiration| expiration < Utc::now())156		.unwrap_or(false);157	expired || !data_is_expected158}159160#[allow(clippy::too_many_arguments)]161#[tracing::instrument(skip(config, secret, field, prefer_identities, batch))]162async fn maybe_regenerate_shared_secret(163	secret_name: &str,164	config: &Config,165	mut secret: FleetSharedSecret,166	field: Value,167	expected_owners: &[String],168	expected_generation_data: serde_json::Value,169	prefer_identities: &[String],170	batch: Option<NixBuildBatch>,171) -> Result<FleetSharedSecret> {172	let original_set = secret.owners.clone();173174	let set = original_set.iter().collect::<BTreeSet<_>>();175	let expected_set = expected_owners.iter().collect::<BTreeSet<_>>();176177	let regeneration_required =178		secret_needs_regeneration(&secret.secret, &expected_generation_data);179180	if set == expected_set && !regeneration_required {181		info!("no need to update owner list, it is already correct");182		return Ok(secret);183	}184185	let should_regenerate = if regeneration_required {186		info!("secret has its generation data changed, regeneration is required");187		true188	} else if set.difference(&expected_set).next().is_some() {189		// TODO: Remove this warning for revokable secrets.190		warn!(191			"host was removed from secret owners, but until this host rebuild, the secret will still be stored on it."192		);193		nix_go_json!(field.regenerateOnOwnerRemoved)194	} else if expected_set.difference(&set).next().is_some() {195		nix_go_json!(field.regenerateOnOwnerAdded)196	} else {197		false198	};199200	if should_regenerate {201		info!("secret needs to be regenerated");202		let generated = generate_shared(203			config,204			secret_name,205			field,206			expected_owners.to_vec(),207			expected_generation_data,208			batch,209		)210		.await?;211		Ok(generated)212	} else {213		drop(batch);214		let identity_holder = if !prefer_identities.is_empty() {215			prefer_identities216				.iter()217				.find(|i| original_set.iter().any(|s| s == *i))218		} else {219			secret.owners.first()220		};221		let Some(identity_holder) = identity_holder else {222			bail!("no available holder found");223		};224225		for (part_name, part) in secret.secret.parts.iter_mut() {226			let _span = info_span!("part reencryption", part_name);227			if !part.raw.encrypted {228				continue;229			}230			let host = config.host(identity_holder).await?;231			let encrypted = host232				.reencrypt(part.raw.clone(), expected_owners.to_vec())233				.await?;234			part.raw = encrypted;235		}236237		secret.owners = expected_owners.to_vec();238		Ok(secret)239	}240}241242#[derive(Deserialize)]243#[serde(rename_all = "camelCase")]244enum GeneratorKind {245	Impure,246	Pure,247}248249async fn generate_pure(250	_config: &Config,251	_display_name: &str,252	_secret: Value,253	_default_generator: Value,254	_owners: &[String],255) -> Result<FleetSecret> {256	bail!("pure generators are broken for now")257}258async fn generate_impure(259	config: &Config,260	_display_name: &str,261	secret: Value,262	default_generator: Value,263	expected_owners: &[String],264	expected_generation_data: serde_json::Value,265	batch: Option<NixBuildBatch>,266) -> Result<FleetSecret> {267	let generator = nix_go!(secret.generator);268	let on: Option<String> = nix_go_json!(default_generator.impureOn);269270	let nixpkgs = &config.nixpkgs;271272	let host = if let Some(on) = &on {273		config.host(on).await?274	} else {275		config.local_host()276	};277	let on_pkgs = host.pkgs().await?;278	let mk_secret_generators = nix_go!(on_pkgs.mkSecretGenerators);279280	let mut recipients = Vec::new();281	for owner in expected_owners {282		let key = config.key(owner).await?;283		recipients.push(key);284	}285	let generators = nix_go!(mk_secret_generators(Obj { recipients }));286	let pkgs_and_generators = nix_go!(on_pkgs + generators);287288	let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));289290	let generator = nix_go!(call_package(generator)(Obj {}));291292	let generator = generator.build_maybe_batch(batch).await?;293	let generator = generator294		.get("out")295		.ok_or_else(|| anyhow!("missing generateImpure out"))?;296	let generator = host.remote_derivation(generator).await?;297298	let out_parent = host.mktemp_dir().await?;299	let out = format!("{out_parent}/out");300301	let mut r#gen = host.cmd(generator).await?;302	r#gen.env("out", &out);303	if on.is_none() {304		// This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.305		let project_path: String = config306			.directory307			.clone()308			.into_os_string()309			.into_string()310			.map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;311		r#gen.env("FLEET_PROJECT", project_path);312	}313	r#gen.run().await.context("impure generator")?;314315	{316		let marker = host.read_file_text(format!("{out}/marker")).await?;317		ensure!(marker == "SUCCESS", "generation not succeeded");318	}319320	let mut parts = BTreeMap::new();321	for part in host.read_dir(&out).await? {322		if part == "created_at" || part == "expires_at" || part == "marker" {323			continue;324		}325		let contents: SecretData = host326			.read_file_text(format!("{out}/{part}"))327			.await?328			.parse()329			.map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;330		parts.insert(part.to_owned(), FleetSecretPart { raw: contents });331	}332333	let created_at = host.read_file_value(format!("{out}/created_at")).await?;334	let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();335336	Ok(FleetSecret {337		created_at,338		expires_at,339		parts,340		generation_data: expected_generation_data,341	})342}343async fn generate(344	config: &Config,345	display_name: &str,346	secret: Value,347	expected_owners: &[String],348	expected_generation_data: serde_json::Value,349	batch: Option<NixBuildBatch>,350) -> Result<FleetSecret> {351	let generator = nix_go!(secret.generator);352	// Can't properly check on nix module system level353	{354		let gen_ty = generator.type_of().await?;355		if gen_ty == "null" {356			bail!("secret has no generator defined, can't automatically generate it.");357		}358		if gen_ty == "set" {359			if !generator.has_field("__functor").await? {360				bail!("generator should be functor, got {gen_ty}");361			}362		} else if gen_ty != "lambda" {363			bail!("generator should be functor, got {gen_ty}");364		}365	}366	let nixpkgs = &config.nixpkgs;367	let default_pkgs = &config.default_pkgs;368	let default_mk_secret_generators = nix_go!(default_pkgs.mkSecretGenerators);369	// Generators provide additional information in passthru, to access370	// passthru we should call generator, but information about where this generator is supposed to build371	// is located in passthru... Thus evaluating generator on host.372	//373	// Maybe it is also possible to do some magic with __functor?374	//375	// I don't want to make modules always responsible for additional secret data anyway,376	// so it should be in derivation, and not in the secret data itself.377	let generators = nix_go!(default_mk_secret_generators(Obj {378		recipients: <Vec<String>>::new(),379	}));380	let pkgs_and_generators = nix_go!(default_pkgs + generators);381382	let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));383	let default_generator = nix_go!(call_package(generator)(Obj {}));384385	let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);386387	match kind {388		GeneratorKind::Impure => {389			generate_impure(390				config,391				display_name,392				secret,393				default_generator,394				expected_owners,395				expected_generation_data,396				batch,397			)398			.await399		}400		GeneratorKind::Pure => {401			generate_pure(402				config,403				display_name,404				secret,405				default_generator,406				expected_owners,407			)408			.await409		}410	}411}412async fn generate_shared(413	config: &Config,414	display_name: &str,415	secret: Value,416	expected_owners: Vec<String>,417	expected_generation_data: serde_json::Value,418	batch: Option<NixBuildBatch>,419) -> Result<FleetSharedSecret> {420	// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);421	Ok(FleetSharedSecret {422		secret: generate(423			config,424			display_name,425			secret,426			&expected_owners,427			expected_generation_data,428			batch,429		)430		.await?,431		owners: expected_owners,432	})433}434435async fn parse_public(436	public: Option<String>,437	public_file: Option<PathBuf>,438) -> Result<Option<SecretData>> {439	Ok(match (public, public_file) {440		(Some(v), None) => Some(SecretData {441			data: v.into(),442			encrypted: false,443		}),444		(None, Some(v)) => Some(SecretData {445			data: read(v).await?,446			encrypted: false,447		}),448		(Some(_), Some(_)) => {449			bail!("only public or public_file should be set")450		}451		(None, None) => None,452	})453}454455async fn parse_secret() -> Result<Option<Vec<u8>>> {456	let mut input = vec![];457	stdin().read_to_end(&mut input)?;458	if input.is_empty() {459		Ok(None)460	} else {461		Ok(Some(input))462	}463}464465fn parse_machines(466	initial: Vec<String>,467	machines: Option<Vec<String>>,468	mut add_machines: Vec<String>,469	mut remove_machines: Vec<String>,470) -> Result<Vec<String>> {471	if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {472		bail!("no operation");473	}474475	let initial_machines = initial.clone();476	let mut target_machines = initial;477	info!("Currently encrypted for {initial_machines:?}");478479	// ensure!(machines.is_some() || !add_machines.is_empty() || )480	if let Some(machines) = machines {481		ensure!(482			add_machines.is_empty() && remove_machines.is_empty(),483			"can't combine --machines and --add-machines/--remove-machines"484		);485		let target = initial_machines.iter().collect::<HashSet<_>>();486		let source = machines.iter().collect::<HashSet<_>>();487		for removed in target.difference(&source) {488			remove_machines.push((*removed).clone());489		}490		for added in source.difference(&target) {491			add_machines.push((*added).clone());492		}493	}494495	for machine in &remove_machines {496		let mut removed = false;497		while let Some(pos) = target_machines.iter().position(|m| m == machine) {498			target_machines.swap_remove(pos);499			removed = true;500		}501		if !removed {502			warn!("secret is not enabled for {machine}");503		}504	}505	for machine in &add_machines {506		if target_machines.iter().any(|m| m == machine) {507			warn!("secret is already added to {machine}");508		} else {509			target_machines.push(machine.to_owned());510		}511	}512	if !remove_machines.is_empty() {513		// TODO: maybe force secret regeneration?514		// Not that useful without revokation.515		warn!(516			"secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret"517		);518	}519	Ok(target_machines)520}521impl Secret {522	pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {523		match self {524			Secret::ForceKeys => {525				for host in config.list_hosts().await? {526					if opts.should_skip(&host).await? {527						continue;528					}529					config.key(&host.name).await?;530				}531			}532			Secret::AddShared {533				mut machines,534				name,535				force,536				public,537				public_part: public_name,538				public_file,539				expires_at,540				re_add,541				part: part_name,542			} => {543				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).544545				let exists = config.has_shared(&name);546				if exists && !force && !re_add {547					bail!("secret already defined");548				}549				if re_add {550					// Fixme: use clap to limit this usage551					ensure!(!force, "--force and --readd are not compatible");552					ensure!(exists, "secret doesn't exists");553					ensure!(554						machines.is_empty(),555						"you can't use machines argument for --readd"556					);557					let shared = config.shared_secret(&name)?;558					machines = shared.owners;559				}560561				let recipients = config.recipients(machines.clone()).await?;562563				let mut parts = BTreeMap::new();564565				let mut input = vec![];566				io::stdin().read_to_end(&mut input)?;567568				if !input.is_empty() {569					let encrypted =570						encrypt_secret_data(recipients.iter().map(|r| r as &dyn Recipient), input)571							.ok_or_else(|| anyhow!("no recipients provided"))?;572					parts.insert(part_name, FleetSecretPart { raw: encrypted });573				}574575				if let Some(public) = parse_public(public, public_file).await? {576					parts.insert(public_name, FleetSecretPart { raw: public });577				}578579				config.replace_shared(580					name,581					FleetSharedSecret {582						owners: machines,583						secret: FleetSecret {584							created_at: Utc::now(),585							expires_at,586							parts,587							generation_data: serde_json::Value::Null,588						},589					},590				);591			}592			Secret::Add {593				machine,594				name,595				replace,596				merge,597				public,598				public_part: public_name,599				public_file,600				part: part_name,601			} => {602				if config.has_secret(&machine, &name) && !replace && !merge {603					bail!(604						"secret already defined.\nUse --replace to override, or --merge to add new parts to existing secret"605					);606				}607608				let mut out = if merge && !replace {609					config610						.host_secret(&machine, &name)611						.context("failed to read existing secret for --merge")?612				} else {613					FleetSecret {614						created_at: Utc::now(),615						expires_at: None,616						parts: BTreeMap::new(),617						generation_data: serde_json::Value::Null,618					}619				};620621				if let Some(secret) = parse_secret().await? {622					let recipient = config.recipient(&machine).await?;623					let encrypted = encrypt_secret_data([&recipient as &dyn Recipient], secret)624						.expect("recipient provided");625					if out626						.parts627						.insert(part_name.clone(), FleetSecretPart { raw: encrypted })628						.is_some() && !replace629					{630						bail!("part {part_name:?} is already defined");631					}632				}633634				if let Some(public) = parse_public(public, public_file).await? {635					if out636						.parts637						.insert(public_name.clone(), FleetSecretPart { raw: public })638						.is_some() && !replace639					{640						bail!("part {public_name:?} is already defined");641					}642				};643644				config.insert_secret(&machine, name, out);645			}646			#[allow(clippy::await_holding_refcell_ref)]647			Secret::Read {648				name,649				machine,650				part: part_name,651			} => {652				let secret = config.host_secret(&machine, &name)?;653				let Some(secret) = secret.parts.get(&part_name) else {654					bail!("no part {part_name} in secret {name}");655				};656				let data = if secret.raw.encrypted {657					let host = config.host(&machine).await?;658					host.decrypt(secret.raw.clone()).await?659				} else {660					secret.raw.data.clone()661				};662663				stdout().write_all(&data)?;664			}665			Secret::ReadShared {666				name,667				part: part_name,668				prefer_identities,669			} => {670				let secret = config.shared_secret(&name)?;671				let Some(part) = secret.secret.parts.get(&part_name) else {672					bail!("no part {part_name} in secret {name}");673				};674				let data = if part.raw.encrypted {675					let identity_holder = if !prefer_identities.is_empty() {676						prefer_identities677							.iter()678							.find(|i| secret.owners.iter().any(|s| s == *i))679					} else {680						secret.owners.first()681					};682					let Some(identity_holder) = identity_holder else {683						bail!("no available holder found");684					};685					let host = config.host(identity_holder).await?;686					host.decrypt(part.raw.clone()).await?687				} else {688					part.raw.data.clone()689				};690				stdout().write_all(&data)?;691			}692			Secret::UpdateShared {693				name,694				machine,695				add_machine,696				remove_machine,697				prefer_identities,698			} => {699				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).700701				let secret = config.shared_secret(&name)?;702				if secret.secret.parts.values().all(|v| !v.raw.encrypted) {703					bail!("no secret");704				}705706				let initial_machines = secret.owners.clone();707				let target_machines = parse_machines(708					initial_machines.clone(),709					machine,710					add_machine,711					remove_machine,712				)?;713714				if target_machines.is_empty() {715					info!("no machines left for secret, removing it");716					config.remove_shared(&name);717					return Ok(());718				}719720				let config_field = &config.config_field;721				let field = nix_go!(config_field.sharedSecrets[{ name }]);722				let expected_generation_data = nix_go_json!(field.expectedGenerationData);723724				let updated = maybe_regenerate_shared_secret(725					&name,726					config,727					secret,728					field,729					&target_machines,730					expected_generation_data,731					&prefer_identities,732					None,733				)734				.await?;735				config.replace_shared(name, updated);736			}737			Secret::Regenerate {738				prefer_identities,739				skip_hosts,740			} => {741				info!("checking for secrets to regenerate");742				let stored_shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();743				{744					// Generate missing shared745					let shared_batch = None;746					let _span = info_span!("shared").entered();747					let expected_shared_set = config748						.list_configured_shared()749						.await?750						.into_iter()751						.collect::<HashSet<_>>();752					for missing in expected_shared_set.difference(&stored_shared_set) {753						let config_field = &config.config_field;754						let secret = nix_go!(config_field.sharedSecrets[{ missing }]);755						let expected_generation_data: serde_json::Value =756							nix_go_json!(secret.expectedGenerationData);757						let expected_owners: Option<Vec<String>> =758							nix_go_json!(secret.expectedOwners);759						let Some(expected_owners) = expected_owners else {760							// Can't generate this missing secret, as it has no defined owners.761							continue;762						};763						info!("generating secret: {missing}");764						let shared = generate_shared(765							config,766							missing,767							secret,768							expected_owners,769							expected_generation_data,770							shared_batch.clone(),771						)772						.in_current_span()773						.await?;774						config.replace_shared(missing.to_string(), shared)775					}776				}777				if !skip_hosts {778					let hosts_batch = None;779					for host in config.list_hosts().await? {780						if opts.should_skip(&host).await? {781							continue;782						}783784						let _span = info_span!("host", host = host.name).entered();785						let expected_set = host786							.list_configured_secrets()787							.in_current_span()788							.await?789							.into_iter()790							.collect::<HashSet<_>>();791						let stored_set = config792							.list_secrets(&host.name)793							.into_iter()794							.collect::<HashSet<_>>();795						for missing in expected_set.difference(&stored_set) {796							info!("generating secret: {missing}");797							let secret = host.secret_field(missing).in_current_span().await?;798							let expected_generation_data =799								nix_go_json!(secret.expectedGenerationData);800							let generated = match generate(801								config,802								missing,803								secret,804								&[host.name.clone()],805								expected_generation_data,806								hosts_batch.clone(),807							)808							.in_current_span()809							.await810							{811								Ok(v) => v,812								Err(e) => {813									error!("{e:?}");814									continue;815								}816							};817							config.insert_secret(&host.name, missing.to_string(), generated)818						}819						for name in stored_set {820							info!("updating secret: {name}");821							let data = config.host_secret(&host.name, &name)?;822							let secret = host.secret_field(&name).in_current_span().await?;823							let expected_generation_data =824								nix_go_json!(secret.expectedGenerationData);825							if secret_needs_regeneration(&data, &expected_generation_data) {826								let generated = match generate(827									config,828									&name,829									secret,830									&[host.name.clone()],831									expected_generation_data,832									hosts_batch.clone(),833								)834								.in_current_span()835								.await836								{837									Ok(v) => v,838									Err(e) => {839										error!("{e:?}");840										continue;841									}842								};843								config.insert_secret(&host.name, name.to_string(), generated)844							}845						}846					}847				}848				let mut to_remove = Vec::new();849				for name in &stored_shared_set {850					info!("updating secret: {name}");851					let data = config.shared_secret(name)?;852					let config_field = &config.config_field;853					let expected_owners: Option<Vec<String>> =854						nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);855					let Some(expected_owners) = expected_owners else {856						warn!("secret was removed from fleet config: {name}, removing from data");857						to_remove.push(name.to_string());858						continue;859					};860861					let secret = nix_go!(config_field.sharedSecrets[{ name }]);862					let expected_generation_data = nix_go_json!(secret.expectedGenerationData);863					config.replace_shared(864						name.to_owned(),865						maybe_regenerate_shared_secret(866							name,867							config,868							data,869							secret,870							&expected_owners,871							expected_generation_data,872							&prefer_identities,873							None,874						)875						.await?,876					);877				}878				for k in to_remove {879					config.remove_shared(&k);880				}881			}882			Secret::List {} => {883				let _span = info_span!("loading secrets").entered();884				let configured = config.list_configured_shared().await?;885				#[derive(Tabled)]886				struct SecretDisplay {887					#[tabled(rename = "Name")]888					name: String,889					#[tabled(rename = "Owners")]890					owners: String,891				}892				let mut table = vec![];893				for name in configured.iter().cloned() {894					let config = config.clone();895					let expected_owners = config.shared_secret_expected_owners(&name).await?;896					let data = config.shared_secret(&name)?;897					let owners = data898						.owners899						.iter()900						.map(|o| {901							if expected_owners.contains(o) {902								o.green().to_string()903							} else {904								o.red().to_string()905							}906						})907						.collect::<Vec<_>>();908					table.push(SecretDisplay {909						owners: owners.join(", "),910						name,911					})912				}913				info!("loaded\n{}", Table::new(table).to_string())914			}915			Secret::Edit {916				name,917				machine,918				part,919				add,920			} => {921				let secret = config.host_secret(&machine, &name)?;922				if let Some(data) = secret.parts.get(&part) {923					let host = config.host(&machine).await?;924					let secret = host.decrypt(data.raw.clone()).await?;925					String::from_utf8(secret).context("secret is not utf8")?926				} else if add {927					String::new()928				} else {929					bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");930				};931			}932		}933		Ok(())934	}935}936937/*938async fn edit_temp_file(939	builder: tempfile::Builder<'_, '_>,940	r: Vec<u8>,941	header: &str,942	comment: &str,943) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {944	if !stdin().is_tty() {945		// TODO: Also try to open /dev/tty directly?946		bail!("stdin is not tty, can't open editor");947	}948949	use std::fmt::Write;950	let mut file = builder.tempfile()?;951952	let mut full_header = String::new();953	let mut had = false;954	for line in header.trim_end().lines() {955		had = true;956		writeln!(&mut full_header, "{comment}{line}")?;957	}958	if had {959		writeln!(&mut full_header, "{}", comment.trim_end())?;960	}961	writeln!(962		&mut full_header,963		"{comment}Do not touch this header! It will be removed automatically"964	)?;965966	file.write_all(full_header.as_bytes())?;967	file.write_all(&r)?;968969	let abs_path = file.into_temp_path();970	let editor = std::env::var_os("VISUAL")971		.or_else(|| std::env::var_os("EDITOR"))972		.unwrap_or_else(|| "vi".into());973	let editor_args = shlex::bytes::split(editor.as_encoded_bytes())974		.ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;975	let editor_args = editor_args976		.into_iter()977		.map(|v| {978			// Only ASCII subsequences are replaced979			unsafe { OsString::from_encoded_bytes_unchecked(v) }980		})981		.collect_vec();982	let Some((editor, args)) = editor_args.split_first() else {983		bail!("EDITOR env var has no command");984	};985	let mut command = Command::new(editor);986	command.args(args);987988	let path_arg = abs_path.canonicalize()?;989990	// TODO: Save full state, using tcget/_getmode/_setmode991	let was_raw = terminal::is_raw_mode_enabled()?;992	terminal::enable_raw_mode()?;993994	let status = command.arg(path_arg).status().await;995996	if !was_raw {997		terminal::disable_raw_mode()?;998	}9991000	let success = match status {1001		Ok(s) => s.success(),1002		Err(e) if e.kind() == io::ErrorKind::NotFound => {1003			bail!("editor not found")1004		}1005		Err(e) => bail!("editor spawn error: {e}"),1006	};10071008	let mut file = std::fs::read(&abs_path).context("read editor output")?;1009	let Some(v) = file.strip_prefix(full_header.as_bytes()) else {1010		todo!();1011	};1012	todo!();10131014	// Ok((success, abs_path))1015}1016*/
after · cmds/fleet/src/cmds/secrets/mod.rs
1use std::{2	collections::{BTreeMap, BTreeSet, HashSet},3	io::{self, Read, Write, stdin, stdout},4	path::PathBuf,5};67use age::Recipient;8use anyhow::{Context, Result, anyhow, bail, ensure};9use chrono::{DateTime, Utc};10use clap::Parser;11use fleet_base::{12	fleetdata::{FleetSecret, FleetSecretPart, FleetSharedSecret, encrypt_secret_data},13	host::Config,14	opts::FleetOpts,15};16use fleet_shared::SecretData;17use nix_eval::{NixBuildBatch, Value, nix_go, nix_go_json};18use owo_colors::OwoColorize;19use serde::Deserialize;20use tabled::{Table, Tabled};21use tokio::fs::read;22use tracing::{Instrument, error, info, info_span, warn};2324#[derive(Parser)]25pub enum Secret {26	AddManager,27	/// Force load host keys for all defined hosts28	ForceKeys,29	/// Add secret, data should be provided in stdin30	AddShared {31		/// Secret name32		name: String,33		/// Secret owners34		#[clap(long, short)]35		machines: Vec<String>,36		/// Override secret if already present37		#[clap(long)]38		force: bool,39		/// Secret public part40		#[clap(long)]41		public: Option<String>,42		/// Load public part from specified file43		#[clap(long)]44		public_file: Option<PathBuf>,4546		/// Create a notification on secret expiration47		#[clap(long)]48		expires_at: Option<DateTime<Utc>>,4950		/// Secret with this name already exists, override its value while keeping the same owners.51		#[clap(long)]52		re_add: bool,5354		/// How to name public secret part55		#[clap(long, short = 'p', default_value = "public")]56		public_part: String,57		/// How to name private secret part58		#[clap(short = 's', long, default_value = "secret")]59		part: String,60	},61	/// Add secret, data should be provided in stdin62	Add {63		/// Secret name64		name: String,65		/// Secret owner66		#[clap(short = 'm', long)]67		machine: String,68		/// Replace secret if already present69		#[clap(long)]70		replace: bool,71		/// Add new parts to existing secret72		#[clap(long)]73		merge: bool,74		/// Secret public part75		#[clap(long)]76		public: Option<String>,77		/// Load public part from specified file78		#[clap(long)]79		public_file: Option<PathBuf>,8081		/// How to name public secret part82		#[clap(short = 'p', long, default_value = "public")]83		public_part: String,84		/// How to name private secret part85		#[clap(short = 's', long, default_value = "secret")]86		part: String,87	},88	/// Read secret from remote host, requires sudo on said host89	Read {90		name: String,91		#[clap(short = 'm', long)]92		machine: String,9394		/// Which private secret part to read95		#[clap(short = 'p', long, default_value = "secret")]96		part: String,97	},98	/// Read secret from remote host, requires sudo on said host99	ReadShared {100		name: String,101		/// Which private secret part to read102		#[clap(short = 'p', long, default_value = "secret")]103		part: String,104		/// Which host should we use to decrypt, in case if reencryption is required, without105		/// regeneration106		#[clap(long)]107		prefer_identities: Vec<String>,108	},109	UpdateShared {110		name: String,111112		#[clap(short = 'm', long)]113		machine: Option<Vec<String>>,114115		#[clap(long)]116		add_machine: Vec<String>,117		#[clap(long)]118		remove_machine: Vec<String>,119120		/// Which host should we use to decrypt121		#[clap(long)]122		prefer_identities: Vec<String>,123	},124	Regenerate {125		/// Which host should we use to decrypt, in case if reencryption is required, without126		/// regeneration127		#[clap(long)]128		prefer_identities: Vec<String>,129		/// Only regenerate shared secrets130		#[clap(long)]131		skip_hosts: bool,132	},133	List {},134	Edit {135		name: String,136		#[clap(short = 'm', long)]137		machine: String,138139		#[clap(long)]140		add: bool,141142		/// Which private secret part to read143		#[clap(short = 'p', long, default_value = "secret")]144		part: String,145	},146}147148fn secret_needs_regeneration(149	secret: &FleetSecret,150	expected_generation_data: &serde_json::Value,151) -> bool {152	let data_is_expected = secret.generation_data == *expected_generation_data;153	// TODO: Leeway?154	let expired = secret155		.expires_at156		.map(|expiration| expiration < Utc::now())157		.unwrap_or(false);158	expired || !data_is_expected159}160161#[allow(clippy::too_many_arguments)]162#[tracing::instrument(skip(config, secret, field, prefer_identities, batch))]163async fn maybe_regenerate_shared_secret(164	secret_name: &str,165	config: &Config,166	mut secret: FleetSharedSecret,167	field: Value,168	expected_owners: &[String],169	expected_generation_data: serde_json::Value,170	prefer_identities: &[String],171	batch: Option<NixBuildBatch>,172) -> Result<FleetSharedSecret> {173	let original_set = secret.owners.clone();174175	let set = original_set.iter().collect::<BTreeSet<_>>();176	let expected_set = expected_owners.iter().collect::<BTreeSet<_>>();177178	let regeneration_required =179		secret_needs_regeneration(&secret.secret, &expected_generation_data);180181	if set == expected_set && !regeneration_required {182		info!("no need to update owner list, it is already correct");183		return Ok(secret);184	}185186	let should_regenerate = if regeneration_required {187		info!("secret has its generation data changed, regeneration is required");188		true189	} else if set.difference(&expected_set).next().is_some() {190		// TODO: Remove this warning for revokable secrets.191		warn!(192			"host was removed from secret owners, but until this host rebuild, the secret will still be stored on it."193		);194		nix_go_json!(field.regenerateOnOwnerRemoved)195	} else if expected_set.difference(&set).next().is_some() {196		nix_go_json!(field.regenerateOnOwnerAdded)197	} else {198		false199	};200201	if should_regenerate {202		info!("secret needs to be regenerated");203		let generated = generate_shared(204			config,205			secret_name,206			field,207			expected_owners.to_vec(),208			expected_generation_data,209			batch,210		)211		.await?;212		Ok(generated)213	} else {214		drop(batch);215		let identity_holder = if !prefer_identities.is_empty() {216			prefer_identities217				.iter()218				.find(|i| original_set.iter().any(|s| s == *i))219		} else {220			secret.owners.first()221		};222		let Some(identity_holder) = identity_holder else {223			bail!("no available holder found");224		};225226		for (part_name, part) in secret.secret.parts.iter_mut() {227			let _span = info_span!("part reencryption", part_name);228			if !part.raw.encrypted {229				continue;230			}231			let host = config.host(identity_holder).await?;232			let encrypted = host233				.reencrypt(part.raw.clone(), expected_owners.to_vec())234				.await?;235			part.raw = encrypted;236		}237238		secret.owners = expected_owners.to_vec();239		Ok(secret)240	}241}242243#[derive(Deserialize)]244#[serde(rename_all = "camelCase")]245enum GeneratorKind {246	Impure,247	Pure,248}249250async fn generate_pure(251	_config: &Config,252	_display_name: &str,253	_secret: Value,254	_default_generator: Value,255	_owners: &[String],256) -> Result<FleetSecret> {257	bail!("pure generators are broken for now")258}259async fn generate_impure(260	config: &Config,261	_display_name: &str,262	secret: Value,263	default_generator: Value,264	expected_owners: &[String],265	expected_generation_data: serde_json::Value,266	batch: Option<NixBuildBatch>,267) -> Result<FleetSecret> {268	let generator = nix_go!(secret.generator);269	let on: Option<String> = nix_go_json!(default_generator.impureOn);270271	let nixpkgs = &config.nixpkgs;272273	let host = if let Some(on) = &on {274		config.host(on).await?275	} else {276		config.local_host()277	};278	let on_pkgs = host.pkgs().await?;279	let mk_secret_generators = nix_go!(on_pkgs.mkSecretGenerators);280281	let mut recipients = Vec::new();282	for owner in expected_owners {283		let key = config.key(owner).await?;284		recipients.push(key);285	}286	let generators = nix_go!(mk_secret_generators(Obj { recipients }));287	let pkgs_and_generators = nix_go!(on_pkgs + generators);288289	let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));290291	let generator = nix_go!(call_package(generator)(Obj {}));292293	let generator = generator.build_maybe_batch(batch).await?;294	let generator = generator295		.get("out")296		.ok_or_else(|| anyhow!("missing generateImpure out"))?;297	let generator = host.remote_derivation(generator).await?;298299	let out_parent = host.mktemp_dir().await?;300	let out = format!("{out_parent}/out");301302	let mut r#gen = host.cmd(generator).await?;303	r#gen.env("out", &out);304	if on.is_none() {305		// This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.306		let project_path: String = config307			.directory308			.clone()309			.into_os_string()310			.into_string()311			.map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;312		r#gen.env("FLEET_PROJECT", project_path);313	}314	r#gen.run().await.context("impure generator")?;315316	{317		let marker = host.read_file_text(format!("{out}/marker")).await?;318		ensure!(marker == "SUCCESS", "generation not succeeded");319	}320321	let mut parts = BTreeMap::new();322	for part in host.read_dir(&out).await? {323		if part == "created_at" || part == "expires_at" || part == "marker" {324			continue;325		}326		let contents: SecretData = host327			.read_file_text(format!("{out}/{part}"))328			.await?329			.parse()330			.map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;331		parts.insert(part.to_owned(), FleetSecretPart { raw: contents });332	}333334	let created_at = host.read_file_value(format!("{out}/created_at")).await?;335	let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();336337	Ok(FleetSecret {338		created_at,339		expires_at,340		parts,341		generation_data: expected_generation_data,342	})343}344async fn generate(345	config: &Config,346	display_name: &str,347	secret: Value,348	expected_owners: &[String],349	expected_generation_data: serde_json::Value,350	batch: Option<NixBuildBatch>,351) -> Result<FleetSecret> {352	let generator = nix_go!(secret.generator);353	// Can't properly check on nix module system level354	{355		let gen_ty = generator.type_of().await?;356		if gen_ty == "null" {357			bail!("secret has no generator defined, can't automatically generate it.");358		}359		if gen_ty == "set" {360			if !generator.has_field("__functor").await? {361				bail!("generator should be functor, got {gen_ty}");362			}363		} else if gen_ty != "lambda" {364			bail!("generator should be functor, got {gen_ty}");365		}366	}367	let nixpkgs = &config.nixpkgs;368	let default_pkgs = &config.default_pkgs;369	let default_mk_secret_generators = nix_go!(default_pkgs.mkSecretGenerators);370	// Generators provide additional information in passthru, to access371	// passthru we should call generator, but information about where this generator is supposed to build372	// is located in passthru... Thus evaluating generator on host.373	//374	// Maybe it is also possible to do some magic with __functor?375	//376	// I don't want to make modules always responsible for additional secret data anyway,377	// so it should be in derivation, and not in the secret data itself.378	let generators = nix_go!(default_mk_secret_generators(Obj {379		recipients: <Vec<String>>::new(),380	}));381	let pkgs_and_generators = nix_go!(default_pkgs + generators);382383	let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));384	let default_generator = nix_go!(call_package(generator)(Obj {}));385386	let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);387388	match kind {389		GeneratorKind::Impure => {390			generate_impure(391				config,392				display_name,393				secret,394				default_generator,395				expected_owners,396				expected_generation_data,397				batch,398			)399			.await400		}401		GeneratorKind::Pure => {402			generate_pure(403				config,404				display_name,405				secret,406				default_generator,407				expected_owners,408			)409			.await410		}411	}412}413async fn generate_shared(414	config: &Config,415	display_name: &str,416	secret: Value,417	expected_owners: Vec<String>,418	expected_generation_data: serde_json::Value,419	batch: Option<NixBuildBatch>,420) -> Result<FleetSharedSecret> {421	// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);422	Ok(FleetSharedSecret {423		secret: generate(424			config,425			display_name,426			secret,427			&expected_owners,428			expected_generation_data,429			batch,430		)431		.await?,432		owners: expected_owners,433	})434}435436async fn parse_public(437	public: Option<String>,438	public_file: Option<PathBuf>,439) -> Result<Option<SecretData>> {440	Ok(match (public, public_file) {441		(Some(v), None) => Some(SecretData {442			data: v.into(),443			encrypted: false,444		}),445		(None, Some(v)) => Some(SecretData {446			data: read(v).await?,447			encrypted: false,448		}),449		(Some(_), Some(_)) => {450			bail!("only public or public_file should be set")451		}452		(None, None) => None,453	})454}455456async fn parse_secret() -> Result<Option<Vec<u8>>> {457	let mut input = vec![];458	stdin().read_to_end(&mut input)?;459	if input.is_empty() {460		Ok(None)461	} else {462		Ok(Some(input))463	}464}465466fn parse_machines(467	initial: Vec<String>,468	machines: Option<Vec<String>>,469	mut add_machines: Vec<String>,470	mut remove_machines: Vec<String>,471) -> Result<Vec<String>> {472	if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {473		bail!("no operation");474	}475476	let initial_machines = initial.clone();477	let mut target_machines = initial;478	info!("Currently encrypted for {initial_machines:?}");479480	// ensure!(machines.is_some() || !add_machines.is_empty() || )481	if let Some(machines) = machines {482		ensure!(483			add_machines.is_empty() && remove_machines.is_empty(),484			"can't combine --machines and --add-machines/--remove-machines"485		);486		let target = initial_machines.iter().collect::<HashSet<_>>();487		let source = machines.iter().collect::<HashSet<_>>();488		for removed in target.difference(&source) {489			remove_machines.push((*removed).clone());490		}491		for added in source.difference(&target) {492			add_machines.push((*added).clone());493		}494	}495496	for machine in &remove_machines {497		let mut removed = false;498		while let Some(pos) = target_machines.iter().position(|m| m == machine) {499			target_machines.swap_remove(pos);500			removed = true;501		}502		if !removed {503			warn!("secret is not enabled for {machine}");504		}505	}506	for machine in &add_machines {507		if target_machines.iter().any(|m| m == machine) {508			warn!("secret is already added to {machine}");509		} else {510			target_machines.push(machine.to_owned());511		}512	}513	if !remove_machines.is_empty() {514		// TODO: maybe force secret regeneration?515		// Not that useful without revokation.516		warn!(517			"secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret"518		);519	}520	Ok(target_machines)521}522impl Secret {523	pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {524		match self {525			Secret::AddManager => {526				todo!("part of fleet-pusher")527			}528			Secret::ForceKeys => {529				for host in config.list_hosts().await? {530					if opts.should_skip(&host).await? {531						continue;532					}533					config.key(&host.name).await?;534				}535			}536			Secret::AddShared {537				mut machines,538				name,539				force,540				public,541				public_part: public_name,542				public_file,543				expires_at,544				re_add,545				part: part_name,546			} => {547				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).548549				let exists = config.has_shared(&name);550				if exists && !force && !re_add {551					bail!("secret already defined");552				}553				if re_add {554					// Fixme: use clap to limit this usage555					ensure!(!force, "--force and --readd are not compatible");556					ensure!(exists, "secret doesn't exists");557					ensure!(558						machines.is_empty(),559						"you can't use machines argument for --readd"560					);561					let shared = config.shared_secret(&name)?;562					machines = shared.owners;563				}564565				let recipients = config.recipients(machines.clone()).await?;566567				let mut parts = BTreeMap::new();568569				let mut input = vec![];570				io::stdin().read_to_end(&mut input)?;571572				if !input.is_empty() {573					let encrypted =574						encrypt_secret_data(recipients.iter().map(|r| r as &dyn Recipient), input)575							.ok_or_else(|| anyhow!("no recipients provided"))?;576					parts.insert(part_name, FleetSecretPart { raw: encrypted });577				}578579				if let Some(public) = parse_public(public, public_file).await? {580					parts.insert(public_name, FleetSecretPart { raw: public });581				}582583				config.replace_shared(584					name,585					FleetSharedSecret {586						owners: machines,587						secret: FleetSecret {588							created_at: Utc::now(),589							expires_at,590							parts,591							generation_data: serde_json::Value::Null,592						},593					},594				);595			}596			Secret::Add {597				machine,598				name,599				replace,600				merge,601				public,602				public_part: public_name,603				public_file,604				part: part_name,605			} => {606				if config.has_secret(&machine, &name) && !replace && !merge {607					bail!(608						"secret already defined.\nUse --replace to override, or --merge to add new parts to existing secret"609					);610				}611612				let mut out = if merge && !replace {613					config614						.host_secret(&machine, &name)615						.context("failed to read existing secret for --merge")?616				} else {617					FleetSecret {618						created_at: Utc::now(),619						expires_at: None,620						parts: BTreeMap::new(),621						generation_data: serde_json::Value::Null,622					}623				};624625				if let Some(secret) = parse_secret().await? {626					let recipient = config.recipient(&machine).await?;627					let encrypted = encrypt_secret_data([&recipient as &dyn Recipient], secret)628						.expect("recipient provided");629					if out630						.parts631						.insert(part_name.clone(), FleetSecretPart { raw: encrypted })632						.is_some() && !replace633					{634						bail!("part {part_name:?} is already defined");635					}636				}637638				if let Some(public) = parse_public(public, public_file).await? {639					if out640						.parts641						.insert(public_name.clone(), FleetSecretPart { raw: public })642						.is_some() && !replace643					{644						bail!("part {public_name:?} is already defined");645					}646				};647648				config.insert_secret(&machine, name, out);649			}650			#[allow(clippy::await_holding_refcell_ref)]651			Secret::Read {652				name,653				machine,654				part: part_name,655			} => {656				let secret = config.host_secret(&machine, &name)?;657				let Some(secret) = secret.parts.get(&part_name) else {658					bail!("no part {part_name} in secret {name}");659				};660				let data = if secret.raw.encrypted {661					let host = config.host(&machine).await?;662					host.decrypt(secret.raw.clone()).await?663				} else {664					secret.raw.data.clone()665				};666667				stdout().write_all(&data)?;668			}669			Secret::ReadShared {670				name,671				part: part_name,672				prefer_identities,673			} => {674				let secret = config.shared_secret(&name)?;675				let Some(part) = secret.secret.parts.get(&part_name) else {676					bail!("no part {part_name} in secret {name}");677				};678				let data = if part.raw.encrypted {679					let identity_holder = if !prefer_identities.is_empty() {680						prefer_identities681							.iter()682							.find(|i| secret.owners.iter().any(|s| s == *i))683					} else {684						secret.owners.first()685					};686					let Some(identity_holder) = identity_holder else {687						bail!("no available holder found");688					};689					let host = config.host(identity_holder).await?;690					host.decrypt(part.raw.clone()).await?691				} else {692					part.raw.data.clone()693				};694				stdout().write_all(&data)?;695			}696			Secret::UpdateShared {697				name,698				machine,699				add_machine,700				remove_machine,701				prefer_identities,702			} => {703				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).704705				let secret = config.shared_secret(&name)?;706				if secret.secret.parts.values().all(|v| !v.raw.encrypted) {707					bail!("no secret");708				}709710				let initial_machines = secret.owners.clone();711				let target_machines = parse_machines(712					initial_machines.clone(),713					machine,714					add_machine,715					remove_machine,716				)?;717718				if target_machines.is_empty() {719					info!("no machines left for secret, removing it");720					config.remove_shared(&name);721					return Ok(());722				}723724				let config_field = &config.config_field;725				let field = nix_go!(config_field.sharedSecrets[{ name }]);726				let expected_generation_data = nix_go_json!(field.expectedGenerationData);727728				let updated = maybe_regenerate_shared_secret(729					&name,730					config,731					secret,732					field,733					&target_machines,734					expected_generation_data,735					&prefer_identities,736					None,737				)738				.await?;739				config.replace_shared(name, updated);740			}741			Secret::Regenerate {742				prefer_identities,743				skip_hosts,744			} => {745				info!("checking for secrets to regenerate");746				let stored_shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();747				{748					// Generate missing shared749					let shared_batch = None;750					let _span = info_span!("shared").entered();751					let expected_shared_set = config752						.list_configured_shared()753						.await?754						.into_iter()755						.collect::<HashSet<_>>();756					for missing in expected_shared_set.difference(&stored_shared_set) {757						let config_field = &config.config_field;758						let secret = nix_go!(config_field.sharedSecrets[{ missing }]);759						let expected_generation_data: serde_json::Value =760							nix_go_json!(secret.expectedGenerationData);761						let expected_owners: Option<Vec<String>> =762							nix_go_json!(secret.expectedOwners);763						let Some(expected_owners) = expected_owners else {764							// Can't generate this missing secret, as it has no defined owners.765							continue;766						};767						info!("generating secret: {missing}");768						let shared = generate_shared(769							config,770							missing,771							secret,772							expected_owners,773							expected_generation_data,774							shared_batch.clone(),775						)776						.in_current_span()777						.await?;778						config.replace_shared(missing.to_string(), shared)779					}780				}781				if !skip_hosts {782					let hosts_batch = None;783					for host in config.list_hosts().await? {784						if opts.should_skip(&host).await? {785							continue;786						}787788						let _span = info_span!("host", host = host.name).entered();789						let expected_set = host790							.list_configured_secrets()791							.in_current_span()792							.await?793							.into_iter()794							.collect::<HashSet<_>>();795						let stored_set = config796							.list_secrets(&host.name)797							.into_iter()798							.collect::<HashSet<_>>();799						for missing in expected_set.difference(&stored_set) {800							info!("generating secret: {missing}");801							let secret = host.secret_field(missing).in_current_span().await?;802							let expected_generation_data =803								nix_go_json!(secret.expectedGenerationData);804							let generated = match generate(805								config,806								missing,807								secret,808								&[host.name.clone()],809								expected_generation_data,810								hosts_batch.clone(),811							)812							.in_current_span()813							.await814							{815								Ok(v) => v,816								Err(e) => {817									error!("{e:?}");818									continue;819								}820							};821							config.insert_secret(&host.name, missing.to_string(), generated)822						}823						for name in stored_set {824							info!("updating secret: {name}");825							let data = config.host_secret(&host.name, &name)?;826							let secret = host.secret_field(&name).in_current_span().await?;827							let expected_generation_data =828								nix_go_json!(secret.expectedGenerationData);829							if secret_needs_regeneration(&data, &expected_generation_data) {830								let generated = match generate(831									config,832									&name,833									secret,834									&[host.name.clone()],835									expected_generation_data,836									hosts_batch.clone(),837								)838								.in_current_span()839								.await840								{841									Ok(v) => v,842									Err(e) => {843										error!("{e:?}");844										continue;845									}846								};847								config.insert_secret(&host.name, name.to_string(), generated)848							}849						}850					}851				}852				let mut to_remove = Vec::new();853				for name in &stored_shared_set {854					info!("updating secret: {name}");855					let data = config.shared_secret(name)?;856					let config_field = &config.config_field;857					let expected_owners: Option<Vec<String>> =858						nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);859					let Some(expected_owners) = expected_owners else {860						warn!("secret was removed from fleet config: {name}, removing from data");861						to_remove.push(name.to_string());862						continue;863					};864865					let secret = nix_go!(config_field.sharedSecrets[{ name }]);866					let expected_generation_data = nix_go_json!(secret.expectedGenerationData);867					config.replace_shared(868						name.to_owned(),869						maybe_regenerate_shared_secret(870							name,871							config,872							data,873							secret,874							&expected_owners,875							expected_generation_data,876							&prefer_identities,877							None,878						)879						.await?,880					);881				}882				for k in to_remove {883					config.remove_shared(&k);884				}885			}886			Secret::List {} => {887				let _span = info_span!("loading secrets").entered();888				let configured = config.list_configured_shared().await?;889				#[derive(Tabled)]890				struct SecretDisplay {891					#[tabled(rename = "Name")]892					name: String,893					#[tabled(rename = "Owners")]894					owners: String,895				}896				let mut table = vec![];897				for name in configured.iter().cloned() {898					let config = config.clone();899					let expected_owners = config.shared_secret_expected_owners(&name).await?;900					let data = config.shared_secret(&name)?;901					let owners = data902						.owners903						.iter()904						.map(|o| {905							if expected_owners.contains(o) {906								o.green().to_string()907							} else {908								o.red().to_string()909							}910						})911						.collect::<Vec<_>>();912					table.push(SecretDisplay {913						owners: owners.join(", "),914						name,915					})916				}917				info!("loaded\n{}", Table::new(table).to_string())918			}919			Secret::Edit {920				name,921				machine,922				part,923				add,924			} => {925				let secret = config.host_secret(&machine, &name)?;926				if let Some(data) = secret.parts.get(&part) {927					let host = config.host(&machine).await?;928					let secret = host.decrypt(data.raw.clone()).await?;929					String::from_utf8(secret).context("secret is not utf8")?930				} else if add {931					String::new()932				} else {933					bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");934				};935			}936		}937		Ok(())938	}939}940941/*942async fn edit_temp_file(943	builder: tempfile::Builder<'_, '_>,944	r: Vec<u8>,945	header: &str,946	comment: &str,947) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {948	if !stdin().is_tty() {949		// TODO: Also try to open /dev/tty directly?950		bail!("stdin is not tty, can't open editor");951	}952953	use std::fmt::Write;954	let mut file = builder.tempfile()?;955956	let mut full_header = String::new();957	let mut had = false;958	for line in header.trim_end().lines() {959		had = true;960		writeln!(&mut full_header, "{comment}{line}")?;961	}962	if had {963		writeln!(&mut full_header, "{}", comment.trim_end())?;964	}965	writeln!(966		&mut full_header,967		"{comment}Do not touch this header! It will be removed automatically"968	)?;969970	file.write_all(full_header.as_bytes())?;971	file.write_all(&r)?;972973	let abs_path = file.into_temp_path();974	let editor = std::env::var_os("VISUAL")975		.or_else(|| std::env::var_os("EDITOR"))976		.unwrap_or_else(|| "vi".into());977	let editor_args = shlex::bytes::split(editor.as_encoded_bytes())978		.ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;979	let editor_args = editor_args980		.into_iter()981		.map(|v| {982			// Only ASCII subsequences are replaced983			unsafe { OsString::from_encoded_bytes_unchecked(v) }984		})985		.collect_vec();986	let Some((editor, args)) = editor_args.split_first() else {987		bail!("EDITOR env var has no command");988	};989	let mut command = Command::new(editor);990	command.args(args);991992	let path_arg = abs_path.canonicalize()?;993994	// TODO: Save full state, using tcget/_getmode/_setmode995	let was_raw = terminal::is_raw_mode_enabled()?;996	terminal::enable_raw_mode()?;997998	let status = command.arg(path_arg).status().await;9991000	if !was_raw {1001		terminal::disable_raw_mode()?;1002	}10031004	let success = match status {1005		Ok(s) => s.success(),1006		Err(e) if e.kind() == io::ErrorKind::NotFound => {1007			bail!("editor not found")1008		}1009		Err(e) => bail!("editor spawn error: {e}"),1010	};10111012	let mut file = std::fs::read(&abs_path).context("read editor output")?;1013	let Some(v) = file.strip_prefix(full_header.as_bytes()) else {1014		todo!();1015	};1016	todo!();10171018	// Ok((success, abs_path))1019}1020*/
modifiedcrates/fleet-base/src/fleetdata.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/fleetdata.rs
+++ b/crates/fleet-base/src/fleetdata.rs
@@ -53,12 +53,22 @@
 
 #[derive(Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
+pub struct ManagerKey {
+	pub name: String,
+	pub key: String,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
 pub struct FleetData {
 	pub version: FleetDataVersion,
 	#[serde(default = "generate_gc_prefix")]
 	pub gc_root_prefix: String,
 
 	#[serde(default)]
+	pub manager_keys: Vec<ManagerKey>,
+
+	#[serde(default)]
 	pub hosts: BTreeMap<String, HostData>,
 	#[serde(default)]
 	#[serde(skip_serializing_if = "BTreeMap::is_empty")]
modifiedflake.lockdiffbeforeafterboth
--- a/flake.lock
+++ b/flake.lock
@@ -2,11 +2,11 @@
   "nodes": {
     "crane": {
       "locked": {
-        "lastModified": 1750266157,
-        "narHash": "sha256-tL42YoNg9y30u7zAqtoGDNdTyXTi8EALDeCB13FtbQA=",
+        "lastModified": 1753316655,
+        "narHash": "sha256-tzWa2kmTEN69OEMhxFy+J2oWSvZP5QhEgXp3TROOzl0=",
         "owner": "ipetkov",
         "repo": "crane",
-        "rev": "e37c943371b73ed87faf33f7583860f81f1d5a48",
+        "rev": "f35a3372d070c9e9ccb63ba7ce347f0634ddf3d2",
         "type": "github"
       },
       "original": {
@@ -22,11 +22,11 @@
         ]
       },
       "locked": {
-        "lastModified": 1749398372,
-        "narHash": "sha256-tYBdgS56eXYaWVW3fsnPQ/nFlgWi/Z2Ymhyu21zVM98=",
+        "lastModified": 1753121425,
+        "narHash": "sha256-TVcTNvOeWWk1DXljFxVRp+E0tzG1LhrVjOGGoMHuXio=",
         "owner": "hercules-ci",
         "repo": "flake-parts",
-        "rev": "9305fe4e5c2a6fcf5ba6a3ff155720fbe4076569",
+        "rev": "644e0fc48951a860279da645ba77fe4a6e814c5e",
         "type": "github"
       },
       "original": {
@@ -37,11 +37,11 @@
     },
     "nixpkgs": {
       "locked": {
-        "lastModified": 1750895632,
-        "narHash": "sha256-EPZWiRmaSTxoBArK5dQyRlSNVLXiBt2hmsYIPgMf3zk=",
+        "lastModified": 1753320130,
+        "narHash": "sha256-KCuv6iYQ0XTVAEJvDLIsk99CJm7fuqIE0/KknyeYPtM=",
         "owner": "nixos",
         "repo": "nixpkgs",
-        "rev": "6ac57ce7fee0d80226095a57ccb7519855ad7c5e",
+        "rev": "788cc7374af486168b8aab6ca49e316c03508a86",
         "type": "github"
       },
       "original": {
@@ -68,11 +68,11 @@
         ]
       },
       "locked": {
-        "lastModified": 1750819193,
-        "narHash": "sha256-XvkupGPZqD54HuKhN/2WhbKjAHeTl1UEnWspzUzRFfA=",
+        "lastModified": 1753238793,
+        "narHash": "sha256-jmQeEpgX+++MEgrcikcwoSiI7vDZWLP0gci7XiWb9uQ=",
         "owner": "oxalica",
         "repo": "rust-overlay",
-        "rev": "1ba3b9c59b68a4b00156827ad46393127b51b808",
+        "rev": "0ad7ab4ca8e83febf147197e65c006dff60623ab",
         "type": "github"
       },
       "original": {
@@ -103,11 +103,11 @@
         ]
       },
       "locked": {
-        "lastModified": 1749194973,
-        "narHash": "sha256-eEy8cuS0mZ2j/r/FE0/LYBSBcIs/MKOIVakwHVuqTfk=",
+        "lastModified": 1753006367,
+        "narHash": "sha256-tzbhc4XttkyEhswByk5R38l+ztN9UDbnj0cTcP6Hp9A=",
         "owner": "numtide",
         "repo": "treefmt-nix",
-        "rev": "a05be418a1af1198ca0f63facb13c985db4cb3c5",
+        "rev": "421b56313c65a0815a52b424777f55acf0b56ddf",
         "type": "github"
       },
       "original": {
modifiedmodules/secrets-data.nixdiffbeforeafterboth
--- a/modules/secrets-data.nix
+++ b/modules/secrets-data.nix
@@ -94,12 +94,28 @@
     };
     config = { };
   };
+  managerKey = {
+    options = {
+      name = mkOption {
+        type = str;
+        description = "Who does this manager key belongs to.";
+      };
+      key = mkOption {
+        type = str;
+        description = "Age-compatible key";
+      };
+    };
+    config = {};
+  };
 in
 {
   options.data = mkDataOption (
     { config, ... }:
     {
       options = {
+        managerKeys = mkOption {
+          type = listOf (submodule managerKey);
+        };
         sharedSecrets = mkOption {
           type = attrsOf (submodule sharedSecretData);
           default = { };
modifiedrust-toolchain.tomldiffbeforeafterboth
--- a/rust-toolchain.toml
+++ b/rust-toolchain.toml
@@ -1,3 +1,3 @@
 [toolchain]
-channel = "1.86.0"
+channel = "nightly-2025-06-10"
 components = ["rustfmt", "clippy", "rust-analyzer", "rust-src"]