git.delta.rocks / jrsonnet / refs/commits / d8f3e18873ec

difftreelog

refactor move parts to secret generator derivation

rquyvuskYaroslav Bolyukin2025-11-05parent: #488d19a.patch.diff
in: trunk

5 files changed

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 anyhow::{Context, Result, anyhow, bail, ensure};8use chrono::{DateTime, Utc};9use clap::Parser;10use fleet_base::{11	fleetdata::{12		FleetHostSecret, FleetSecretData, FleetSecretPart, FleetSharedSecret, encrypt_secret_data,13	},14	host::Config,15	opts::FleetOpts,16	secret::{Expectations, RegenerationReason, SharedSecretDefinition, secret_needs_regeneration},17};18use fleet_shared::SecretData;19use nix_eval::{NixType, Value, nix_go, nix_go_json};20use owo_colors::OwoColorize;21use serde::Deserialize;22use tabled::{Table, Tabled};23use tokio::{fs::read, task::spawn_blocking};24use tracing::{Instrument, error, info, info_span, warn};2526#[derive(Parser)]27pub enum Secret {28	AddManager,29	/// Force load host keys for all defined hosts30	ForceKeys,31	/// Add secret, data should be provided in stdin32	AddShared {33		/// Secret name34		name: String,35		/// Secret owners36		#[clap(long, short)]37		machines: Vec<String>,38		/// Override secret if already present39		#[clap(long)]40		force: bool,41		/// Secret public part42		#[clap(long)]43		public: Option<String>,44		/// Load public part from specified file45		#[clap(long)]46		public_file: Option<PathBuf>,4748		/// Create a notification on secret expiration49		#[clap(long)]50		expires_at: Option<DateTime<Utc>>,5152		/// Secret with this name already exists, override its value while keeping the same owners.53		#[clap(long)]54		re_add: bool,5556		/// How to name public secret part57		#[clap(long, short = 'p', default_value = "public")]58		public_part: String,59		/// How to name private secret part60		#[clap(short = 's', long, default_value = "secret")]61		part: String,62	},63	/// Add secret, data should be provided in stdin64	Add {65		/// Secret name66		name: String,67		/// Secret owner68		#[clap(short = 'm', long)]69		machine: String,70		/// Replace secret if already present71		#[clap(long)]72		replace: bool,73		/// Add new parts to existing secret74		#[clap(long)]75		merge: bool,76		/// Secret public part77		#[clap(long)]78		public: Option<String>,79		/// Load public part from specified file80		#[clap(long)]81		public_file: Option<PathBuf>,8283		/// How to name public secret part84		#[clap(short = 'p', long, default_value = "public")]85		public_part: String,86		/// How to name private secret part87		#[clap(short = 's', long, default_value = "secret")]88		part: String,89	},90	/// Read secret from remote host, requires sudo on said host91	Read {92		name: String,93		#[clap(short = 'm', long)]94		machine: String,9596		/// Which private secret part to read97		#[clap(short = 'p', long, default_value = "secret")]98		part: String,99	},100	/// Read secret from remote host, requires sudo on said host101	ReadShared {102		name: String,103		/// Which private secret part to read104		#[clap(short = 'p', long, default_value = "secret")]105		part: String,106		/// Which host should we use to decrypt, in case if reencryption is required, without107		/// regeneration108		#[clap(long)]109		prefer_identities: Vec<String>,110	},111	UpdateShared {112		name: String,113114		#[clap(short = 'm', long)]115		machine: Option<Vec<String>>,116117		#[clap(long)]118		add_machine: Vec<String>,119		#[clap(long)]120		remove_machine: Vec<String>,121122		/// Which host should we use to decrypt123		#[clap(long)]124		prefer_identities: Vec<String>,125	},126	Regenerate {127		/// Which host should we use to decrypt, in case if reencryption is required, without128		/// regeneration129		#[clap(long)]130		prefer_identities: Vec<String>,131		/// Only regenerate shared secrets132		#[clap(long)]133		skip_hosts: bool,134	},135	List {},136	Edit {137		name: String,138		#[clap(short = 'm', long)]139		machine: String,140141		#[clap(long)]142		add: bool,143144		/// Which private secret part to read145		#[clap(short = 'p', long, default_value = "secret")]146		part: String,147	},148}149150#[allow(clippy::too_many_arguments)]151#[tracing::instrument(skip(config, secret, definition, prefer_identities))]152async fn maybe_regenerate_shared_secret(153	secret_name: &str,154	config: &Config,155	mut secret: FleetSharedSecret,156	definition: SharedSecretDefinition,157	prefer_identities: &[String],158	expectations: &Expectations,159) -> Result<FleetSharedSecret> {160	let reason = secret_needs_regeneration(&secret.secret, &secret.owners, expectations);161	let value = definition.inner();162163	let (should_reencrypt, reason) = match reason {164		Some(RegenerationReason::OwnersAdded(_)) => {165			// Secret always needs to be reencrypted for new owners to be able to read it166			(167				true,168				if nix_go_json!(value.regenerateOnOwnerAdded) {169					reason170				} else {171					None172				},173			)174		}175		Some(RegenerationReason::OwnersRemoved(_)) => {176			// No need to reencrypt, we can just leave stanzas in place.177			if nix_go_json!(value.regenerateOnOwnerRemoved) {178				(true, reason)179			} else {180				(false, None)181			}182		}183		Some(_) => (true, reason),184		None => (false, None),185	};186187	if let Some(reason) = reason {188		info!("secret needs to be regenerated: {reason}");189		let generated = generate_shared(config, secret_name, definition, expectations).await?;190		Ok(generated)191	} else if should_reencrypt {192		info!("secret needs to be reencrypted");193		let identity_holder = if !prefer_identities.is_empty() {194			prefer_identities195				.iter()196				.find(|i| secret.owners.iter().any(|s| s == *i))197		} else {198			secret.owners.first()199		};200		let Some(identity_holder) = identity_holder else {201			bail!("no available holder found");202		};203204		for (part_name, part) in secret.secret.parts.iter_mut() {205			let _span = info_span!("part reencryption", part_name);206			if !part.raw.encrypted {207				continue;208			}209			let host = config.host(identity_holder).await?;210			let encrypted = host211				.reencrypt(212					part.raw.clone(),213					expectations.owners.iter().cloned().collect(),214				)215				.await?;216			part.raw = encrypted;217		}218		secret.owners = expectations.owners.clone();219		Ok(secret)220	} else {221		Ok(secret)222	}223}224225#[derive(Deserialize)]226#[serde(rename_all = "camelCase")]227enum GeneratorKind {228	Impure,229	Pure,230}231232async fn generate_pure(233	_config: &Config,234	_display_name: &str,235	_secret: Value,236	_default_generator: Value,237	_expectations: &Expectations,238) -> Result<FleetSecretData> {239	bail!("pure generators are broken for now")240}241async fn generate_impure(242	config: &Config,243	_display_name: &str,244	secret: Value,245	default_generator: Value,246	expectations: &Expectations,247) -> Result<FleetSecretData> {248	let generator = nix_go!(secret.generator);249	let on: Option<String> = nix_go_json!(default_generator.impureOn);250251	let nixpkgs = &config.nixpkgs;252253	let host = if let Some(on) = &on {254		config.host(on).await?255	} else {256		config.local_host()257	};258	let on_pkgs = host.pkgs().await?;259	let mk_secret_generators = nix_go!(on_pkgs.mkSecretGenerators);260261	let mut recipients = Vec::new();262	for owner in &expectations.owners {263		let key = config.key(owner).await?;264		recipients.push(key);265	}266	let generators = nix_go!(mk_secret_generators(Obj { recipients }));267	let pkgs_and_generators = on_pkgs.attrs_update(generators)?;268269	let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));270271	let generator = nix_go!(call_package(generator)(Obj {}));272273	let generator = spawn_blocking(move || generator.build("out"))274		.await275		.expect("nix build shouldn't fail")?;276	let generator = host.remote_derivation(&generator).await?;277278	let out_parent = host.mktemp_dir().await?;279	let out = format!("{out_parent}/out");280281	let mut r#gen = host.cmd(generator).await?;282	r#gen.env("out", &out);283	if on.is_none() {284		// This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.285		let project_path: String = config286			.directory287			.clone()288			.into_os_string()289			.into_string()290			.map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;291		r#gen.env("FLEET_PROJECT", project_path);292	}293	r#gen.run().await.context("impure generator")?;294295	{296		let marker = host.read_file_text(format!("{out}/marker")).await?;297		ensure!(marker == "SUCCESS", "generation not succeeded");298	}299300	let mut parts = BTreeMap::new();301	for part in host.read_dir(&out).await? {302		if part == "created_at" || part == "expires_at" || part == "marker" {303			continue;304		}305		let contents: SecretData = host306			.read_file_text(format!("{out}/{part}"))307			.await?308			.parse()309			.map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;310		parts.insert(part.to_owned(), FleetSecretPart { raw: contents });311	}312313	let created_at = host.read_file_value(format!("{out}/created_at")).await?;314	let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();315316	let new_data = FleetSecretData {317		created_at,318		expires_at,319		parts,320		generation_data: expectations.generation_data.clone(),321	};322323	if let Some(reason) = secret_needs_regeneration(&new_data, &expectations.owners, expectations) {324		bail!("newly generated secret needs to be regenerated: {reason}")325	}326327	Ok(new_data)328}329330async fn generate(331	config: &Config,332	display_name: &str,333	secret: Value,334	expectations: &Expectations,335) -> Result<FleetSecretData> {336	let generator = nix_go!(secret.generator);337	// Can't properly check on nix module system level338	{339		let gen_ty = generator.type_of();340		if matches!(gen_ty, NixType::Null) {341			bail!("secret has no generator defined, can't automatically generate it.");342		}343		if matches!(gen_ty, NixType::Attrs) {344			if !generator.has_field("__functor")? {345				bail!("generator should be functor, got {gen_ty:?}");346			}347		} else if matches!(gen_ty, NixType::Function) {348			bail!("generator should be functor, got {gen_ty:?}");349		}350	}351	let nixpkgs = &config.nixpkgs;352	let default_pkgs = &config.default_pkgs;353	let default_mk_secret_generators = nix_go!(default_pkgs.mkSecretGenerators);354	// Generators provide additional information in passthru, to access355	// passthru we should call generator, but information about where this generator is supposed to build356	// is located in passthru... Thus evaluating generator on host.357	//358	// Maybe it is also possible to do some magic with __functor?359	//360	// I don't want to make modules always responsible for additional secret data anyway,361	// so it should be in derivation, and not in the secret data itself.362	let generators = nix_go!(default_mk_secret_generators(Obj {363		recipients: <Vec<String>>::new(),364	}));365	let pkgs_and_generators = default_pkgs.clone().attrs_update(generators)?;366367	let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));368	let default_generator = nix_go!(call_package(generator)(Obj {}));369370	let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);371372	match kind {373		GeneratorKind::Impure => {374			generate_impure(375				config,376				display_name,377				secret,378				default_generator,379				expectations,380			)381			.await382		}383		GeneratorKind::Pure => {384			generate_pure(385				config,386				display_name,387				secret,388				default_generator,389				expectations,390			)391			.await392		}393	}394}395async fn generate_shared(396	config: &Config,397	display_name: &str,398	secret: SharedSecretDefinition,399	expectations: &Expectations,400) -> Result<FleetSharedSecret> {401	// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);402	Ok(FleetSharedSecret {403		managed: Some(true),404		secret: generate(config, display_name, secret.inner(), expectations).await?,405		owners: expectations.owners.clone(),406	})407}408409async fn parse_public(410	public: Option<String>,411	public_file: Option<PathBuf>,412) -> Result<Option<SecretData>> {413	Ok(match (public, public_file) {414		(Some(v), None) => Some(SecretData {415			data: v.into(),416			encrypted: false,417		}),418		(None, Some(v)) => Some(SecretData {419			data: read(v).await?,420			encrypted: false,421		}),422		(Some(_), Some(_)) => {423			bail!("only public or public_file should be set")424		}425		(None, None) => None,426	})427}428429async fn parse_secret() -> Result<Option<Vec<u8>>> {430	let mut input = vec![];431	stdin().read_to_end(&mut input)?;432	if input.is_empty() {433		Ok(None)434	} else {435		Ok(Some(input))436	}437}438439fn parse_machines(440	initial: BTreeSet<String>,441	machines: Option<Vec<String>>,442	mut add_machines: Vec<String>,443	mut remove_machines: Vec<String>,444) -> Result<BTreeSet<String>> {445	if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {446		bail!("no operation");447	}448449	let initial_machines = initial.clone();450	let mut target_machines = initial;451	info!("Currently encrypted for {initial_machines:?}");452453	if let Some(machines) = machines {454		ensure!(455			add_machines.is_empty() && remove_machines.is_empty(),456			"can't combine --machines and --add-machines/--remove-machines"457		);458		let target = initial_machines.iter().collect::<HashSet<_>>();459		let source = machines.iter().collect::<HashSet<_>>();460		for removed in target.difference(&source) {461			remove_machines.push((*removed).clone());462		}463		for added in source.difference(&target) {464			add_machines.push((*added).clone());465		}466	}467468	for machine in &remove_machines {469		if !target_machines.remove(machine) {470			warn!("secret is not enabled for {machine}");471		}472	}473	for machine in &add_machines {474		if !target_machines.insert(machine.to_owned()) {475			warn!("secret is already added to {machine}");476		}477	}478	if !remove_machines.is_empty() {479		// TODO: maybe force secret regeneration?480		// Not that useful without revokation.481		warn!(482			"secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret"483		);484	}485	Ok(target_machines)486}487impl Secret {488	pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {489		match self {490			Secret::AddManager => {491				todo!("part of fleet-pusher")492			}493			Secret::ForceKeys => {494				for host in config.list_hosts().await? {495					if opts.should_skip(&host).await? {496						continue;497					}498					config.key(&host.name).await?;499				}500			}501			Secret::AddShared {502				machines,503				name,504				force,505				public,506				public_part: public_name,507				public_file,508				expires_at,509				re_add,510				part: part_name,511			} => {512				let mut machines: BTreeSet<String> = machines.into_iter().collect();513				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).514515				if let Some(old_shared) = config.shared_secret(&name)? {516					if !force && !re_add {517						bail!("secret already defined");518					};519					if old_shared.managed.unwrap_or(false) {520						bail!("secret is marked as managed, should not be updated manually");521					};522					if re_add {523						// Fixme: use clap to limit this usage524						ensure!(!force, "--force and --readd are not compatible");525						ensure!(526							machines.is_empty(),527							"you can't use machines argument for --readd"528						);529						machines = old_shared.owners;530					}531				} else if re_add {532					bail!("secret doesn't exists");533				};534535				let recipients = config536					.recipients(machines.iter().cloned().collect())537					.await?;538539				let mut parts = BTreeMap::new();540541				let mut input = vec![];542				io::stdin().read_to_end(&mut input)?;543544				if !input.is_empty() {545					let encrypted = encrypt_secret_data(recipients.iter(), input)546						.ok_or_else(|| anyhow!("no recipients provided"))?;547					parts.insert(part_name, FleetSecretPart { raw: encrypted });548				}549550				if let Some(public) = parse_public(public, public_file).await? {551					parts.insert(public_name, FleetSecretPart { raw: public });552				}553554				config.replace_shared(555					name,556					FleetSharedSecret {557						managed: Some(false),558						owners: machines,559						secret: FleetSecretData {560							created_at: Utc::now(),561							expires_at,562							parts,563							generation_data: serde_json::Value::Null,564						},565					},566				);567			}568			Secret::Add {569				machine,570				name,571				replace,572				merge,573				public,574				public_part: public_name,575				public_file,576				part: part_name,577			} => {578				if config.has_secret(&machine, &name) && !replace && !merge {579					bail!(580						"secret already defined.\nUse --replace to override, or --merge to add new parts to existing secret"581					);582				}583584				let mut out = if merge && !replace {585					config586						.host_secret(&machine, &name)587						.context("failed to read existing secret for --merge")?588				} else {589					FleetHostSecret {590						managed: Some(false),591						secret: FleetSecretData {592							created_at: Utc::now(),593							expires_at: None,594							parts: BTreeMap::new(),595							generation_data: serde_json::Value::Null,596						},597					}598				};599				if out.managed.unwrap_or(false) {600					bail!("secret is managed by fleet and should not be updated manually");601				}602				out.managed = Some(false);603604				if let Some(secret) = parse_secret().await? {605					let recipient = config.recipient(&machine).await?;606					let encrypted =607						encrypt_secret_data([&recipient], secret).expect("recipient provided");608					if out609						.secret610						.parts611						.insert(part_name.clone(), FleetSecretPart { raw: encrypted })612						.is_some() && !replace613					{614						bail!(615							"part {part_name:?} is already defined, use --replace if you wish to replace it"616						);617					}618				}619620				if let Some(public) = parse_public(public, public_file).await? {621					if out622						.secret623						.parts624						.insert(public_name.clone(), FleetSecretPart { raw: public })625						.is_some() && !replace626					{627						bail!(628							"part {public_name:?} is already defined, use --replace if you wish to replace it"629						);630					}631				};632633				config.insert_secret(&machine, name, out);634			}635			#[allow(clippy::await_holding_refcell_ref)]636			Secret::Read {637				name,638				machine,639				part: part_name,640			} => {641				let secret = config.host_secret(&machine, &name)?;642				let Some(secret) = secret.secret.parts.get(&part_name) else {643					bail!("no part {part_name} in secret {name}");644				};645				let data = if secret.raw.encrypted {646					let host = config.host(&machine).await?;647					host.decrypt(secret.raw.clone()).await?648				} else {649					secret.raw.data.clone()650				};651652				stdout().write_all(&data)?;653			}654			Secret::ReadShared {655				name,656				part: part_name,657				prefer_identities,658			} => {659				let Some(secret) = config.shared_secret(&name)? else {660					bail!("secret doesn't exists");661				};662				let Some(part) = secret.secret.parts.get(&part_name) else {663					bail!("no part {part_name} in secret {name}");664				};665				let data = if part.raw.encrypted {666					let identity_holder = if !prefer_identities.is_empty() {667						prefer_identities668							.iter()669							.find(|i| secret.owners.iter().any(|s| s == *i))670					} else {671						secret.owners.first()672					};673					let Some(identity_holder) = identity_holder else {674						bail!("no available holder found");675					};676					let host = config.host(identity_holder).await?;677					host.decrypt(part.raw.clone()).await?678				} else {679					part.raw.data.clone()680				};681				stdout().write_all(&data)?;682			}683			Secret::UpdateShared {684				name,685				machine,686				add_machine,687				remove_machine,688				prefer_identities,689			} => {690				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).691692				let Some(secret) = config.shared_secret(&name)? else {693					bail!("secret doesn't exists");694				};695				if secret.secret.parts.values().all(|v| !v.raw.encrypted) {696					bail!("no secret");697				}698699				let initial_machines = secret.owners.clone();700				let target_machines = parse_machines(701					initial_machines.clone(),702					machine,703					add_machine,704					remove_machine,705				)?;706707				if target_machines.is_empty() {708					info!("no machines left for secret, removing it");709					config.remove_shared(&name);710					return Ok(());711				}712713				let definition = config.shared_secret_definition(&name)?;714				let expectations = definition.expectations()?;715716				let updated = maybe_regenerate_shared_secret(717					&name,718					config,719					secret,720					definition,721					&prefer_identities,722					&expectations,723				)724				.await?;725				config.replace_shared(name, updated);726			}727			Secret::Regenerate {728				prefer_identities,729				skip_hosts,730			} => {731				info!("checking for secrets to regenerate");732				let expected_shared_set = config733					.list_configured_shared()734					.await?735					.into_iter()736					.collect::<HashSet<_>>();737				let stored_shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();738				{739					// Generate missing shared740					let _span = info_span!("shared").entered();741					for missing in expected_shared_set.difference(&stored_shared_set) {742						let definition = config.shared_secret_definition(missing)?;743						if !definition.is_managed()? {744							info!("skipping unmanaged secret: {missing}");745							continue;746						}747						let expectations = definition.expectations()?;748						info!("generating secret: {missing}");749						let shared = generate_shared(config, missing, definition, &expectations)750							.in_current_span()751							.await?;752						config.replace_shared(missing.to_string(), shared)753					}754				}755				if !skip_hosts {756					for host in config.list_hosts().await? {757						if opts.should_skip(&host).await? {758							continue;759						}760761						let _span = info_span!("host", host = host.name).entered();762						let expected_set = host763							.list_defined_secrets()?764							.into_iter()765							.collect::<HashSet<_>>();766						let stored_set = config767							.list_secrets(&host.name)768							.into_iter()769							.collect::<HashSet<_>>();770						for missing_secret in expected_set.difference(&stored_set) {771							info!("generating missing secret: {missing_secret}");772							let definition = host.secret_definition(missing_secret)?;773							let expectations = definition.expectations()?;774							let generated = match generate(775								config,776								missing_secret,777								definition.inner(),778								&expectations,779							)780							.in_current_span()781							.await782							{783								Ok(v) => v,784								Err(e) => {785									error!("{e:?}");786									continue;787								}788							};789							config.insert_secret(790								&host.name,791								missing_secret.to_string(),792								FleetHostSecret {793									managed: Some(true),794									secret: generated,795								},796							)797						}798						for known_secret in stored_set.intersection(&expected_set) {799							info!("updating secret: {known_secret}");800							let data = config.host_secret(&host.name, known_secret)?;801							let definition = host.secret_definition(known_secret)?;802							let expectations = definition.expectations()?;803							if let Some(regen_reason) = data.needs_regeneration(&expectations) {804								info!("needs regeneration: {regen_reason}");805								let generated = match generate(806									config,807									known_secret,808									definition.inner(),809									&expectations,810								)811								.in_current_span()812								.await813								{814									Ok(v) => v,815									Err(e) => {816										error!("{e:?}");817										continue;818									}819								};820								config.insert_secret(821									&host.name,822									known_secret.to_string(),823									FleetHostSecret {824										managed: Some(true),825										secret: generated,826									},827								)828							}829						}830						for removed_secret in stored_set.difference(&expected_set) {831							info!("removing secret: {removed_secret}");832							config.remove_secret(&host.name, removed_secret);833						}834					}835				}836				for known_secret in stored_shared_set.intersection(&expected_shared_set) {837					info!("updating shared secret: {known_secret}");838					let data = config.shared_secret(known_secret)?.expect("exists");839840					let definition = config.shared_secret_definition(known_secret)?;841					let expectations = definition.expectations()?;842					config.replace_shared(843						known_secret.to_owned(),844						maybe_regenerate_shared_secret(845							known_secret,846							config,847							data,848							definition,849							&prefer_identities,850							&expectations,851						)852						.await?,853					);854				}855				for removed_secret in stored_shared_set.difference(&expected_shared_set) {856					info!("removing shared secret: {removed_secret}");857					config.remove_shared(removed_secret);858				}859			}860			Secret::List {} => {861				let _span = info_span!("loading secrets").entered();862				let configured = config.list_configured_shared().await?;863				#[derive(Tabled)]864				struct SecretDisplay {865					#[tabled(rename = "Name")]866					name: String,867					#[tabled(rename = "Owners")]868					owners: String,869				}870				let mut table = vec![];871				for name in configured.iter().cloned() {872					let config = config.clone();873					let data = config.shared_secret(&name)?.expect("exists");874					let definition = config.shared_secret_definition(&name)?;875					let expectations = definition.expectations()?;876					let owners = data877						.owners878						.iter()879						.map(|o| {880							if expectations.owners.contains(o) {881								o.green().to_string()882							} else {883								o.red().to_string()884							}885						})886						.collect::<Vec<_>>();887					table.push(SecretDisplay {888						owners: owners.join(", "),889						name,890					})891				}892				info!("loaded\n{}", Table::new(table).to_string())893			}894			Secret::Edit {895				name,896				machine,897				part,898				add,899			} => {900				let secret = config.host_secret(&machine, &name)?;901				if let Some(data) = secret.secret.parts.get(&part) {902					let host = config.host(&machine).await?;903					let secret = host.decrypt(data.raw.clone()).await?;904					String::from_utf8(secret).context("secret is not utf8")?905				} else if add {906					String::new()907				} else {908					bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");909				};910			}911		}912		Ok(())913	}914}915916/*917async fn edit_temp_file(918	builder: tempfile::Builder<'_, '_>,919	r: Vec<u8>,920	header: &str,921	comment: &str,922) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {923	if !stdin().is_tty() {924		// TODO: Also try to open /dev/tty directly?925		bail!("stdin is not tty, can't open editor");926	}927928	use std::fmt::Write;929	let mut file = builder.tempfile()?;930931	let mut full_header = String::new();932	let mut had = false;933	for line in header.trim_end().lines() {934		had = true;935		writeln!(&mut full_header, "{comment}{line}")?;936	}937	if had {938		writeln!(&mut full_header, "{}", comment.trim_end())?;939	}940	writeln!(941		&mut full_header,942		"{comment}Do not touch this header! It will be removed automatically"943	)?;944945	file.write_all(full_header.as_bytes())?;946	file.write_all(&r)?;947948	let abs_path = file.into_temp_path();949	let editor = std::env::var_os("VISUAL")950		.or_else(|| std::env::var_os("EDITOR"))951		.unwrap_or_else(|| "vi".into());952	let editor_args = shlex::bytes::split(editor.as_encoded_bytes())953		.ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;954	let editor_args = editor_args955		.into_iter()956		.map(|v| {957			// Only ASCII subsequences are replaced958			unsafe { OsString::from_encoded_bytes_unchecked(v) }959		})960		.collect_vec();961	let Some((editor, args)) = editor_args.split_first() else {962		bail!("EDITOR env var has no command");963	};964	let mut command = Command::new(editor);965	command.args(args);966967	let path_arg = abs_path.canonicalize()?;968969	// TODO: Save full state, using tcget/_getmode/_setmode970	let was_raw = terminal::is_raw_mode_enabled()?;971	terminal::enable_raw_mode()?;972973	let status = command.arg(path_arg).status().await;974975	if !was_raw {976		terminal::disable_raw_mode()?;977	}978979	let success = match status {980		Ok(s) => s.success(),981		Err(e) if e.kind() == io::ErrorKind::NotFound => {982			bail!("editor not found")983		}984		Err(e) => bail!("editor spawn error: {e}"),985	};986987	let mut file = std::fs::read(&abs_path).context("read editor output")?;988	let Some(v) = file.strip_prefix(full_header.as_bytes()) else {989		todo!();990	};991	todo!();992993	// Ok((success, abs_path))994}995*/
modifiedcrates/fleet-base/src/secret.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/secret.rs
+++ b/crates/fleet-base/src/secret.rs
@@ -17,20 +17,37 @@
 pub struct HostSecretDefinition(pub(crate) String, pub(crate) Value);
 impl HostSecretDefinition {
 	pub fn is_managed(&self) -> Result<bool> {
-		let value = &self.1;
-		Ok(!nix_go!(value.generator).is_null())
+		let def = self.definition_value()?;
+		Ok(!nix_go!(def.generator).is_null())
 	}
+	pub fn is_shared(&self) -> Result<bool> {
+		let def = self.definition_value()?;
+		Ok(nix_go_json!(def.shared))
+	}
 	pub fn expectations(&self) -> Result<Expectations> {
-		let value = &self.1;
+		let def = self.definition_value()?;
+		let parts = nix_go!(def.parts);
+
+		let mut public_parts = BTreeSet::new();
+		let mut private_parts = BTreeSet::new();
+		for part in parts.list_fields()? {
+			if nix_go_json!(parts[&part].encrypted) {
+				private_parts.insert(part.clone());
+			} else {
+				public_parts.insert(part.clone());
+			}
+		}
+
 		Ok(Expectations {
 			owners: BTreeSet::from([self.0.clone()]),
-			generation_data: nix_go_json!(value.expectedGenerationData),
-			public_parts: nix_go_json!(value.expectedPublicParts),
-			private_parts: nix_go_json!(value.expectedPrivateParts),
+			generation_data: nix_go_json!(def.expectedGenerationData),
+			public_parts,
+			private_parts,
 		})
 	}
-	pub fn inner(&self) -> Value {
-		self.1.clone()
+	pub fn definition_value(&self) -> Result<Value> {
+		let value = &self.1;
+		Ok(nix_go!(value.definition))
 	}
 }
 
@@ -49,7 +66,7 @@
 			private_parts: nix_go_json!(value.expectedPrivateParts),
 		})
 	}
-	pub fn inner(&self) -> Value {
+	pub fn definition_value(&self) -> Value {
 		self.0.clone()
 	}
 }
modifiedlib/default.nixdiffbeforeafterboth
--- a/lib/default.nix
+++ b/lib/default.nix
@@ -57,215 +57,191 @@
 
   inherit (modules) mkFleetDefault mkFleetGeneratorDefault;
 
-  secrets =
-    let
-      describedGenerator =
-        generator: {parts ? {}}:
-        {parts = {};}
-        // {
-          __functionArgs = functionArgs generator;
-          __functor = _: generator;
-        };
-    in
-    {
-      inherit describedGenerator;
+  secrets = {
 
-      /**
-        Generate a random secret password, 32 ascii characters by default
+    /**
+      Generate a random secret password, 32 ascii characters by default
 
-        Options:
-          size: generated password length in ascii characters (bytes).
-          noSymbols: by default, character set includes various special characters ($ , ! + * : ~), and might
-                     not be accepted in some contexts, this option switches charset to just [A-Za-z0-9].
+      Options:
+        size: generated password length in ascii characters (bytes).
+        noSymbols: by default, character set includes various special characters ($ , ! + * : ~), and might
+                   not be accepted in some contexts, this option switches charset to just [A-Za-z0-9].
 
-        Output:
-          Resulting secret has only part: secret, which contains encrypted password.
-      */
-      mkPassword =
+      Output:
+        Resulting secret has only part: secret, which contains encrypted password.
+    */
+    mkPassword =
+      {
+        size ? 32,
+      }:
+      (
         {
-          size ? 32,
+          coreutils,
+          mkSecretGenerator,
         }:
-        describedGenerator
-          (
-            {
-              coreutils,
-              mkSecretGenerator,
-            }:
-            mkSecretGenerator {
-              script = ''
-                mkdir $out
-                gh generate password -o $out/secret --size ${toString size}
-              '';
-            }
-          )
-          {
-            parts.secret.encrypted = true;
-          };
+        mkSecretGenerator {
+          script = ''
+            mkdir $out
+            gh generate password -o $out/secret --size ${toString size}
+          '';
+          parts.secret.encrypted = true;
+        }
+      );
 
-      /**
-        Generate a random ed25519 keypair
+    /**
+      Generate a random ed25519 keypair
 
-        Options:
-          noEmbedPublic: By default, secret key also embeds public key in itself ("extended" format, 64 bytes)
-                         When noEmbedPublis is enabled - only the private scalar is included.
-          encoding: Encoring of public and secret parts, can be "raw" (default), "base64" or "hex".
+      Options:
+        noEmbedPublic: By default, secret key also embeds public key in itself ("extended" format, 64 bytes)
+                       When noEmbedPublis is enabled - only the private scalar is included.
+        encoding: Encoring of public and secret parts, can be "raw" (default), "base64" or "hex".
 
-        Output:
-          Resulting secret has two parts: public and secret, where the secret part is encrypted.
+      Output:
+        Resulting secret has two parts: public and secret, where the secret part is encrypted.
+
+      This secret format is used by e.g Garage S3 server
+    */
+    mkEd25519 =
+      {
+        noEmbedPublic ? false,
+        encoding ? null,
+      }:
+      (
+        { mkSecretGenerator }:
+        mkSecretGenerator {
+          script = ''
+            mkdir $out
+            gh generate ed25519 -p $out/public -s $out/secret \
+              ${optionalString noEmbedPublic "--no-embed-public"} \
+              ${optionalString (encoding != null) "--encoding=${encoding}"}
+          '';
+          parts.secret.encrypted = true;
+          parts.public.encrypted = false;
+        }
+      );
 
-        This secret format is used by e.g Garage S3 server
-      */
-      mkEd25519 =
-        {
-          noEmbedPublic ? false,
-          encoding ? null,
-        }:
-        describedGenerator
-          (
-            { mkSecretGenerator }:
-            mkSecretGenerator {
-              script = ''
-                mkdir $out
-                gh generate ed25519 -p $out/public -s $out/secret \
-                  ${optionalString noEmbedPublic "--no-embed-public"} \
-                  ${optionalString (encoding != null) "--encoding=${encoding}"}
-              '';
-            }
-          )
-          {
-            parts.secret.encrypted = true;
-            parts.public.encrypted = false;
-          };
+    /**
+      Generate a random x25519 keypair
 
-      /**
-        Generate a random x25519 keypair
+      Options:
+        encoding: Encoring of public and secret parts, can be "raw" (default), "base64" or "hex".
 
-        Options:
-          encoding: Encoring of public and secret parts, can be "raw" (default), "base64" or "hex".
+      Output:
+        Resulting secret has two parts: public and secret, where the secret part is encrypted.
 
-        Output:
-          Resulting secret has two parts: public and secret, where the secret part is encrypted.
+      This secret format is used by e.g Wireguard VPN for peers (base64-encoded)
+    */
+    mkX25519 =
+      {
+        encoding ? null,
+      }:
+      (
+        { mkSecretGenerator }:
+        mkSecretGenerator {
+          script = ''
+            mkdir $out
+            gh generate x25519 -p $out/public -s $out/secret \
+              ${optionalString (encoding != null) "--encoding=${encoding}"}
+          '';
 
-        This secret format is used by e.g Wireguard VPN for peers (base64-encoded)
-      */
-      mkX25519 =
-        {
-          encoding ? null,
-        }:
-        describedGenerator
-          (
-            { mkSecretGenerator }:
-            mkSecretGenerator {
-              script = ''
-                mkdir $out
-                gh generate x25519 -p $out/public -s $out/secret \
-                  ${optionalString (encoding != null) "--encoding=${encoding}"}
-              '';
-            }
-          )
-          {
-            parts.secret.encrypted = true;
-            parts.public.encrypted = false;
-          };
+          parts.secret.encrypted = true;
+          parts.public.encrypted = false;
+        }
+      );
 
-      /**
-        Generate a random RSA keypair
+    /**
+      Generate a random RSA keypair
 
-        Options:
-          size: RSA key size, 4096 by default
+      Options:
+        size: RSA key size, 4096 by default
 
-        Output:
-          Resulting secret has two parts: public and secret, where the secret part is encrypted.
-          Both parts are PEM encoded.
-      */
-      mkRsa =
+      Output:
+        Resulting secret has two parts: public and secret, where the secret part is encrypted.
+        Both parts are PEM encoded.
+    */
+    mkRsa =
+      {
+        size ? 4096,
+      }:
+      (
         {
-          size ? 4096,
+          openssl,
+          mkSecretGenerator,
         }:
-        describedGenerator
-          (
-            {
-              openssl,
-              mkSecretGenerator,
-            }:
-            mkSecretGenerator {
-              script = ''
-                mkdir $out
+        mkSecretGenerator {
+          script = ''
+            mkdir $out
+
+            ${openssl}/bin/openssl genrsa -out rsa_private.key ${toString size}
+            ${openssl}/bin/openssl rsa -in rsa_private.key -pubout -out rsa_public.key
 
-                ${openssl}/bin/openssl genrsa -out rsa_private.key ${toString size}
-                ${openssl}/bin/openssl rsa -in rsa_private.key -pubout -out rsa_public.key
+            cat rsa_private.key | gh private -o $out/secret
+            cat rsa_public.key | gh public -o $out/public
+          '';
 
-                cat rsa_private.key | gh private -o $out/secret
-                cat rsa_public.key | gh public -o $out/public
-              '';
-            }
-          )
-          {
-            parts.secret.encrypted = true;
-            parts.public.encrypted = false;
-          };
+          parts.secret.encrypted = true;
+          parts.public.encrypted = false;
+        }
+      );
 
-      /**
-        Generate a random byte sequence
+    /**
+      Generate a random byte sequence
 
-        Options:
-          size: generated password length in bytes, 32 by default.
-          encoding: how the generated bytes should be encoded, "raw" (default), "hex" or "base64"
-          noNuls: prevent output byte sequence from containing internal \0, useful for some C applications
-                  that can't handle their strings properly.
+      Options:
+        size: generated password length in bytes, 32 by default.
+        encoding: how the generated bytes should be encoded, "raw" (default), "hex" or "base64"
+        noNuls: prevent output byte sequence from containing internal \0, useful for some C applications
+                that can't handle their strings properly.
 
-        Output:
-          Resulting secret has only part: secret, which contains encrypted bytes.
+      Output:
+        Resulting secret has only part: secret, which contains encrypted bytes.
 
-        Might be used for e.g. Wireguard VPN PSK keys (base64-encoded)
-      */
-      mkBytes =
-        {
-          count ? 32,
-          encoding,
-          noNuls ? false,
-        }:
-        describedGenerator
-          (
-            { mkSecretGenerator }:
-            mkSecretGenerator {
-              script = ''
-                mkdir $out
-                gh generate bytes --count=${toString count} --encoding=${encoding} -o $out/secret \
-                  ${optionalString noNuls "--no-nuls"}
-              '';
-            }
-          )
-          {
-            parts.secret.encrypted = true;
-          };
-      /**
-        Shorthand for `mkBytes`, which defaults to "hex" encoding
-      */
-      mkHexBytes =
-        {
-          count ? 32,
-        }:
-        mkBytes {
-          inherit count;
-          encoding = "hex";
-        };
-      /**
-        Shorthand for `mkBytes`, which defaults to "base64" encoding
-      */
-      mkBase64Bytes =
-        {
-          count ? 32,
-        }:
-        mkBytes {
-          inherit count;
-          encoding = "base64";
-        };
+      Might be used for e.g. Wireguard VPN PSK keys (base64-encoded)
+    */
+    mkBytes =
+      {
+        count ? 32,
+        encoding,
+        noNuls ? false,
+      }:
+      (
+        { mkSecretGenerator }:
+        mkSecretGenerator {
+          script = ''
+            mkdir $out
+            gh generate bytes --count=${toString count} --encoding=${encoding} -o $out/secret \
+              ${optionalString noNuls "--no-nuls"}
+          '';
+          parts.secret.encrypted = true;
+        }
+      );
+    /**
+      Shorthand for `mkBytes`, which defaults to "hex" encoding
+    */
+    mkHexBytes =
+      {
+        count ? 32,
+      }:
+      mkBytes {
+        inherit count;
+        encoding = "hex";
+      };
+    /**
+      Shorthand for `mkBytes`, which defaults to "base64" encoding
+    */
+    mkBase64Bytes =
+      {
+        count ? 32,
+      }:
+      mkBytes {
+        inherit count;
+        encoding = "base64";
+      };
 
-      # Wireguard
-      # mkWireguard = {}: mkX25519 {encoding = "base64";};
-      # mkWireguardPsk = {}: mkBase64Bytes {count = 32;};
-    };
+    # Wireguard
+    # mkWireguard = {}: mkX25519 {encoding = "base64";};
+    # mkWireguardPsk = {}: mkBase64Bytes {count = 32;};
+  };
 
   inherit (secrets)
     mkPassword
modifiedmodules/nixos/secrets.nixdiffbeforeafterboth
--- a/modules/nixos/secrets.nix
+++ b/modules/nixos/secrets.nix
@@ -105,10 +105,14 @@
     in
     {
       options = {
+        shared = mkOption {
+          type = bool;
+          description = "Was this secret propagated from a shared secret?";
+        };
         parts = mkOption {
           type = lazyAttrsOf (secretPartType secretName);
           description = "Definition of secret parts";
-          default = {};
+          default = { };
         };
         generator = mkOption {
           type = uniq (nullOr (functionTo package));
@@ -137,24 +141,39 @@
           default = null;
         };
       };
-      config.parts = mkMerge [
-        (mkIf (config.generator != null && config.generator ? parts) config.generator.parts)
-        (mapAttrs (_: _: {}) (removeAttrs (sysConfig.data.secrets.${secretName} or {}) ["shared" "managed"]))
-      ];
+      config = {
+        shared = (sysConfig.data.secrets.${secretName} or { shared = false; }).shared;
+        parts = mkMerge [
+          (mkIf (config.generator != null)
+            (
+              # Get fake derivation body, in future it should be implemented the same way as in Rust.
+              lib.callPackageWith (
+                pkgs
+                // {
+                  mkSecretGenerator = pkgs.stdenv.mkDerivation;
+                  mkImpureSecretGenerator = pkgs.stdenv.mkDerivation;
+                }
+              ) config.generator { }
+            ).parts
+          )
+          (mapAttrs (_: _: { }) (
+            removeAttrs (sysConfig.data.secrets.${secretName} or { }) [
+              "shared"
+              "managed"
+            ]
+          ))
+        ];
+      };
     }
   );
   processPart = secretName: partName: part: {
     inherit (part) path stablePath;
     raw = config.data.secrets.${secretName}.${partName}.raw;
   };
-  processSecret =
-    secretName: secret:
-    {
-      inherit (secret.definition) group mode owner;
-      parts = (mapAttrs (processPart secretName) (
-        secret.definition.parts
-      ));
-    };
+  processSecret = secretName: secret: {
+    inherit (secret.definition) group mode owner;
+    parts = (mapAttrs (processPart secretName) (secret.definition.parts));
+  };
   secretsData = (mapAttrs (processSecret) config.secrets);
   secretsFile = pkgs.writeTextFile {
     name = "secrets.json";
@@ -174,7 +193,7 @@
     secrets = mkOption {
       type = attrsOf secretType;
       default = { };
-      apply = v: (mapAttrs (_: secret: secret.parts // {definition = secret;}) v);
+      apply = v: (mapAttrs (_: secret: secret.parts // { definition = secret; }) v);
       description = "Host-local secrets";
     };
     system.secretsData = mkOption {
modifiedmodules/secrets.nixdiffbeforeafterboth
--- a/modules/secrets.nix
+++ b/modules/secrets.nix
@@ -124,6 +124,7 @@
                 # If set - script will be run on remote machine, otherwise it will be run with fleet project in CWD
                 # (Some secrets-encryption-in-git/managed PKI solution is expected)
                 impureOn ? null,
+                parts,
               }:
               (prev.writeShellScript "impureGenerator.sh" ''
                 #!/bin/sh
@@ -151,12 +152,12 @@
               '').overrideAttrs
                 (old: {
                   passthru = {
-                    inherit impureOn;
+                    inherit impureOn parts;
                     generatorKind = "impure";
                   };
                 });
             # Pure generators are disabled for now
-            mkSecretGenerator = { script }: mkImpureSecretGenerator { inherit script; };
+            mkSecretGenerator = { script, parts }: mkImpureSecretGenerator { inherit script parts; };
 
             # TODO: Implement consistent naming
             # Pure secret generator is supposed to be run entirely by nix, using `__impure` derivation type...