git.delta.rocks / fleet / refs/commits / 28094c98518f

difftreelog

source

crates/fleet-base/src/fleetdata.rs20.9 KiBsourcehistory
1use std::{2	cmp::Ordering,3	collections::{4		BTreeMap, BTreeSet,5		btree_map::{self, Entry},6	},7	fmt,8	io::{self, Cursor},9	str::FromStr,10	sync::RwLock,11};1213use age::Recipient;14use chrono::{DateTime, Utc};15use fleet_shared::SecretData;16use rand::{17	distr::{Alphanumeric, SampleString as _},18	rng,19};20use serde::{21	Deserialize, Serialize,22	de::{self, Error},23};24use serde_json::Value;25use tracing::info;2627#[derive(Serialize, Deserialize, Default)]28#[serde(rename_all = "camelCase")]29pub struct HostData {30	#[serde(default)]31	#[serde(skip_serializing_if = "String::is_empty")]32	pub encryption_key: String,33}3435const VERSION: &str = "0.1.0";36pub struct FleetDataVersion;37impl Serialize for FleetDataVersion {38	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>39	where40		S: serde::Serializer,41	{42		VERSION.serialize(serializer)43	}44}45impl<'de> Deserialize<'de> for FleetDataVersion {46	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>47	where48		D: serde::Deserializer<'de>,49	{50		let version = String::deserialize(deserializer)?;51		if version != VERSION {52			return Err(D::Error::custom(format!(53				"fleet.nix data version mismatch, expected {VERSION}, got {version}.\nFollow the docs for migration instruction"54			)));55		}56		Ok(Self)57	}58}5960fn generate_gc_prefix() -> String {61	let id = Alphanumeric.sample_string(&mut rng(), 8);62	format!("fleet-gc-{id}")63}6465#[derive(Serialize, Deserialize)]66#[serde(rename_all = "camelCase")]67pub struct ManagerKey {68	pub name: String,69	pub key: String,70}7172#[derive(Serialize, Deserialize)]73#[serde(rename_all = "camelCase")]74pub struct FleetData {75	pub version: FleetDataVersion,76	#[serde(default = "generate_gc_prefix")]77	pub gc_root_prefix: String,7879	#[serde(default, skip_serializing_if = "Vec::is_empty")]80	pub manager_keys: Vec<ManagerKey>,8182	#[serde(default)]83	pub hosts: RwLock<BTreeMap<String, HostData>>,8485	#[serde(default, alias = "shared_secrets")]86	pub secrets: RwLock<FleetSecrets>,8788	// extra_name => anything89	#[serde(default)]90	pub extra: RwLock<BTreeMap<String, Value>>,9192	#[serde(default)]93	#[serde(skip_serializing)]94	host_secrets: BTreeMap<SecretOwner, BTreeMap<String, FleetSecretDistribution>>,95}96impl FromStr for FleetData {97	type Err = anyhow::Error;98	fn from_str(s: &str) -> anyhow::Result<Self> {99		let mut data: Self = nixlike::parse_str(s)?;100		if !data.host_secrets.is_empty() {101			info!("migrating host secrets into shared secrets structure");102			data.secrets103				.write()104				.expect("no poisoning")105				.merge_from_hosts(std::mem::take(&mut data.host_secrets));106		}107		Ok(data)108	}109}110111/// Returns None if recipients.is_empty()112pub fn encrypt_secret_data<'r>(113	recipients: impl IntoIterator<Item = &'r Box<dyn Recipient>>,114	data: Vec<u8>,115) -> Option<SecretData> {116	let mut encrypted = vec![];117	let mut encryptor = age::Encryptor::with_recipients(recipients.into_iter().map(|v| &**v))118		.ok()?119		.wrap_output(&mut encrypted)120		.expect("in memory write");121	io::copy(&mut Cursor::new(data), &mut encryptor).expect("in memory copy");122	encryptor.finish().expect("in memory flush");123	Some(SecretData {124		data: encrypted,125		encrypted: true,126	})127}128129#[derive(Serialize, Deserialize, Clone, Debug)]130pub struct FleetSecretPart {131	pub raw: SecretData,132}133134#[derive(Serialize, Deserialize, Clone, Debug)]135#[serde(rename_all = "camelCase")]136#[must_use]137pub struct FleetSecretData {138	pub created_at: DateTime<Utc>,139	#[serde(default, skip_serializing_if = "Option::is_none", alias = "expire_at")]140	pub expires_at: Option<DateTime<Utc>>,141142	#[serde(flatten)]143	pub parts: BTreeMap<String, FleetSecretPart>,144145	#[serde(default, skip_serializing_if = "Value::is_null")]146	pub generation_data: Value,147}148149#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, Ord, PartialEq, Eq)]150#[repr(transparent)]151pub struct SecretOwner(String);152153impl fmt::Display for SecretOwner {154	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {155		write!(f, "host:{}", self.0)156	}157}158159impl SecretOwner {160	pub fn host(s: impl AsRef<str>) -> SecretOwner {161		SecretOwner(s.as_ref().to_owned())162	}163	pub fn as_host(&self) -> Option<&str> {164		Some(&self.0)165	}166}167168#[derive(Serialize, Deserialize, Clone, Debug)]169#[serde(rename_all = "camelCase")]170#[must_use]171pub struct FleetSecretDistribution {172	#[serde(default)]173	owners: BTreeSet<SecretOwner>,174	#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]175	owners_pending_prune: BTreeMap<SecretOwner, String>,176177	#[serde(flatten)]178	pub secret: FleetSecretData,179180	#[serde(default, skip_serializing_if = "Option::is_none")]181	pending_prune: Option<String>,182	#[serde(default, skip_serializing, alias = "managed")]183	_deprecated_managed: bool,184}185186const EMPTY_PENDING_PRUNE: &BTreeMap<SecretOwner, String> = &BTreeMap::new();187impl FleetSecretDistribution {188	pub fn new(owners: BTreeSet<SecretOwner>, secret: FleetSecretData, now: DateTime<Utc>) -> Self {189		assert!(190			!owners.is_empty(),191			"distribution should have at least one owner"192		);193		if let Some(expires_at) = &secret.expires_at {194			assert!(195				*expires_at > now,196				"secret should not be expired on creation"197			);198		}199		Self {200			owners,201			secret,202			owners_pending_prune: BTreeMap::new(),203			pending_prune: None,204			_deprecated_managed: true,205		}206	}207208	fn owners_ex(&self, including_pruned: bool) -> impl Iterator<Item = &SecretOwner> {209		let pending_prune = if including_pruned {210			&self.owners_pending_prune211		} else {212			EMPTY_PENDING_PRUNE213		};214		self.owners.iter().chain(pending_prune.keys())215	}216	pub fn owners(&self) -> impl Iterator<Item = &SecretOwner> {217		self.owners_ex(false)218	}219	pub fn owners_pending_prune(&self) -> impl Iterator<Item = &SecretOwner> {220		self.owners_pending_prune.keys()221	}222	pub fn is_pending_prune(&self) -> bool {223		self.pending_prune.is_some()224	}225226	pub fn prune(&mut self, reason: String) {227		assert!(228			self.pending_prune.is_none(),229			"it shouldn't be possible to prune the same distribution twice using public api"230		);231		self.pending_prune = Some(reason);232	}233	pub fn prune_owners(&mut self, owners: &BTreeSet<SecretOwner>, reason: String) {234		// if self.owners.iter().all(|o| owners.contains(o)) && self.owners_pending_prune.is_empty() {235		// 	self.prune(format!("all owners were pruned: {reason}"));236		// 	return;237		// }238		for owner in owners {239			if self.owners.remove(owner) {240				self.owners_pending_prune241					.insert(owner.to_owned(), reason.clone());242			}243		}244		// if self.owners.is_empty() {245		// 	self.prune("no owners left".to_owned());246		// }247	}248	pub fn unprune_owner(&mut self, owner: SecretOwner) {249		if self.owners_pending_prune.remove(&owner).is_some() {250			self.owners.insert(owner);251		}252	}253}254255#[derive(Clone, Debug, Default)]256#[must_use]257pub struct FleetSecretDistributions {258	stored: Vec<FleetSecretDistribution>,259}260261fn compare_dists(262	a: &FleetSecretDistribution,263	b: &FleetSecretDistribution,264	prefer_identities: &BTreeSet<SecretOwner>,265	include_pruned_owners: bool,266) -> Ordering {267	use Ordering::*;268	if prefer_identities.is_empty() {269		let a_has = a270			.owners_ex(include_pruned_owners)271			.any(|o| prefer_identities.contains(o));272		let b_has = b273			.owners_ex(include_pruned_owners)274			.any(|o| prefer_identities.contains(o));275		match (a_has, b_has) {276			(true, false) => return Greater,277			(false, true) => return Less,278			_ => {}279		}280	}281	match (a.secret.expires_at, b.secret.expires_at) {282		(None, Some(_)) => return Greater,283		(Some(_), None) => return Less,284		(Some(a), Some(b)) => {285			// Later is better286			return a.cmp(&b);287		}288		(None, None) => {}289	}290291	// Which one is easier to access292	a.owners.len().cmp(&b.owners.len())293}294295impl FleetSecretDistributions {296	/// Drop expired distributions297	fn prune_expired(&mut self, now: DateTime<Utc>) {298		for ele in self.distributions_mut() {299			if let Some(expires_at) = ele.secret.expires_at300				&& expires_at < now301			{302				ele.prune(format!("expired during check at {now}"));303			}304		}305	}306	/// Perform all pruning relevant to shared secrets307	/// Also see expected_owner_removed308	#[allow(clippy::too_many_arguments)]309	pub fn prune_shared(310		&mut self,311		expected_owners: &BTreeSet<SecretOwner>,312		unique: bool,313		expected_parts: &BTreeMap<String, GeneratorPart>,314		expected_generation_data: &Value,315		regenerate_on_owner_removed: bool,316		regenerate_on_owner_added: bool,317		prefer_identities: &BTreeSet<SecretOwner>,318		now: DateTime<Utc>,319	) {320		self.prune_expired(now);321		self.prune_generation_data(expected_generation_data, None);322		self.prune_missing_parts(expected_parts, None);323324		let current_owners = self.owners().cloned().collect::<BTreeSet<SecretOwner>>();325326		let mut to_add = expected_owners.difference(&current_owners);327		if to_add.next().is_some() && unique && regenerate_on_owner_added {328			for dist in self.distributions_mut() {329				dist.prune(330					"owners missing, can't add new distribution, regeneration preferred"331						.to_string(),332				);333			}334			return;335		}336337		for to_remove in current_owners.difference(expected_owners) {338			self.entry(to_remove.clone()).remove(339				regenerate_on_owner_removed,340				"owner was removed from expected owners list, regenerate_on_owner_removed is set"341					.to_string(),342			);343		}344		if unique {345			self.prune_nonunique(prefer_identities);346		}347	}348	pub fn prune_host(349		&mut self,350		owner: SecretOwner,351		expected_parts: &BTreeMap<String, GeneratorPart>,352		expected_generation_data: &Value,353		now: DateTime<Utc>,354	) {355		self.prune_expired(now);356		self.prune_generation_data(expected_generation_data, Some(&owner));357		// TODO: Owner-based pruning is warranted (e.g host no longer has secret defined)358		self.prune_missing_parts(expected_parts, Some(&owner));359	}360	/// Position of best distributions as in iterator returned by distributions()361	/// None if distributions not found362	fn best_idx(363		&self,364		prefer_identities: &BTreeSet<SecretOwner>,365		include_pruned_owners: bool,366	) -> Option<usize> {367		self.distributions()368			.enumerate()369			.max_by(|(_, a), (_, b)| compare_dists(a, b, prefer_identities, include_pruned_owners))370			.map(|(p, _)| p)371	}372	/// Secret wants to be the same on all hosts, leave only one unpruned version of it373	fn prune_nonunique(&mut self, prefer_identities: &BTreeSet<SecretOwner>) {374		if self.distributions().next().is_none() {375			return;376		}377		let best = self.best_idx(prefer_identities, false).expect("not empty");378		for (i, dist) in self.distributions_mut().enumerate() {379			if i != best {380				dist.prune(381					"secret wants to be the same on all hosts, only the best one was left"382						.to_owned(),383				);384			}385		}386	}387388	pub fn try_unprune(&mut self, owner: SecretOwner) -> Option<&FleetSecretDistribution> {389		assert!(self.get(&owner).is_none(), "secret is not pruned for host");390		if let Some(dist) = self391			.distributions_mut()392			.find(|v| v.owners_pending_prune.contains_key(&owner))393		{394			dist.unprune_owner(owner);395			Some(dist)396		} else {397			None398		}399	}400401	pub fn best_distribution_for_reencryption(402		&mut self,403		prefer_identities: &BTreeSet<SecretOwner>,404	) -> Option<&mut FleetSecretDistribution> {405		let best_idx = self.best_idx(prefer_identities, true)?;406		self.distributions_mut().nth(best_idx)407	}408409	fn prune_missing_parts(410		&mut self,411		expected_parts: &BTreeMap<String, GeneratorPart>,412		filter_owner: Option<&SecretOwner>,413	) {414		'dist: for ele in self.distributions_mut() {415			if let Some(filter_owner) = filter_owner416				&& !ele.owners.contains(filter_owner)417			{418				continue;419				// Note: secret still can have multiple owners even if it is host-owned420				// in this case we expect that all owners using the same generator, so we can prune distribution for all of them421			}422			for (name, part) in expected_parts {423				let Some(stored_part) = ele.secret.parts.get(name) else {424					ele.prune(format!("secret definition added new part: {name}"));425					continue 'dist;426				};427				if part.encrypted != stored_part.raw.encrypted {428					ele.prune(format!(429						"secret definition now requires part to be {}",430						if part.encrypted {431							"encrypted"432						} else {433							"non-encrypted"434						}435					));436					continue 'dist;437				}438			}439		}440	}441	fn prune_generation_data(442		&mut self,443		expected_generation_data: &Value,444		filter_owner: Option<&SecretOwner>,445	) {446		for ele in self.distributions_mut() {447			if let Some(filter_owner) = filter_owner448				&& !ele.owners.contains(filter_owner)449			{450				continue;451				// Note: secret still can have multiple owners even if it is host-owned452				// in this case we expect that all owners using the same generator, so we can prune distribution for all of them453			}454			if ele.secret.generation_data != *expected_generation_data {455				ele.prune(format!(456					"expected generation data mismatch: {expected_generation_data:?}"457				));458			}459		}460	}461462	/// Prune all distributions with no unpruned owners.463	/// For ease of reencryption where possible, it is only called on persistence, when in memory - pruned owners are kept and464	/// can decrypt their secrets.465	fn prune_dead(&mut self) {466		for ele in self.distributions_mut() {467			if ele.owners.is_empty() {468				ele.prune("no owners left".to_owned());469			}470		}471	}472473	pub fn all_distributions(&self) -> impl Iterator<Item = &FleetSecretDistribution> {474		self.stored.iter()475	}476	pub fn distributions(&self) -> impl Iterator<Item = &FleetSecretDistribution> {477		self.stored.iter().filter(|v| v.pending_prune.is_none())478	}479	pub fn distributions_mut(&mut self) -> impl Iterator<Item = &mut FleetSecretDistribution> {480		self.stored.iter_mut().filter(|v| v.pending_prune.is_none())481	}482	pub fn owners(&self) -> impl Iterator<Item = &SecretOwner> {483		self.distributions().flat_map(|v| v.owners.iter())484	}485	#[allow(486		clippy::len_without_is_empty,487		reason = "should not be empty for a long time"488	)]489	pub fn len(&self) -> usize {490		self.distributions().count()491	}492493	pub fn get(&self, owner: &SecretOwner) -> Option<&FleetSecretDistribution> {494		self.distributions().find(|d| d.owners.contains(owner))495	}496	fn entry(&mut self, owner: SecretOwner) -> DistEntry<'_> {497		let Some((idx, dist)) = self498			.distributions()499			.enumerate()500			.find(|(_, d)| d.owners.contains(&owner))501		else {502			return DistEntry::Vacant(VacantDistEntry {503				distributions: self,504				owners: BTreeSet::from([owner]),505			});506		};507		DistEntry::Occupied(OccupiedDistEntry {508			owners: dist.owners.clone(),509			distributions: self,510			idx,511		})512	}513	pub fn extend(&mut self, dist: FleetSecretDistribution, reason: String) {514		for ele in self.distributions_mut() {515			ele.prune_owners(&dist.owners, reason.clone());516		}517		self.stored.push(dist);518	}519	pub fn contains(&self, owner: &SecretOwner) -> bool {520		self.distributions().any(|d| d.owners.contains(owner))521	}522}523524pub struct OccupiedDistEntry<'d> {525	distributions: &'d mut FleetSecretDistributions,526	idx: usize,527	owners: BTreeSet<SecretOwner>,528}529impl<'d> OccupiedDistEntry<'d> {530	fn remove(self, whole_dist: bool, reason: String) -> VacantDistEntry<'d> {531		let dist = &mut self.distributions.stored[self.idx];532		if whole_dist {533			dist.prune(reason);534		} else {535			dist.prune_owners(&self.owners, reason);536		}537		VacantDistEntry {538			distributions: self.distributions,539			owners: self.owners,540		}541	}542	pub fn set(self, secret: FleetSecretData, reason: String) -> Self {543		self.remove(false, reason).set(secret)544	}545}546pub struct VacantDistEntry<'d> {547	distributions: &'d mut FleetSecretDistributions,548	owners: BTreeSet<SecretOwner>,549}550impl<'d> VacantDistEntry<'d> {551	pub fn set(self, secret: FleetSecretData) -> OccupiedDistEntry<'d> {552		let Self {553			distributions,554			owners,555		} = self;556		let idx = distributions.stored.len();557		distributions.stored.push(FleetSecretDistribution {558			owners: owners.clone(),559			secret,560561			owners_pending_prune: BTreeMap::new(),562			pending_prune: None,563			_deprecated_managed: true,564		});565		OccupiedDistEntry {566			distributions,567			owners,568			idx,569		}570	}571}572573pub enum DistEntry<'d> {574	Vacant(VacantDistEntry<'d>),575	Occupied(OccupiedDistEntry<'d>),576}577impl DistEntry<'_> {578	pub fn remove(self, whole_dist: bool, reason: String) -> Self {579		match self {580			DistEntry::Vacant(_) => self,581			DistEntry::Occupied(o) => Self::Vacant(o.remove(whole_dist, reason)),582		}583	}584	pub fn set(self, secret: FleetSecretData, reason: String) -> Self {585		Self::Occupied(match self {586			DistEntry::Vacant(e) => e.set(secret),587			DistEntry::Occupied(e) => e.set(secret, reason),588		})589	}590}591592impl Serialize for FleetSecretDistributions {593	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>594	where595		S: serde::Serializer,596	{597		let mut v = self.clone();598		v.prune_dead();599		let mut found_hosts = BTreeSet::new();600		for ele in v.distributions() {601			if ele.pending_prune.is_some() {602				continue;603			}604			if ele.owners.is_empty() {605				panic!("consistency: secret distribution has no defined owners");606			}607			for ele in ele.owners.iter() {608				if !found_hosts.insert(ele) {609					panic!(610						"consistency: secret distribution contains duplicate entry for the same host",611					);612				}613			}614		}615		match v.stored.len() {616			0 => panic!("consistency: empty distributions"),617			1 => v.stored[0].serialize(serializer),618			_ => {619				let mut sorted = v.stored.clone();620				// Store outdated distributions last621				sorted.sort_by_key(|v| v.pending_prune.is_some() as u32);622				sorted.serialize(serializer)623			}624		}625	}626}627impl<'de> Deserialize<'de> for FleetSecretDistributions {628	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>629	where630		D: serde::Deserializer<'de>,631	{632		#[derive(Deserialize)]633		#[serde(untagged)]634		enum Distributions {635			One(FleetSecretDistribution),636			Many(Vec<FleetSecretDistribution>),637		}638		let d = Distributions::deserialize(deserializer)?;639		let stored = match d {640			Distributions::One(d) => vec![d],641			Distributions::Many(ds) => ds,642		};643		if stored.is_empty() {644			return Err(de::Error::custom("consistency: empty distributions"));645		}646		let mut found_hosts = BTreeSet::new();647		for ele in stored.iter() {648			if ele.pending_prune.is_some() {649				continue;650			}651			if ele.owners.is_empty() {652				return Err(de::Error::custom(653					"consistency: secret distribution has no defined owners",654				));655			}656			for ele in ele.owners.iter() {657				if !found_hosts.insert(ele) {658					return Err(de::Error::custom(659						"consistency: secret distribution contains duplicate entry for the same host",660					));661				}662			}663		}664		Ok(Self { stored })665	}666}667668#[derive(Deserialize, Default)]669pub struct FleetSecrets(BTreeMap<String, FleetSecretDistributions>);670671impl Serialize for FleetSecrets {672	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>673	where674		S: serde::Serializer,675	{676		let data: BTreeMap<String, FleetSecretDistributions> = self677			.0678			.iter()679			.filter(|(_, v)| !v.stored.is_empty())680			.map(|(k, v)| (k.clone(), v.clone()))681			.collect();682683		data.serialize(serializer)684	}685}686687impl FleetSecrets {688	pub fn keys(&self) -> btree_map::Keys<'_, String, FleetSecretDistributions> {689		self.0.keys()690	}691692	pub fn keys_for_owner(&self, owner: &SecretOwner) -> impl Iterator<Item = &String> {693		self.0694			.iter()695			.filter(|(_, d)| d.contains(owner))696			.map(|(n, _)| n)697	}698699	pub fn set_data(&mut self, secret: String, data: FleetSecretDistribution) {700		match self.0.entry(secret) {701			Entry::Vacant(e) => {702				e.insert(FleetSecretDistributions { stored: vec![data] });703			}704			Entry::Occupied(mut e) => {705				let dists = e.get_mut();706				dists.extend(data, "secret data was replaced".to_owned())707			}708		}709	}710	pub fn get(&self, secret: &str) -> Option<&FleetSecretDistributions> {711		self.0.get(secret)712	}713	pub fn get_mut(&mut self, secret: &str) -> Option<&mut FleetSecretDistributions> {714		self.0.get_mut(secret)715	}716717	pub fn get_or_create(&mut self, secret: &str) -> &mut FleetSecretDistributions {718		self.0.entry(secret.to_owned()).or_default()719	}720721	pub fn contains(&self, secret: &str) -> bool {722		self.0.contains_key(secret)723	}724	pub fn remove(&mut self, secret: &str) {725		self.0.remove(secret);726	}727728	fn merge_from_hosts(729		&mut self,730		host_secrets: BTreeMap<SecretOwner, BTreeMap<String, FleetSecretDistribution>>,731	) {732		for (host, host_secrets) in host_secrets {733			for (secret_name, mut secret_data) in host_secrets {734				secret_data.owners.insert(host.clone());735				self.set_data(secret_name, secret_data);736			}737		}738	}739740	pub fn prune_host(&mut self, host: &SecretOwner, expected_nonshared: BTreeSet<String>) {741		for (name, dists) in self.0.iter_mut() {742			if expected_nonshared.contains(name) {743				continue;744			}745			for dist in dists.distributions_mut() {746				if dist.owners.contains(host) {747					dist.prune_owners(748						&BTreeSet::from([host.to_owned()]),749						"host no longer defines this secret".to_owned(),750					);751				}752			}753		}754	}755}756757#[derive(Debug, Clone)]758pub struct Expectations {759	pub owners: BTreeSet<SecretOwner>,760	pub generation_data: serde_json::Value,761	pub parts: BTreeMap<String, GeneratorPart>,762}763#[derive(Deserialize, Debug, Clone)]764pub struct GeneratorPart {765	pub encrypted: bool,766}767768#[derive(Debug, Clone, Copy)]769pub struct RegenerationConstraints {770	pub allow_different: bool,771	pub regenerate_on_owner_added: bool,772	pub regenerate_on_owner_removed: bool,773}774impl RegenerationConstraints {775	pub fn host_personal() -> Self {776		Self {777			allow_different: false,778			regenerate_on_owner_added: true,779			regenerate_on_owner_removed: true,780		}781	}782	pub fn without_preferences(self) -> Self {783		Self {784			allow_different: self.allow_different,785			regenerate_on_owner_added: false,786			regenerate_on_owner_removed: false,787		}788	}789}