git.delta.rocks / jrsonnet / refs/commits / 71200382b2ad

difftreelog

feat log failed secret names

onryxsmxYaroslav Bolyukin2025-10-18parent: #6ea54a7.patch.diff
in: trunk

1 file changed

modifiedcmds/install-secrets/src/main.rsdiffbeforeafterboth
before · cmds/install-secrets/src/main.rs
1use std::{2	collections::{BTreeMap, HashMap},3	fs::{self, File},4	io::{self, Cursor, Read, Write},5	iter,6	os::unix::prelude::PermissionsExt,7	path::{Path, PathBuf},8	str::{FromStr, from_utf8},9};1011use age::{12	Decryptor, Encryptor, Identity, Recipient,13	ssh::{Identity as SshIdentity, Recipient as SshRecipient},14};15use anyhow::{Context, Result, anyhow, bail, ensure};16use clap::Parser;17use fleet_shared::SecretData;18use nix::unistd::{Group, User, chown};19use serde::Deserialize;20use tracing::{error, info, info_span};21use tracing_subscriber::{EnvFilter, filter::LevelFilter};2223#[derive(Parser)]24#[clap(author)]25enum Opts {26	/// Install secrets from json specification27	Install { data: PathBuf },28	/// Reencrypt secret using host key, outputting in fleet encoded string29	Reencrypt {30		#[clap(long)]31		secret: SecretData,32		#[clap(long)]33		targets: Vec<String>,34	},35	/// Decrypt secret using host key, outputting in fleet encoded string36	Decrypt {37		#[clap(long)]38		secret: SecretData,39		/// Shoult decoded output be printed as plaintext, instead of z85?40		#[clap(long)]41		plaintext: bool,42	},43}4445#[derive(Deserialize)]46#[serde(rename_all = "camelCase")]47struct Part {48	raw: SecretData,49	path: PathBuf,50	stable_path: PathBuf,51}5253#[derive(Deserialize)]54#[serde(rename_all = "camelCase")]55struct DataItem {56	group: String,57	mode: String,58	owner: String,59	root_path: Option<PathBuf>,6061	#[serde(flatten)]62	parts: BTreeMap<String, Part>,63}6465type Data = HashMap<String, DataItem>;6667fn decrypt(input: &SecretData, identity: &dyn Identity) -> Result<Vec<u8>> {68	ensure!(input.encrypted, "passed data is not encrypted!");69	let mut input = Cursor::new(&input.data);70	let decryptor = Decryptor::new(&mut input).context("failed to init decryptor")?;71	if decryptor.is_scrypt() {72		bail!("should be recipients");73	}74	let mut decryptor = decryptor75		.decrypt(iter::once(identity as &dyn age::Identity))76		.context("failed to decrypt, wrong key?")?;7778	let mut decrypted = Vec::new();79	decryptor80		.read_to_end(&mut decrypted)81		.context("failed to decrypt")?;82	Ok(decrypted)83}84fn encrypt(input: &[u8], targets: Vec<String>) -> Result<SecretData> {85	let recipients = targets86		.into_iter()87		.map(|t| {88			SshRecipient::from_str(&t).map_err(|e| anyhow!("failed to parse recipient: {e:?}"))89		})90		.collect::<Result<Vec<SshRecipient>>>()?;91	let recipients = recipients.iter().map(|v| v as &dyn Recipient);92	let mut encrypted = vec![];93	let mut encryptor = Encryptor::with_recipients(recipients)94		.expect("recipients provided")95		.wrap_output(&mut encrypted)96		.expect("constructor should not fail");97	io::copy(&mut Cursor::new(input), &mut encryptor).expect("copy should not fail");98	encryptor.finish().context("failed to finish encryption")?;99	Ok(SecretData {100		data: encrypted,101		encrypted: true,102	})103}104105fn init_part(identity: &dyn Identity, item: &DataItem, value: &Part) -> Result<()> {106	let stable_dir = value.stable_path.parent().expect("not root");107108	// Right now stable & non-stable data are both located in this dir.109	std::fs::create_dir_all(stable_dir)?;110111	let mut stable_temp =112		tempfile::NamedTempFile::new_in(stable_dir).context("failed to create tempfile")?;113	let mut hashed = File::create(&value.path)?;114115	let private = value.raw.encrypted;116	let data = if private {117		decrypt(&value.raw, identity)?118	} else {119		value.raw.data.to_owned()120	};121122	hashed.write_all(&data)?;123	hashed.flush()?;124	stable_temp.write_all(&data)?;125	stable_temp.flush()?;126127	let mode = if private {128		fs::Permissions::from_mode(129			u32::from_str_radix(&item.mode, 8).context("failed to parse mode as octal")?,130		)131	} else {132		fs::Permissions::from_mode(0o444)133	};134	fs::set_permissions(stable_temp.path(), mode.clone()).context("stable temp mode")?;135	fs::set_permissions(&value.path, mode).context("hashed mode")?;136137	// Files are initially owned by root, thus making set mode first inaccessible to user, and then138	// altering user/group.139	if private {140		let user = User::from_name(&item.owner)141			.context("failed to get user")?142			.ok_or_else(|| anyhow!("user not found"))?;143		let group = Group::from_name(&item.group)144			.context("failed to get group")?145			.ok_or_else(|| anyhow!("group not found"))?;146147		chown(stable_temp.path(), Some(user.uid), Some(group.gid))148			.context("failed to apply user/group")?;149		chown(&value.path, Some(user.uid), Some(group.gid))150			.context("failed to apply user/group")?;151	}152153	stable_temp154		.persist(&value.stable_path)155		.context("stable persist")?;156	Ok(())157}158159fn init_secret(identity: &age::ssh::Identity, value: &DataItem) -> Result<()> {160	if let Some(root_path) = &value.root_path {161		if !fs::metadata(root_path).map(|m| m.is_dir()).unwrap_or(false) {162			fs::create_dir(root_path).context("failed to create secret directory")?;163		}164	}165	let mut errored = false;166	for (part_id, part) in value.parts.iter() {167		let _span = info_span!("part", part_id = part_id);168		if let Err(e) = init_part(identity, value, part) {169			error!("failed to init part {part_id}: {e}");170			errored = true;171		}172	}173174	ensure!(!errored, "some secret parts have failed to initialize");175	Ok(())176}177178fn host_identity() -> anyhow::Result<SshIdentity> {179	let identity = SshIdentity::from_buffer(180		&mut Cursor::new(181			fs::read("/etc/ssh/ssh_host_ed25519_key").context("failed to read host private key")?,182		),183		None,184	)185	.context("failed to parse identity")?;186	Ok(identity)187}188189fn install(data: &Path) -> anyhow::Result<()> {190	let data = fs::read(data).context("failed to read secrets data")?;191	let data_str = from_utf8(&data).context("failed to read data to string")?;192	let data: Data = serde_json::from_str(data_str).context("failed to parse data")?;193194	if !fs::metadata("/run/secrets")195		.map(|m| m.is_dir())196		.unwrap_or(false)197	{198		fs::create_dir("/run/secrets").context("failed to create secrets directory")?;199	}200201	if data.is_empty() {202		info!("no secrets to install");203		return Ok(());204	}205206	let identity = host_identity()?;207208	let mut failed = false;209	for (name, value) in data {210		let _span = info_span!("init", name = name);211		if let Err(e) = init_secret(&identity, &value) {212			error!("secret failed to initialize: {e}");213			failed = true;214		}215	}216	if failed {217		bail!("one or more secrets failed");218	}219220	Ok(())221}222223fn main() -> anyhow::Result<()> {224	tracing_subscriber::fmt()225		.with_env_filter(226			EnvFilter::builder()227				.with_default_directive(LevelFilter::INFO.into())228				.from_env_lossy(),229		)230		.without_time()231		.with_target(false)232		.init();233234	let opts = Opts::parse();235236	match opts {237		Opts::Install { data } => install(&data),238		Opts::Reencrypt { secret, targets } => {239			let identity = host_identity()?;240			let decrypted = decrypt(&secret, &identity).context("during decryption")?;241			let encrypted = encrypt(&decrypted, targets).context("during re-encryption")?;242243			println!("{encrypted}");244			Ok(())245		}246		Opts::Decrypt { secret, plaintext } => {247			let identity = host_identity()?;248			let decrypted = decrypt(&secret, &identity).context("during decryption")?;249250			if plaintext {251				let s = String::from_utf8(decrypted).context("output is not utf8")?;252				print!("{s}");253			} else {254				println!(255					"{}",256					SecretData {257						data: decrypted,258						encrypted: false259					}260				);261			}262			Ok(())263		}264	}265}