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

difftreelog

feat fleet secret add --merge cli flag

Yaroslav Bolyukin2024-07-05parent: #0528ea1.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	ffi::OsString,4	io::{self, stdin, stdout, Read, Write},5	path::PathBuf,6};78use anyhow::{anyhow, bail, ensure, Context, Result};9use chrono::{DateTime, Utc};10use clap::Parser;11use crossterm::{terminal, tty::IsTty};12use fleet_shared::SecretData;13use itertools::Itertools;14use nix_eval::{nix_go, nix_go_json, Value};15use owo_colors::OwoColorize;16use serde::Deserialize;17use tabled::{Table, Tabled};18use tokio::{fs::read, process::Command};19use tracing::{error, info, info_span, warn, Instrument};2021use crate::{22	fleetdata::{encrypt_secret_data, FleetSecret, FleetSecretPart, FleetSharedSecret},23	host::Config,24};2526#[derive(Parser)]27pub enum Secret {28	/// Force load host keys for all defined hosts29	ForceKeys,30	/// Add secret, data should be provided in stdin31	AddShared {32		/// Secret name33		name: String,34		/// Secret owners35		#[clap(long, short)]36		machines: Vec<String>,37		/// Override secret if already present38		#[clap(long)]39		force: bool,40		/// Secret public part41		#[clap(long)]42		public: Option<String>,43		/// Load public part from specified file44		#[clap(long)]45		public_file: Option<PathBuf>,4647		/// Create a notification on secret expiration48		#[clap(long)]49		expires_at: Option<DateTime<Utc>>,5051		/// Secret with this name already exists, override its value while keeping the same owners.52		#[clap(long)]53		re_add: bool,5455		/// How to name public secret part56		#[clap(long, short = 'p', default_value = "public")]57		public_part: String,58		/// How to name private secret part59		#[clap(short = 's', long, default_value = "secret")]60		part: String,61	},62	/// Add secret, data should be provided in stdin63	Add {64		/// Secret name65		name: String,66		/// Secret owner67		#[clap(short = 'm', long)]68		machine: String,69		/// Override secret if already present70		#[clap(long)]71		force: 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 {235		recipients: { recipients },236	}));237238	let generator = nix_go!(call_package(generator)(generators));239240	let generator = generator.build().await?;241	let generator = generator242		.get("out")243		.ok_or_else(|| anyhow!("missing generateImpure out"))?;244	let generator = host.remote_derivation(generator).await?;245246	let out_parent = host.mktemp_dir().await?;247	let out = format!("{out_parent}/out");248249	let mut gen = host.cmd(generator).await?;250	gen.env("out", &out);251	if on.is_none() {252		// This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.253		let project_path: String = config254			.directory255			.clone()256			.into_os_string()257			.into_string()258			.map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;259		gen.env("FLEET_PROJECT", project_path);260	}261	gen.run().await.context("impure generator")?;262263	{264		let marker = host.read_file_text(format!("{out}/marker")).await?;265		ensure!(marker == "SUCCESS", "generation not succeeded");266	}267268	let mut parts = BTreeMap::new();269	for part in host.read_dir(&out).await? {270		if part == "created_at" || part == "expired_at" || part == "marker" {271			continue;272		}273		let contents: SecretData = host274			.read_file_text(format!("{out}/{part}"))275			.await?276			.parse()277			.map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;278		parts.insert(part.to_owned(), FleetSecretPart { raw: contents });279	}280281	let created_at = host.read_file_value(format!("{out}/created_at")).await?;282	let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();283284	Ok(FleetSecret {285		created_at,286		expires_at,287		parts,288	})289}290async fn generate(291	config: &Config,292	display_name: &str,293	secret: Value,294	owners: &[String],295) -> Result<FleetSecret> {296	let generator = nix_go!(secret.generator);297	// Can't properly check on nix module system level298	{299		let gen_ty = generator.type_of().await?;300		if gen_ty == "null" {301			bail!("secret has no generator defined, can't automatically generate it.");302		}303		if gen_ty != "lambda" {304			bail!("generator should be lambda, got {gen_ty}");305		}306	}307	let default_pkgs = &config.default_pkgs;308	let default_call_package = nix_go!(default_pkgs.callPackage);309	let default_mk_secret_generators = nix_go!(default_pkgs.mkSecretGenerators);310	// Generators provide additional information in passthru, to access311	// passthru we should call generator, but information about where this generator is supposed to build312	// is located in passthru... Thus evaluating generator on host.313	//314	// Maybe it is also possible to do some magic with __functor?315	//316	// I don't want to make modules always responsible for additional secret data anyway,317	// so it should be in derivation, and not in the secret data itself.318	let generators = nix_go!(default_mk_secret_generators(Obj {319		recipients: { <Vec<String>>::new() },320	}));321	let default_generator = nix_go!(default_call_package(generator)(generators));322323	let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);324325	match kind {326		GeneratorKind::Impure => {327			generate_impure(config, display_name, secret, default_generator, owners).await328		}329		GeneratorKind::Pure => {330			generate_pure(config, display_name, secret, default_generator, owners).await331		}332	}333}334async fn generate_shared(335	config: &Config,336	display_name: &str,337	secret: Value,338	expected_owners: Vec<String>,339) -> Result<FleetSharedSecret> {340	// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);341	Ok(FleetSharedSecret {342		secret: generate(config, display_name, secret, &expected_owners).await?,343		owners: expected_owners,344	})345}346347async fn parse_public(348	public: Option<String>,349	public_file: Option<PathBuf>,350) -> Result<Option<SecretData>> {351	Ok(match (public, public_file) {352		(Some(v), None) => Some(SecretData {353			data: v.into(),354			encrypted: false,355		}),356		(None, Some(v)) => Some(SecretData {357			data: read(v).await?,358			encrypted: false,359		}),360		(Some(_), Some(_)) => {361			bail!("only public or public_file should be set")362		}363		(None, None) => None,364	})365}366367async fn parse_secret() -> Result<Option<Vec<u8>>> {368	let mut input = vec![];369	stdin().read_to_end(&mut input)?;370	if input.is_empty() {371		Ok(None)372	} else {373		Ok(Some(input))374	}375}376377fn parse_machines(378	initial: Vec<String>,379	machines: Option<Vec<String>>,380	mut add_machines: Vec<String>,381	mut remove_machines: Vec<String>,382) -> Result<Vec<String>> {383	if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {384		bail!("no operation");385	}386387	let initial_machines = initial.clone();388	let mut target_machines = initial;389	info!("Currently encrypted for {initial_machines:?}");390391	// ensure!(machines.is_some() || !add_machines.is_empty() || )392	if let Some(machines) = machines {393		ensure!(394			add_machines.is_empty() && remove_machines.is_empty(),395			"can't combine --machines and --add-machines/--remove-machines"396		);397		let target = initial_machines.iter().collect::<HashSet<_>>();398		let source = machines.iter().collect::<HashSet<_>>();399		for removed in target.difference(&source) {400			remove_machines.push((*removed).clone());401		}402		for added in source.difference(&target) {403			add_machines.push((*added).clone());404		}405	}406407	for machine in &remove_machines {408		let mut removed = false;409		while let Some(pos) = target_machines.iter().position(|m| m == machine) {410			target_machines.swap_remove(pos);411			removed = true;412		}413		if !removed {414			warn!("secret is not enabled for {machine}");415		}416	}417	for machine in &add_machines {418		if target_machines.iter().any(|m| m == machine) {419			warn!("secret is already added to {machine}");420		} else {421			target_machines.push(machine.to_owned());422		}423	}424	if !remove_machines.is_empty() {425		// TODO: maybe force secret regeneration?426		// Not that useful without revokation.427		warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");428	}429	Ok(target_machines)430}431impl Secret {432	pub async fn run(self, config: &Config) -> Result<()> {433		match self {434			Secret::ForceKeys => {435				for host in config.list_hosts().await? {436					if config.should_skip(&host.name) {437						continue;438					}439					config.key(&host.name).await?;440				}441			}442			Secret::AddShared {443				mut machines,444				name,445				force,446				public,447				public_part: public_name,448				public_file,449				expires_at,450				re_add,451				part: part_name,452			} => {453				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).454455				let exists = config.has_shared(&name);456				if exists && !force && !re_add {457					bail!("secret already defined");458				}459				if re_add {460					// Fixme: use clap to limit this usage461					ensure!(!force, "--force and --readd are not compatible");462					ensure!(exists, "secret doesn't exists");463					ensure!(464						machines.is_empty(),465						"you can't use machines argument for --readd"466					);467					let shared = config.shared_secret(&name)?;468					machines = shared.owners;469				}470471				let recipients = config.recipients(machines.clone()).await?;472473				let mut parts = BTreeMap::new();474475				let mut input = vec![];476				io::stdin().read_to_end(&mut input)?;477478				if !input.is_empty() {479					let encrypted = encrypt_secret_data(recipients, input)480						.ok_or_else(|| anyhow!("no recipients provided"))?;481					parts.insert(part_name, FleetSecretPart { raw: encrypted });482				}483484				if let Some(public) = parse_public(public, public_file).await? {485					parts.insert(public_name, FleetSecretPart { raw: public });486				}487488				config.replace_shared(489					name,490					FleetSharedSecret {491						owners: machines,492						secret: FleetSecret {493							created_at: Utc::now(),494							expires_at,495							parts,496						},497					},498				);499			}500			Secret::Add {501				machine,502				name,503				force,504				public,505				public_part: public_name,506				public_file,507				part: part_name,508			} => {509				if config.has_secret(&machine, &name) && !force {510					bail!("secret already defined");511				}512513				let mut parts = BTreeMap::new();514515				if let Some(secret) = parse_secret().await? {516					let recipient = config.recipient(&machine).await?;517					let encrypted =518						encrypt_secret_data(vec![recipient], secret).expect("recipient provided");519					parts.insert(part_name, FleetSecretPart { raw: encrypted });520				}521522				if let Some(public) = parse_public(public, public_file).await? {523					parts.insert(public_name, FleetSecretPart { raw: public });524				};525526				config.insert_secret(527					&machine,528					name,529					FleetSecret {530						created_at: Utc::now(),531						expires_at: None,532						parts,533					},534				);535			}536			#[allow(clippy::await_holding_refcell_ref)]537			Secret::Read {538				name,539				machine,540				part: part_name,541			} => {542				let secret = config.host_secret(&machine, &name)?;543				let Some(secret) = secret.parts.get(&part_name) else {544					bail!("no part {part_name} in secret {name}");545				};546				let data = if secret.raw.encrypted {547					let host = config.host(&machine).await?;548					host.decrypt(secret.raw.clone()).await?549				} else {550					secret.raw.data.clone()551				};552553				stdout().write_all(&data)?;554			}555			Secret::UpdateShared {556				name,557				machine,558				add_machine,559				remove_machine,560				prefer_identities,561			} => {562				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).563564				let secret = config.shared_secret(&name)?;565				if secret.secret.parts.values().all(|v| !v.raw.encrypted) {566					bail!("no secret");567				}568569				let initial_machines = secret.owners.clone();570				let target_machines = parse_machines(571					initial_machines.clone(),572					machine,573					add_machine,574					remove_machine,575				)?;576577				if target_machines.is_empty() {578					info!("no machines left for secret, removing it");579					config.remove_shared(&name);580					return Ok(());581				}582583				let config_field = &config.config_unchecked_field;584				let field = nix_go!(config_field.sharedSecrets[{ name }]);585586				let updated = update_owner_set(587					&name,588					config,589					secret,590					field,591					&target_machines,592					&prefer_identities,593				)594				.await?;595				config.replace_shared(name, updated);596			}597			Secret::Regenerate { prefer_identities } => {598				info!("checking for secrets to regenerate");599				{600					let _span = info_span!("shared").entered();601					let expected_shared_set = config602						.list_configured_shared()603						.await?604						.into_iter()605						.collect::<HashSet<_>>();606					let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();607					for missing in expected_shared_set.difference(&shared_set) {608						let config_field = &config.config_unchecked_field;609						let secret = nix_go!(config_field.sharedSecrets[{ missing }]);610						let expected_owners: Option<Vec<String>> =611							nix_go_json!(secret.expectedOwners);612						let Some(expected_owners) = expected_owners else {613							// TODO: Might still need to regenerate614							continue;615						};616						info!("generating secret: {missing}");617						let shared = generate_shared(config, missing, secret, expected_owners)618							.in_current_span()619							.await?;620						config.replace_shared(missing.to_string(), shared)621					}622				}623				for host in config.list_hosts().await? {624					if config.should_skip(&host.name) {625						continue;626					}627628					let _span = info_span!("host", host = host.name).entered();629					let expected_set = host630						.list_configured_secrets()631						.in_current_span()632						.await?633						.into_iter()634						.collect::<HashSet<_>>();635					let stored_set = config636						.list_secrets(&host.name)637						.into_iter()638						.collect::<HashSet<_>>();639					for missing in expected_set.difference(&stored_set) {640						info!("generating secret: {missing}");641						let secret = host.secret_field(missing).in_current_span().await?;642						let generated =643							match generate(config, missing, secret, &[host.name.clone()])644								.in_current_span()645								.await646							{647								Ok(v) => v,648								Err(e) => {649									error!("{e:?}");650									continue;651								}652							};653						config.insert_secret(&host.name, missing.to_string(), generated)654					}655				}656				let mut to_remove = Vec::new();657				for name in &config.list_shared() {658					info!("updating secret: {name}");659					let data = config.shared_secret(name)?;660					let config_field = &config.config_unchecked_field;661					let expected_owners: Vec<String> =662						nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);663					if expected_owners.is_empty() {664						warn!("secret was removed from fleet config: {name}, removing from data");665						to_remove.push(name.to_string());666						continue;667					}668669					let secret = nix_go!(config_field.sharedSecrets[{ name }]);670					config.replace_shared(671						name.to_owned(),672						update_owner_set(673							name,674							config,675							data,676							secret,677							&expected_owners,678							&prefer_identities,679						)680						.await?,681					);682				}683				for k in to_remove {684					config.remove_shared(&k);685				}686			}687			Secret::List {} => {688				let _span = info_span!("loading secrets").entered();689				let configured = config.list_configured_shared().await?;690				#[derive(Tabled)]691				struct SecretDisplay {692					#[tabled(rename = "Name")]693					name: String,694					#[tabled(rename = "Owners")]695					owners: String,696				}697				let mut table = vec![];698				for name in configured.iter().cloned() {699					let config = config.clone();700					let expected_owners = config.shared_secret_expected_owners(&name).await?;701					let data = config.shared_secret(&name)?;702					let owners = data703						.owners704						.iter()705						.map(|o| {706							if expected_owners.contains(o) {707								o.green().to_string()708							} else {709								o.red().to_string()710							}711						})712						.collect::<Vec<_>>();713					table.push(SecretDisplay {714						owners: owners.join(", "),715						name,716					})717				}718				info!("loaded\n{}", Table::new(table).to_string())719			}720			Secret::Edit {721				name,722				machine,723				part,724				add,725			} => {726				let secret = config.host_secret(&machine, &name)?;727				if let Some(data) = secret.parts.get(&part) {728					let host = config.host(&machine).await?;729					let secret = host.decrypt(data.raw.clone()).await?;730					String::from_utf8(secret).context("secret is not utf8")?731				} else if add {732					String::new()733				} else {734					bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");735				};736			}737		}738		Ok(())739	}740}741742async fn edit_temp_file(743	builder: tempfile::Builder<'_, '_>,744	r: Vec<u8>,745	header: &str,746	comment: &str,747) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {748	if !stdin().is_tty() {749		// TODO: Also try to open /dev/tty directly?750		bail!("stdin is not tty, can't open editor");751	}752753	use std::fmt::Write;754	let mut file = builder.tempfile()?;755756	let mut full_header = String::new();757	let mut had = false;758	for line in header.trim_end().lines() {759		had = true;760		writeln!(&mut full_header, "{comment}{line}")?;761	}762	if had {763		writeln!(&mut full_header, "{}", comment.trim_end())?;764	}765	writeln!(766		&mut full_header,767		"{comment}Do not touch this header! It will be removed automatically"768	)?;769770	file.write_all(full_header.as_bytes())?;771	file.write_all(&r)?;772773	let abs_path = file.into_temp_path();774	let editor = std::env::var_os("VISUAL")775		.or_else(|| std::env::var_os("EDITOR"))776		.unwrap_or_else(|| "vi".into());777	let editor_args = shlex::bytes::split(editor.as_encoded_bytes())778		.ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;779	let editor_args = editor_args780		.into_iter()781		.map(|v| {782			// Only ASCII subsequences are replaced783			unsafe { OsString::from_encoded_bytes_unchecked(v) }784		})785		.collect_vec();786	let Some((editor, args)) = editor_args.split_first() else {787		bail!("EDITOR env var has no command");788	};789	let mut command = Command::new(editor);790	command.args(args);791792	let path_arg = abs_path.canonicalize()?;793794	// TODO: Save full state, using tcget/_getmode/_setmode795	let was_raw = terminal::is_raw_mode_enabled()?;796	terminal::enable_raw_mode()?;797798	let status = command.arg(path_arg).status().await;799800	if !was_raw {801		terminal::disable_raw_mode()?;802	}803804	let success = match status {805		Ok(s) => s.success(),806		Err(e) if e.kind() == io::ErrorKind::NotFound => {807			bail!("editor not found")808		}809		Err(e) => bail!("editor spawn error: {e}"),810	};811812	let mut file = std::fs::read(&abs_path).context("read editor output")?;813	let Some(v) = file.strip_prefix(full_header.as_bytes()) else {814		todo!();815	};816	todo!();817818	// Ok((success, abs_path))819}