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

difftreelog

source

cmds/fleet/src/cmds/secrets/mod.rs21.4 KiBsourcehistory
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		machines: Vec<String>,36		/// Override secret if already present37		#[clap(long)]38		force: bool,39		/// Secret public part40		#[clap(long)]41		public: Option<String>,42		/// How to name public secret part43		#[clap(long, default_value = "public")]44		public_name: String,45		/// Load public part from specified file46		#[clap(long)]47		public_file: Option<PathBuf>,4849		/// Create a notification on secret expiration50		#[clap(long)]51		expires_at: Option<DateTime<Utc>>,5253		/// Secret with this name already exists, override its value while keeping the same owners.54		#[clap(long)]55		re_add: bool,5657		#[clap(default_value = "secret")]58		part_name: String,59	},60	/// Add secret, data should be provided in stdin61	Add {62		/// Secret name63		name: String,64		/// Secret owners65		machine: String,66		/// Override secret if already present67		#[clap(long)]68		force: bool,69		/// Secret public part70		#[clap(long)]71		public: Option<String>,72		/// How to name public secret part73		#[clap(long, default_value = "public")]74		public_name: String,75		/// Load public part from specified file76		#[clap(long)]77		public_file: Option<PathBuf>,7879		#[clap(default_value = "secret")]80		part_name: String,81	},82	/// Read secret from remote host, requires sudo on said host83	Read {84		name: String,85		machine: String,8687		#[clap(default_value = "secret")]88		part_name: String,89	},90	UpdateShared {91		name: String,9293		#[clap(long)]94		machines: Option<Vec<String>>,9596		#[clap(long)]97		add_machines: Vec<String>,98		#[clap(long)]99		remove_machines: Vec<String>,100101		/// Which host should we use to decrypt102		#[clap(long)]103		prefer_identities: Vec<String>,104105		#[clap(default_value = "secret")]106		part_name: String,107	},108	Regenerate {109		/// Which host should we use to decrypt, in case if reencryption is required, without110		/// regeneration111		#[clap(long)]112		prefer_identities: Vec<String>,113	},114	List {},115	Edit {116		name: String,117		machine: String,118119		#[clap(default_value = "secret")]120		part: String,121122		#[clap(long)]123		add: bool,124	},125}126127#[tracing::instrument(skip(config, secret, field, prefer_identities))]128async fn update_owner_set(129	secret_name: &str,130	config: &Config,131	mut secret: FleetSharedSecret,132	field: Value,133	updated_set: &[String],134	prefer_identities: &[String],135) -> Result<FleetSharedSecret> {136	let original_set = secret.owners.clone();137138	let set = original_set.iter().collect::<BTreeSet<_>>();139	let expected_set = updated_set.iter().collect::<BTreeSet<_>>();140141	if set == expected_set {142		info!("no need to update owner list, it is already correct");143		return Ok(secret);144	}145146	let should_regenerate = if set.difference(&expected_set).next().is_some() {147		// TODO: Remove this warning for revokable secrets.148		warn!("host was removed from secret owners, but until this host rebuild, the secret will still be stored on it.");149		nix_go_json!(field.regenerateOnOwnerRemoved)150	} else if expected_set.difference(&set).next().is_some() {151		nix_go_json!(field.regenerateOnOwnerAdded)152	} else {153		false154	};155156	if should_regenerate {157		info!("secret is owner-dependent, will regenerate");158		let generated = generate_shared(config, secret_name, field, updated_set.to_vec()).await?;159		Ok(generated)160	} else {161		let identity_holder = if !prefer_identities.is_empty() {162			prefer_identities163				.iter()164				.find(|i| original_set.iter().any(|s| s == *i))165		} else {166			secret.owners.first()167		};168		let Some(identity_holder) = identity_holder else {169			bail!("no available holder found");170		};171172		for (part_name, part) in secret.secret.parts.iter_mut() {173			let _span = info_span!("part reencryption", part_name);174			if !part.raw.encrypted {175				continue;176			}177			let host = config.host(identity_holder).await?;178			let encrypted = host179				.reencrypt(part.raw.clone(), updated_set.to_vec())180				.await?;181			part.raw = encrypted;182		}183184		secret.owners = updated_set.to_vec();185		Ok(secret)186	}187}188189#[derive(Deserialize)]190#[serde(rename_all = "camelCase")]191enum GeneratorKind {192	Impure,193	Pure,194}195196async fn generate_pure(197	_config: &Config,198	_display_name: &str,199	_secret: Value,200	_default_generator: Value,201	_owners: &[String],202) -> Result<FleetSecret> {203	bail!("pure generators are broken for now")204}205async fn generate_impure(206	config: &Config,207	_display_name: &str,208	secret: Value,209	default_generator: Value,210	owners: &[String],211) -> Result<FleetSecret> {212	let generator = nix_go!(secret.generator);213	let on: Option<String> = nix_go_json!(default_generator.impureOn);214215	let host = if let Some(on) = &on {216		config.host(on).await?217	} else {218		config.local_host()219	};220	let on_pkgs = host.pkgs().await?;221	let call_package = nix_go!(on_pkgs.callPackage);222	let mk_encrypt_secret = nix_go!(on_pkgs.mkEncryptSecret);223224	let mut recipients = Vec::new();225	for owner in owners {226		let key = config.key(owner).await?;227		recipients.push(key);228	}229	let encrypt = nix_go!(mk_encrypt_secret(Obj {230		recipients: { recipients },231	}));232233	let generator = nix_go!(call_package(generator)(Obj {234		encrypt,235		// rustfmt_please_newline236	}));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	// Generators provide additional information in passthru, to access308	// passthru we should call generator, but information about where this generator is supposed to build309	// is located in passthru... Thus evaluating generator on host.310	//311	// Maybe it is also possible to do some magic with __functor?312	//313	// I don't want to make modules always responsible for additional secret data anyway,314	// so it should be in derivation, and not in the secret data itself.315	let default_generator = nix_go!(default_call_package(generator)(Obj {316		encrypt: { "exit 1" },317		// rustfmt_please_newline318	}));319320	let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);321322	match kind {323		GeneratorKind::Impure => {324			generate_impure(config, display_name, secret, default_generator, owners).await325		}326		GeneratorKind::Pure => {327			generate_pure(config, display_name, secret, default_generator, owners).await328		}329	}330}331async fn generate_shared(332	config: &Config,333	display_name: &str,334	secret: Value,335	expected_owners: Vec<String>,336) -> Result<FleetSharedSecret> {337	// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);338	Ok(FleetSharedSecret {339		secret: generate(config, display_name, secret, &expected_owners).await?,340		owners: expected_owners,341	})342}343344async fn parse_public(345	public: Option<String>,346	public_file: Option<PathBuf>,347) -> Result<Option<SecretData>> {348	Ok(match (public, public_file) {349		(Some(v), None) => Some(SecretData {350			data: v.into(),351			encrypted: false,352		}),353		(None, Some(v)) => Some(SecretData {354			data: read(v).await?,355			encrypted: false,356		}),357		(Some(_), Some(_)) => {358			bail!("only public or public_file should be set")359		}360		(None, None) => None,361	})362}363364async fn parse_secret() -> Result<Option<Vec<u8>>> {365	let mut input = vec![];366	io::stdin().read_to_end(&mut input)?;367	if input.is_empty() {368		Ok(None)369	} else {370		Ok(Some(input))371	}372}373374fn parse_machines(375	initial: Vec<String>,376	machines: Option<Vec<String>>,377	mut add_machines: Vec<String>,378	mut remove_machines: Vec<String>,379) -> Result<Vec<String>> {380	if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {381		bail!("no operation");382	}383384	let initial_machines = initial.clone();385	let mut target_machines = initial;386	info!("Currently encrypted for {initial_machines:?}");387388	// ensure!(machines.is_some() || !add_machines.is_empty() || )389	if let Some(machines) = machines {390		ensure!(391			add_machines.is_empty() && remove_machines.is_empty(),392			"can't combine --machines and --add-machines/--remove-machines"393		);394		let target = initial_machines.iter().collect::<HashSet<_>>();395		let source = machines.iter().collect::<HashSet<_>>();396		for removed in target.difference(&source) {397			remove_machines.push((*removed).clone());398		}399		for added in source.difference(&target) {400			add_machines.push((*added).clone());401		}402	}403404	for machine in &remove_machines {405		let mut removed = false;406		while let Some(pos) = target_machines.iter().position(|m| m == machine) {407			target_machines.swap_remove(pos);408			removed = true;409		}410		if !removed {411			warn!("secret is not enabled for {machine}");412		}413	}414	for machine in &add_machines {415		if target_machines.iter().any(|m| m == machine) {416			warn!("secret is already added to {machine}");417		} else {418			target_machines.push(machine.to_owned());419		}420	}421	if !remove_machines.is_empty() {422		// TODO: maybe force secret regeneration?423		// Not that useful without revokation.424		warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");425	}426	Ok(target_machines)427}428impl Secret {429	pub async fn run(self, config: &Config) -> Result<()> {430		match self {431			Secret::ForceKeys => {432				for host in config.list_hosts().await? {433					if config.should_skip(&host.name) {434						continue;435					}436					config.key(&host.name).await?;437				}438			}439			Secret::AddShared {440				mut machines,441				name,442				force,443				public,444				public_name,445				public_file,446				expires_at,447				re_add,448				part_name,449			} => {450				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).451452				let exists = config.has_shared(&name);453				if exists && !force && !re_add {454					bail!("secret already defined");455				}456				if re_add {457					// Fixme: use clap to limit this usage458					ensure!(!force, "--force and --readd are not compatible");459					ensure!(exists, "secret doesn't exists");460					ensure!(461						machines.is_empty(),462						"you can't use machines argument for --readd"463					);464					let shared = config.shared_secret(&name)?;465					machines = shared.owners;466				}467468				let recipients = config.recipients(machines.clone()).await?;469470				let mut parts = BTreeMap::new();471472				let mut input = vec![];473				io::stdin().read_to_end(&mut input)?;474475				if !input.is_empty() {476					let encrypted = encrypt_secret_data(recipients, input)477						.ok_or_else(|| anyhow!("no recipients provided"))?;478					parts.insert(part_name, FleetSecretPart { raw: encrypted });479				}480481				if let Some(public) = parse_public(public, public_file).await? {482					parts.insert(public_name, FleetSecretPart { raw: public });483				}484485				config.replace_shared(486					name,487					FleetSharedSecret {488						owners: machines,489						secret: FleetSecret {490							created_at: Utc::now(),491							expires_at,492							parts,493						},494					},495				);496			}497			Secret::Add {498				machine,499				name,500				force,501				public,502				public_name,503				public_file,504				part_name,505			} => {506				if config.has_secret(&machine, &name) && !force {507					bail!("secret already defined");508				}509510				let mut parts = BTreeMap::new();511512				if let Some(secret) = parse_secret().await? {513					let recipient = config.recipient(&machine).await?;514					let encrypted =515						encrypt_secret_data(vec![recipient], secret).expect("recipient provided");516					parts.insert(part_name, FleetSecretPart { raw: encrypted });517				}518519				if let Some(public) = parse_public(public, public_file).await? {520					parts.insert(public_name, FleetSecretPart { raw: public });521				};522523				config.insert_secret(524					&machine,525					name,526					FleetSecret {527						created_at: Utc::now(),528						expires_at: None,529						parts,530					},531				);532			}533			#[allow(clippy::await_holding_refcell_ref)]534			Secret::Read {535				name,536				machine,537				part_name,538			} => {539				let secret = config.host_secret(&machine, &name)?;540				let Some(secret) = secret.parts.get(&part_name) else {541					bail!("no part {part_name} in secret {name}");542				};543				let data = if secret.raw.encrypted {544					let host = config.host(&machine).await?;545					host.decrypt(secret.raw.clone()).await?546				} else {547					secret.raw.data.clone()548				};549550				stdout().write_all(&data)?;551			}552			Secret::UpdateShared {553				name,554				machines,555				add_machines,556				remove_machines,557				prefer_identities,558				part_name,559			} => {560				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).561562				let secret = config.shared_secret(&name)?;563				if secret.secret.parts.get(&part_name).is_none() {564					bail!("no secret");565				}566567				let initial_machines = secret.owners.clone();568				let target_machines = parse_machines(569					initial_machines.clone(),570					machines,571					add_machines,572					remove_machines,573				)?;574575				if target_machines.is_empty() {576					info!("no machines left for secret, removing it");577					config.remove_shared(&name);578					return Ok(());579				}580581				let config_field = &config.config_unchecked_field;582				let field = nix_go!(config_field.sharedSecrets[{ name }]);583584				let updated = update_owner_set(585					&name,586					config,587					secret,588					field,589					&target_machines,590					&prefer_identities,591				)592				.await?;593				config.replace_shared(name, updated);594			}595			Secret::Regenerate { prefer_identities } => {596				info!("checking for secrets to regenerate");597				{598					let _span = info_span!("shared").entered();599					let expected_shared_set = config600						.list_configured_shared()601						.await?602						.into_iter()603						.collect::<HashSet<_>>();604					let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();605					for missing in expected_shared_set.difference(&shared_set) {606						let config_field = &config.config_unchecked_field;607						let secret = nix_go!(config_field.sharedSecrets[{ missing }]);608						let expected_owners: Option<Vec<String>> =609							nix_go_json!(secret.expectedOwners);610						let Some(expected_owners) = expected_owners else {611							// TODO: Might still need to regenerate612							continue;613						};614						info!("generating secret: {missing}");615						let shared = generate_shared(config, missing, secret, expected_owners)616							.in_current_span()617							.await?;618						config.replace_shared(missing.to_string(), shared)619					}620				}621				for host in config.list_hosts().await? {622					if config.should_skip(&host.name) {623						continue;624					}625626					let _span = info_span!("host", host = host.name).entered();627					let expected_set = host628						.list_configured_secrets()629						.in_current_span()630						.await?631						.into_iter()632						.collect::<HashSet<_>>();633					let stored_set = config634						.list_secrets(&host.name)635						.into_iter()636						.collect::<HashSet<_>>();637					for missing in expected_set.difference(&stored_set) {638						info!("generating secret: {missing}");639						let secret = host.secret_field(missing).in_current_span().await?;640						let generated =641							match generate(config, missing, secret, &[host.name.clone()])642								.in_current_span()643								.await644							{645								Ok(v) => v,646								Err(e) => {647									error!("{e:?}");648									continue;649								}650							};651						config.insert_secret(&host.name, missing.to_string(), generated)652					}653				}654				let mut to_remove = Vec::new();655				for name in &config.list_shared() {656					info!("updating secret: {name}");657					let data = config.shared_secret(name)?;658					let config_field = &config.config_unchecked_field;659					let expected_owners: Vec<String> =660						nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);661					if expected_owners.is_empty() {662						warn!("secret was removed from fleet config: {name}, removing from data");663						to_remove.push(name.to_string());664						continue;665					}666667					let secret = nix_go!(config_field.sharedSecrets[{ name }]);668					config.replace_shared(669						name.to_owned(),670						update_owner_set(671							name,672							config,673							data,674							secret,675							&expected_owners,676							&prefer_identities,677						)678						.await?,679					);680				}681				for k in to_remove {682					config.remove_shared(&k);683				}684			}685			Secret::List {} => {686				let _span = info_span!("loading secrets").entered();687				let configured = config.list_configured_shared().await?;688				#[derive(Tabled)]689				struct SecretDisplay {690					#[tabled(rename = "Name")]691					name: String,692					#[tabled(rename = "Owners")]693					owners: String,694				}695				let mut table = vec![];696				for name in configured.iter().cloned() {697					let config = config.clone();698					let expected_owners = config.shared_secret_expected_owners(&name).await?;699					let data = config.shared_secret(&name)?;700					let owners = data701						.owners702						.iter()703						.map(|o| {704							if expected_owners.contains(o) {705								o.green().to_string()706							} else {707								o.red().to_string()708							}709						})710						.collect::<Vec<_>>();711					table.push(SecretDisplay {712						owners: owners.join(", "),713						name,714					})715				}716				info!("loaded\n{}", Table::new(table).to_string())717			}718			Secret::Edit {719				name,720				machine,721				part,722				add,723			} => {724				let secret = config.host_secret(&machine, &name)?;725				if let Some(data) = secret.parts.get(&part) {726					let host = config.host(&machine).await?;727					let secret = host.decrypt(data.raw.clone()).await?;728					String::from_utf8(secret).context("secret is not utf8")?729				} else if add {730					String::new()731				} else {732					bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");733				};734			}735		}736		Ok(())737	}738}739740async fn edit_temp_file(741	builder: tempfile::Builder<'_, '_>,742	r: Vec<u8>,743	header: &str,744	comment: &str,745) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {746	if !stdin().is_tty() {747		// TODO: Also try to open /dev/tty directly?748		bail!("stdin is not tty, can't open editor");749	}750751	use std::fmt::Write;752	let mut file = builder.tempfile()?;753754	let mut full_header = String::new();755	let mut had = false;756	for line in header.trim_end().lines() {757		had = true;758		writeln!(&mut full_header, "{comment}{line}")?;759	}760	if had {761		writeln!(&mut full_header, "{}", comment.trim_end())?;762	}763	writeln!(764		&mut full_header,765		"{comment}Do not touch this header! It will be removed automatically"766	)?;767768	file.write_all(full_header.as_bytes())?;769	file.write_all(&r)?;770771	let abs_path = file.into_temp_path();772	let editor = std::env::var_os("VISUAL")773		.or_else(|| std::env::var_os("EDITOR"))774		.unwrap_or_else(|| "vi".into());775	let editor_args = shlex::bytes::split(editor.as_encoded_bytes())776		.ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;777	let editor_args = editor_args778		.into_iter()779		.map(|v| {780			// Only ASCII subsequences are replaced781			unsafe { OsString::from_encoded_bytes_unchecked(v) }782		})783		.collect_vec();784	let Some((editor, args)) = editor_args.split_first() else {785		bail!("EDITOR env var has no command");786	};787	let mut command = Command::new(editor);788	command.args(args);789790	let path_arg = abs_path.canonicalize()?;791792	// TODO: Save full state, using tcget/_getmode/_setmode793	let was_raw = terminal::is_raw_mode_enabled()?;794	terminal::enable_raw_mode()?;795796	let status = command.arg(path_arg).status().await;797798	if !was_raw {799		terminal::disable_raw_mode()?;800	}801802	let success = match status {803		Ok(s) => s.success(),804		Err(e) if e.kind() == io::ErrorKind::NotFound => {805			bail!("editor not found")806		}807		Err(e) => bail!("editor spawn error: {e}"),808	};809810	let mut file = std::fs::read(&abs_path).context("read editor output")?;811	let Some(v) = file.strip_prefix(full_header.as_bytes()) else {812		todo!();813	};814	todo!();815816	// Ok((success, abs_path))817}