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

difftreelog

refactor prepare batching for secrets

Yaroslav Bolyukin2024-11-14parent: #e7a5b5f.patch.diff
in: trunk

1 file 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, stdin, stdout, Read, Write},4	path::PathBuf,5};67use anyhow::{anyhow, bail, ensure, Context, Result};8use chrono::{DateTime, Utc};9use clap::Parser;10use fleet_base::{11	fleetdata::{encrypt_secret_data, FleetSecret, FleetSecretPart, FleetSharedSecret},12	host::Config,13	opts::FleetOpts,14};15use fleet_shared::SecretData;16use nix_eval::{nix_go, nix_go_json, Value};17use owo_colors::OwoColorize;18use serde::Deserialize;19use tabled::{Table, Tabled};20use tokio::fs::read;21use tracing::{error, info, info_span, warn, Instrument};2223#[derive(Parser)]24pub enum Secret {25	/// Force load host keys for all defined hosts26	ForceKeys,27	/// Add secret, data should be provided in stdin28	AddShared {29		/// Secret name30		name: String,31		/// Secret owners32		#[clap(long, short)]33		machines: Vec<String>,34		/// Override secret if already present35		#[clap(long)]36		force: bool,37		/// Secret public part38		#[clap(long)]39		public: Option<String>,40		/// Load public part from specified file41		#[clap(long)]42		public_file: Option<PathBuf>,4344		/// Create a notification on secret expiration45		#[clap(long)]46		expires_at: Option<DateTime<Utc>>,4748		/// Secret with this name already exists, override its value while keeping the same owners.49		#[clap(long)]50		re_add: bool,5152		/// How to name public secret part53		#[clap(long, short = 'p', default_value = "public")]54		public_part: String,55		/// How to name private secret part56		#[clap(short = 's', long, default_value = "secret")]57		part: String,58	},59	/// Add secret, data should be provided in stdin60	Add {61		/// Secret name62		name: String,63		/// Secret owner64		#[clap(short = 'm', long)]65		machine: String,66		/// Replace secret if already present67		#[clap(long)]68		replace: bool,69		/// Add new parts to existing secret70		#[clap(long)]71		merge: bool,72		/// Secret public part73		#[clap(long)]74		public: Option<String>,75		/// Load public part from specified file76		#[clap(long)]77		public_file: Option<PathBuf>,7879		/// How to name public secret part80		#[clap(short = 'p', long, default_value = "public")]81		public_part: String,82		/// How to name private secret part83		#[clap(short = 's', long, default_value = "secret")]84		part: String,85	},86	/// Read secret from remote host, requires sudo on said host87	Read {88		name: String,89		#[clap(short = 'm', long)]90		machine: String,9192		/// Which private secret part to read93		#[clap(short = 'p', long, default_value = "secret")]94		part: String,95	},96	UpdateShared {97		name: String,9899		#[clap(short = 'm', long)]100		machine: Option<Vec<String>>,101102		#[clap(long)]103		add_machine: Vec<String>,104		#[clap(long)]105		remove_machine: Vec<String>,106107		/// Which host should we use to decrypt108		#[clap(long)]109		prefer_identities: Vec<String>,110	},111	Regenerate {112		/// Which host should we use to decrypt, in case if reencryption is required, without113		/// regeneration114		#[clap(long)]115		prefer_identities: Vec<String>,116	},117	List {},118	Edit {119		name: String,120		#[clap(short = 'm', long)]121		machine: String,122123		#[clap(long)]124		add: bool,125126		/// Which private secret part to read127		#[clap(short = 'p', long, default_value = "secret")]128		part: String,129	},130}131132#[tracing::instrument(skip(config, secret, field, prefer_identities))]133async fn update_owner_set(134	secret_name: &str,135	config: &Config,136	mut secret: FleetSharedSecret,137	field: Value,138	updated_set: &[String],139	prefer_identities: &[String],140) -> Result<FleetSharedSecret> {141	let original_set = secret.owners.clone();142143	let set = original_set.iter().collect::<BTreeSet<_>>();144	let expected_set = updated_set.iter().collect::<BTreeSet<_>>();145146	if set == expected_set {147		info!("no need to update owner list, it is already correct");148		return Ok(secret);149	}150151	let should_regenerate = if set.difference(&expected_set).next().is_some() {152		// TODO: Remove this warning for revokable secrets.153		warn!("host was removed from secret owners, but until this host rebuild, the secret will still be stored on it.");154		nix_go_json!(field.regenerateOnOwnerRemoved)155	} else if expected_set.difference(&set).next().is_some() {156		nix_go_json!(field.regenerateOnOwnerAdded)157	} else {158		false159	};160161	if should_regenerate {162		info!("secret is owner-dependent, will regenerate");163		let generated = generate_shared(config, secret_name, field, updated_set.to_vec()).await?;164		Ok(generated)165	} else {166		let identity_holder = if !prefer_identities.is_empty() {167			prefer_identities168				.iter()169				.find(|i| original_set.iter().any(|s| s == *i))170		} else {171			secret.owners.first()172		};173		let Some(identity_holder) = identity_holder else {174			bail!("no available holder found");175		};176177		for (part_name, part) in secret.secret.parts.iter_mut() {178			let _span = info_span!("part reencryption", part_name);179			if !part.raw.encrypted {180				continue;181			}182			let host = config.host(identity_holder).await?;183			let encrypted = host184				.reencrypt(part.raw.clone(), updated_set.to_vec())185				.await?;186			part.raw = encrypted;187		}188189		secret.owners = updated_set.to_vec();190		Ok(secret)191	}192}193194#[derive(Deserialize)]195#[serde(rename_all = "camelCase")]196enum GeneratorKind {197	Impure,198	Pure,199}200201async fn generate_pure(202	_config: &Config,203	_display_name: &str,204	_secret: Value,205	_default_generator: Value,206	_owners: &[String],207) -> Result<FleetSecret> {208	bail!("pure generators are broken for now")209}210async fn generate_impure(211	config: &Config,212	_display_name: &str,213	secret: Value,214	default_generator: Value,215	owners: &[String],216) -> Result<FleetSecret> {217	let generator = nix_go!(secret.generator);218	let on: Option<String> = nix_go_json!(default_generator.impureOn);219220	let host = if let Some(on) = &on {221		config.host(on).await?222	} else {223		config.local_host()224	};225	let on_pkgs = host.pkgs().await?;226	let call_package = nix_go!(on_pkgs.callPackage);227	let mk_secret_generators = nix_go!(on_pkgs.mkSecretGenerators);228229	let mut recipients = Vec::new();230	for owner in owners {231		let key = config.key(owner).await?;232		recipients.push(key);233	}234	let generators = nix_go!(mk_secret_generators(Obj { recipients }));235236	let generator = nix_go!(call_package(generator)(generators));237238	let generator = generator.build().await?;239	let generator = generator240		.get("out")241		.ok_or_else(|| anyhow!("missing generateImpure out"))?;242	let generator = host.remote_derivation(generator).await?;243244	let out_parent = host.mktemp_dir().await?;245	let out = format!("{out_parent}/out");246247	let mut gen = host.cmd(generator).await?;248	gen.env("out", &out);249	if on.is_none() {250		// This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.251		let project_path: String = config252			.directory253			.clone()254			.into_os_string()255			.into_string()256			.map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;257		gen.env("FLEET_PROJECT", project_path);258	}259	gen.run().await.context("impure generator")?;260261	{262		let marker = host.read_file_text(format!("{out}/marker")).await?;263		ensure!(marker == "SUCCESS", "generation not succeeded");264	}265266	let mut parts = BTreeMap::new();267	for part in host.read_dir(&out).await? {268		if part == "created_at" || part == "expired_at" || part == "marker" {269			continue;270		}271		let contents: SecretData = host272			.read_file_text(format!("{out}/{part}"))273			.await?274			.parse()275			.map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;276		parts.insert(part.to_owned(), FleetSecretPart { raw: contents });277	}278279	let created_at = host.read_file_value(format!("{out}/created_at")).await?;280	let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();281282	Ok(FleetSecret {283		created_at,284		expires_at,285		parts,286	})287}288async fn generate(289	config: &Config,290	display_name: &str,291	secret: Value,292	owners: &[String],293) -> Result<FleetSecret> {294	let generator = nix_go!(secret.generator);295	// Can't properly check on nix module system level296	{297		let gen_ty = generator.type_of().await?;298		if gen_ty == "null" {299			bail!("secret has no generator defined, can't automatically generate it.");300		}301		if gen_ty != "lambda" {302			bail!("generator should be lambda, got {gen_ty}");303		}304	}305	let default_pkgs = &config.default_pkgs;306	let default_call_package = nix_go!(default_pkgs.callPackage);307	let default_mk_secret_generators = nix_go!(default_pkgs.mkSecretGenerators);308	// Generators provide additional information in passthru, to access309	// passthru we should call generator, but information about where this generator is supposed to build310	// is located in passthru... Thus evaluating generator on host.311	//312	// Maybe it is also possible to do some magic with __functor?313	//314	// I don't want to make modules always responsible for additional secret data anyway,315	// so it should be in derivation, and not in the secret data itself.316	let generators = nix_go!(default_mk_secret_generators(Obj {317		recipients: <Vec<String>>::new(),318	}));319	let default_generator = nix_go!(default_call_package(generator)(generators));320321	let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);322323	match kind {324		GeneratorKind::Impure => {325			generate_impure(config, display_name, secret, default_generator, owners).await326		}327		GeneratorKind::Pure => {328			generate_pure(config, display_name, secret, default_generator, owners).await329		}330	}331}332async fn generate_shared(333	config: &Config,334	display_name: &str,335	secret: Value,336	expected_owners: Vec<String>,337) -> Result<FleetSharedSecret> {338	// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);339	Ok(FleetSharedSecret {340		secret: generate(config, display_name, secret, &expected_owners).await?,341		owners: expected_owners,342	})343}344345async fn parse_public(346	public: Option<String>,347	public_file: Option<PathBuf>,348) -> Result<Option<SecretData>> {349	Ok(match (public, public_file) {350		(Some(v), None) => Some(SecretData {351			data: v.into(),352			encrypted: false,353		}),354		(None, Some(v)) => Some(SecretData {355			data: read(v).await?,356			encrypted: false,357		}),358		(Some(_), Some(_)) => {359			bail!("only public or public_file should be set")360		}361		(None, None) => None,362	})363}364365async fn parse_secret() -> Result<Option<Vec<u8>>> {366	let mut input = vec![];367	stdin().read_to_end(&mut input)?;368	if input.is_empty() {369		Ok(None)370	} else {371		Ok(Some(input))372	}373}374375fn parse_machines(376	initial: Vec<String>,377	machines: Option<Vec<String>>,378	mut add_machines: Vec<String>,379	mut remove_machines: Vec<String>,380) -> Result<Vec<String>> {381	if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {382		bail!("no operation");383	}384385	let initial_machines = initial.clone();386	let mut target_machines = initial;387	info!("Currently encrypted for {initial_machines:?}");388389	// ensure!(machines.is_some() || !add_machines.is_empty() || )390	if let Some(machines) = machines {391		ensure!(392			add_machines.is_empty() && remove_machines.is_empty(),393			"can't combine --machines and --add-machines/--remove-machines"394		);395		let target = initial_machines.iter().collect::<HashSet<_>>();396		let source = machines.iter().collect::<HashSet<_>>();397		for removed in target.difference(&source) {398			remove_machines.push((*removed).clone());399		}400		for added in source.difference(&target) {401			add_machines.push((*added).clone());402		}403	}404405	for machine in &remove_machines {406		let mut removed = false;407		while let Some(pos) = target_machines.iter().position(|m| m == machine) {408			target_machines.swap_remove(pos);409			removed = true;410		}411		if !removed {412			warn!("secret is not enabled for {machine}");413		}414	}415	for machine in &add_machines {416		if target_machines.iter().any(|m| m == machine) {417			warn!("secret is already added to {machine}");418		} else {419			target_machines.push(machine.to_owned());420		}421	}422	if !remove_machines.is_empty() {423		// TODO: maybe force secret regeneration?424		// Not that useful without revokation.425		warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");426	}427	Ok(target_machines)428}429impl Secret {430	pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {431		match self {432			Secret::ForceKeys => {433				for host in config.list_hosts().await? {434					if opts.should_skip(&host).await? {435						continue;436					}437					config.key(&host.name).await?;438				}439			}440			Secret::AddShared {441				mut machines,442				name,443				force,444				public,445				public_part: public_name,446				public_file,447				expires_at,448				re_add,449				part: part_name,450			} => {451				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).452453				let exists = config.has_shared(&name);454				if exists && !force && !re_add {455					bail!("secret already defined");456				}457				if re_add {458					// Fixme: use clap to limit this usage459					ensure!(!force, "--force and --readd are not compatible");460					ensure!(exists, "secret doesn't exists");461					ensure!(462						machines.is_empty(),463						"you can't use machines argument for --readd"464					);465					let shared = config.shared_secret(&name)?;466					machines = shared.owners;467				}468469				let recipients = config.recipients(machines.clone()).await?;470471				let mut parts = BTreeMap::new();472473				let mut input = vec![];474				io::stdin().read_to_end(&mut input)?;475476				if !input.is_empty() {477					let encrypted = encrypt_secret_data(recipients, input)478						.ok_or_else(|| anyhow!("no recipients provided"))?;479					parts.insert(part_name, FleetSecretPart { raw: encrypted });480				}481482				if let Some(public) = parse_public(public, public_file).await? {483					parts.insert(public_name, FleetSecretPart { raw: public });484				}485486				config.replace_shared(487					name,488					FleetSharedSecret {489						owners: machines,490						secret: FleetSecret {491							created_at: Utc::now(),492							expires_at,493							parts,494						},495					},496				);497			}498			Secret::Add {499				machine,500				name,501				replace,502				merge,503				public,504				public_part: public_name,505				public_file,506				part: part_name,507			} => {508				if config.has_secret(&machine, &name) && !replace && !merge {509					bail!("secret already defined.\nUse --replace to override, or --merge to add new parts to existing secret");510				}511512				let mut out = if merge && !replace {513					config514						.host_secret(&machine, &name)515						.context("failed to read existing secret for --merge")?516				} else {517					FleetSecret {518						created_at: Utc::now(),519						expires_at: None,520						parts: BTreeMap::new(),521					}522				};523524				if let Some(secret) = parse_secret().await? {525					let recipient = config.recipient(&machine).await?;526					let encrypted =527						encrypt_secret_data(vec![recipient], secret).expect("recipient provided");528					if out529						.parts530						.insert(part_name.clone(), FleetSecretPart { raw: encrypted })531						.is_some() && !replace532					{533						bail!("part {part_name:?} is already defined");534					}535				}536537				if let Some(public) = parse_public(public, public_file).await? {538					if out539						.parts540						.insert(public_name.clone(), FleetSecretPart { raw: public })541						.is_some() && !replace542					{543						bail!("part {public_name:?} is already defined");544					}545				};546547				config.insert_secret(&machine, name, out);548			}549			#[allow(clippy::await_holding_refcell_ref)]550			Secret::Read {551				name,552				machine,553				part: part_name,554			} => {555				let secret = config.host_secret(&machine, &name)?;556				let Some(secret) = secret.parts.get(&part_name) else {557					bail!("no part {part_name} in secret {name}");558				};559				let data = if secret.raw.encrypted {560					let host = config.host(&machine).await?;561					host.decrypt(secret.raw.clone()).await?562				} else {563					secret.raw.data.clone()564				};565566				stdout().write_all(&data)?;567			}568			Secret::UpdateShared {569				name,570				machine,571				add_machine,572				remove_machine,573				prefer_identities,574			} => {575				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).576577				let secret = config.shared_secret(&name)?;578				if secret.secret.parts.values().all(|v| !v.raw.encrypted) {579					bail!("no secret");580				}581582				let initial_machines = secret.owners.clone();583				let target_machines = parse_machines(584					initial_machines.clone(),585					machine,586					add_machine,587					remove_machine,588				)?;589590				if target_machines.is_empty() {591					info!("no machines left for secret, removing it");592					config.remove_shared(&name);593					return Ok(());594				}595596				let config_field = &config.config_field;597				let field = nix_go!(config_field.sharedSecrets[{ name }]);598599				let updated = update_owner_set(600					&name,601					config,602					secret,603					field,604					&target_machines,605					&prefer_identities,606				)607				.await?;608				config.replace_shared(name, updated);609			}610			Secret::Regenerate { prefer_identities } => {611				info!("checking for secrets to regenerate");612				{613					let _span = info_span!("shared").entered();614					let expected_shared_set = config615						.list_configured_shared()616						.await?617						.into_iter()618						.collect::<HashSet<_>>();619					let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();620					for missing in expected_shared_set.difference(&shared_set) {621						let config_field = &config.config_field;622						let secret = nix_go!(config_field.sharedSecrets[{ missing }]);623						let expected_owners: Option<Vec<String>> =624							nix_go_json!(secret.expectedOwners);625						let Some(expected_owners) = expected_owners else {626							// TODO: Might still need to regenerate627							continue;628						};629						info!("generating secret: {missing}");630						let shared = generate_shared(config, missing, secret, expected_owners)631							.in_current_span()632							.await?;633						config.replace_shared(missing.to_string(), shared)634					}635				}636				for host in config.list_hosts().await? {637					if opts.should_skip(&host).await? {638						continue;639					}640641					let _span = info_span!("host", host = host.name).entered();642					let expected_set = host643						.list_configured_secrets()644						.in_current_span()645						.await?646						.into_iter()647						.collect::<HashSet<_>>();648					let stored_set = config649						.list_secrets(&host.name)650						.into_iter()651						.collect::<HashSet<_>>();652					for missing in expected_set.difference(&stored_set) {653						info!("generating secret: {missing}");654						let secret = host.secret_field(missing).in_current_span().await?;655						let generated =656							match generate(config, missing, secret, &[host.name.clone()])657								.in_current_span()658								.await659							{660								Ok(v) => v,661								Err(e) => {662									error!("{e:?}");663									continue;664								}665							};666						config.insert_secret(&host.name, missing.to_string(), generated)667					}668				}669				let mut to_remove = Vec::new();670				for name in &config.list_shared() {671					info!("updating secret: {name}");672					let data = config.shared_secret(name)?;673					let config_field = &config.config_field;674					let expected_owners: Vec<String> =675						nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);676					if expected_owners.is_empty() {677						warn!("secret was removed from fleet config: {name}, removing from data");678						to_remove.push(name.to_string());679						continue;680					}681682					let secret = nix_go!(config_field.sharedSecrets[{ name }]);683					config.replace_shared(684						name.to_owned(),685						update_owner_set(686							name,687							config,688							data,689							secret,690							&expected_owners,691							&prefer_identities,692						)693						.await?,694					);695				}696				for k in to_remove {697					config.remove_shared(&k);698				}699			}700			Secret::List {} => {701				let _span = info_span!("loading secrets").entered();702				let configured = config.list_configured_shared().await?;703				#[derive(Tabled)]704				struct SecretDisplay {705					#[tabled(rename = "Name")]706					name: String,707					#[tabled(rename = "Owners")]708					owners: String,709				}710				let mut table = vec![];711				for name in configured.iter().cloned() {712					let config = config.clone();713					let expected_owners = config.shared_secret_expected_owners(&name).await?;714					let data = config.shared_secret(&name)?;715					let owners = data716						.owners717						.iter()718						.map(|o| {719							if expected_owners.contains(o) {720								o.green().to_string()721							} else {722								o.red().to_string()723							}724						})725						.collect::<Vec<_>>();726					table.push(SecretDisplay {727						owners: owners.join(", "),728						name,729					})730				}731				info!("loaded\n{}", Table::new(table).to_string())732			}733			Secret::Edit {734				name,735				machine,736				part,737				add,738			} => {739				let secret = config.host_secret(&machine, &name)?;740				if let Some(data) = secret.parts.get(&part) {741					let host = config.host(&machine).await?;742					let secret = host.decrypt(data.raw.clone()).await?;743					String::from_utf8(secret).context("secret is not utf8")?744				} else if add {745					String::new()746				} else {747					bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");748				};749			}750		}751		Ok(())752	}753}754755/*756async fn edit_temp_file(757	builder: tempfile::Builder<'_, '_>,758	r: Vec<u8>,759	header: &str,760	comment: &str,761) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {762	if !stdin().is_tty() {763		// TODO: Also try to open /dev/tty directly?764		bail!("stdin is not tty, can't open editor");765	}766767	use std::fmt::Write;768	let mut file = builder.tempfile()?;769770	let mut full_header = String::new();771	let mut had = false;772	for line in header.trim_end().lines() {773		had = true;774		writeln!(&mut full_header, "{comment}{line}")?;775	}776	if had {777		writeln!(&mut full_header, "{}", comment.trim_end())?;778	}779	writeln!(780		&mut full_header,781		"{comment}Do not touch this header! It will be removed automatically"782	)?;783784	file.write_all(full_header.as_bytes())?;785	file.write_all(&r)?;786787	let abs_path = file.into_temp_path();788	let editor = std::env::var_os("VISUAL")789		.or_else(|| std::env::var_os("EDITOR"))790		.unwrap_or_else(|| "vi".into());791	let editor_args = shlex::bytes::split(editor.as_encoded_bytes())792		.ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;793	let editor_args = editor_args794		.into_iter()795		.map(|v| {796			// Only ASCII subsequences are replaced797			unsafe { OsString::from_encoded_bytes_unchecked(v) }798		})799		.collect_vec();800	let Some((editor, args)) = editor_args.split_first() else {801		bail!("EDITOR env var has no command");802	};803	let mut command = Command::new(editor);804	command.args(args);805806	let path_arg = abs_path.canonicalize()?;807808	// TODO: Save full state, using tcget/_getmode/_setmode809	let was_raw = terminal::is_raw_mode_enabled()?;810	terminal::enable_raw_mode()?;811812	let status = command.arg(path_arg).status().await;813814	if !was_raw {815		terminal::disable_raw_mode()?;816	}817818	let success = match status {819		Ok(s) => s.success(),820		Err(e) if e.kind() == io::ErrorKind::NotFound => {821			bail!("editor not found")822		}823		Err(e) => bail!("editor spawn error: {e}"),824	};825826	let mut file = std::fs::read(&abs_path).context("read editor output")?;827	let Some(v) = file.strip_prefix(full_header.as_bytes()) else {828		todo!();829	};830	todo!();831832	// Ok((success, abs_path))833}834*/