git.delta.rocks / fleet / refs/commits / 7bc4be6bcef6

difftreelog

source

crates/fleet-base/src/host.rs18.7 KiBsourcehistory
1use std::{2	collections::{BTreeMap, BTreeSet, HashSet},3	future::Future,4	io::Write,5	ops::Deref,6	path::PathBuf,7	pin::Pin,8	str::FromStr,9	sync::{Arc, OnceLock},10};1112use anyhow::{Context, Result, anyhow, bail, ensure};13use camino::{Utf8Path, Utf8PathBuf};14use chrono::{DateTime, Utc};15use fleet_shared::SecretData;16use nix_eval::{Store, Value, nix_go, nix_go_json, util::assert_warn};17use remowt_client::{AgentBundle, Remowt};18use remowt_endpoints::fs::FsClient;19use remowt_link_shared::Address;20use remowt_ui_prompt::auto::AutoPrompter;21use remowt_ui_prompt::bifrost::PromptEndpoints;22use remowt_ui_prompt::{PrependSourcePrompter, Source};23use tabled::Tabled;24use tempfile::NamedTempFile;25use time::UtcDateTime;26use tokio::task::spawn_blocking;27use tracing::{info, warn};2829use crate::fleetdata::{30	FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretPart, SecretOwner,31};3233pub struct FleetConfigInternals {34	pub prefer_identities: BTreeSet<SecretOwner>,35	pub now: DateTime<Utc>,3637	/// Fleet project directory, containing fleet.nix file.38	pub directory: PathBuf,39	/// builtins.currentSystem40	pub local_system: String,41	pub data: Arc<FleetData>,42	/// fleet_config.config43	pub config_field: Value,44	/// flake.output45	pub flake_outputs: Value,46	// TODO: Remove with connectivity refactor47	pub localhost: String,4849	/// import nixpkgs {system = local};50	pub default_pkgs: Value,51	/// inputs.nixpkgs52	pub nixpkgs: Value,5354	pub local_host: OnceLock<Arc<ConfigHost>>,55}5657// TODO: Make field not pub58#[derive(Clone)]59pub struct Config(pub Arc<FleetConfigInternals>);6061impl Deref for Config {62	type Target = FleetConfigInternals;6364	fn deref(&self) -> &Self::Target {65		&self.066	}67}6869#[derive(Clone, PartialEq, Copy, Debug)]70pub enum DeployKind {71	/// NixOS => NixOS managed by fleet72	UpgradeToFleet,73	/// NixOS managed by fleet => NixOS managed by fleet74	Fleet,75	/// Remote host has /mnt, /mnt/boot mounted,76	/// generated config is added to fleet configuration.77	NixosInstall,78	/// Remote host has some system and nix installed in multi-user mode (/nix is owned by root),79	/// generated config is added to fleet configuration,80	/// and /etc/NIXOS_LUSTRATE exists, fleet will perform the rest.81	NixosLustrate,82}8384impl FromStr for DeployKind {85	type Err = anyhow::Error;86	fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {87		match s {88			"upgrade-to-fleet" => Ok(Self::UpgradeToFleet),89			"fleet" => Ok(Self::Fleet),90			"nixos-install" => Ok(Self::NixosInstall),91			"nixos-lustrate" => Ok(Self::NixosLustrate),92			v => bail!(93				"unknown deploy_kind: {v}; expected on of \"upgrade-to-fleet\", \"fleet\", \"nixos-install\", \"nixos-lustrate\""94			),95		}96	}97}98pub struct ConfigHost {99	config: Config,100	pub name: String,101	groups: OnceLock<Vec<String>>,102103	// TODO: Both of those values are taken from host opts, there should be a cleaner way to specify it104	deploy_kind: OnceLock<DeployKind>,105	session_destination: OnceLock<String>,106	legacy_ssh_store: OnceLock<bool>,107108	pub host_config: Option<Value>,109	pub nixos_config: OnceLock<Value>,110	pub nixos_unchecked_config: OnceLock<Value>,111	pub pkgs_override: Option<Value>,112113	// TODO: Move command helpers away with connectivity refactor114	pub local: bool,115	pub remowt: OnceLock<Remowt>,116	nix_store: OnceLock<Arc<Store>>,117	nix_plugin: tokio::sync::OnceCell<()>,118}119120const NIX_PLUGIN_ID: u16 = 2;121122fn agents_dir() -> Result<PathBuf> {123	std::env::var_os("REMOWT_AGENTS_DIR")124		.map(PathBuf::from)125		.or_else(|| option_env!("REMOWT_AGENTS_DIR").map(PathBuf::from))126		.ok_or_else(|| {127			anyhow!("no remowt-agents bundle; set REMOWT_AGENTS_DIR to a remowt-agents output")128		})129}130131fn agent_bundle() -> Result<AgentBundle> {132	AgentBundle::from_dir(agents_dir()?)133}134135#[derive(Debug, Clone, Copy)]136pub enum GenerationStorage {137	Deployer,138	Machine,139	Pusher,140}141impl GenerationStorage {142	fn prefix(&self) -> &'static str {143		match self {144			GenerationStorage::Deployer => "deployer.",145			GenerationStorage::Machine => "",146			GenerationStorage::Pusher => "pusher.",147		}148	}149}150151#[derive(Tabled, Debug)]152pub struct Generation {153	#[tabled(rename = "ID", format("{}", self.rollback_id()))]154	pub id: u32,155	#[tabled(rename = "Current")]156	pub current: bool,157	#[tabled(rename = "Created at")]158	pub datetime: UtcDateTime,159	#[tabled(format = "{:?}")]160	pub store_path: Utf8PathBuf,161	#[tabled(skip)]162	pub location: GenerationStorage,163}164impl Generation {165	pub fn rollback_id(&self) -> String {166		format!("{}{}", self.location.prefix(), self.id)167	}168}169170impl ConfigHost {171	pub async fn list_generations(&self, profile: &str) -> Result<Vec<Generation>> {172		let plugin_id = self.ensure_nix_plugin().await?;173		let nix = self174			.remowt()175			.await?176			.plugin_endpoints::<remowt_fleet::NixClient<_>>(plugin_id);177		let raw = nix178			.list_generations(profile.to_owned())179			.await180			.map_err(|e| anyhow!("{e:?}"))?181			.map_err(|e| anyhow!("{e}"))?;182		raw.into_iter()183			.map(|g| {184				let id: u32 =185					g.id.try_into()186						.with_context(|| format!("generation id {} doesn't fit in u32", g.id))?;187				let datetime = UtcDateTime::from_unix_timestamp(g.creation_time_unix)188					.with_context(|| {189						format!("invalid generation timestamp {}", g.creation_time_unix)190					})?;191				Ok(Generation {192					id,193					current: g.current,194					datetime,195					store_path: g.store_path,196					location: GenerationStorage::Machine,197				})198			})199			.collect()200	}201202	pub fn set_session_destination(&self, dest: String) {203		self.session_destination204			.set(dest)205			.expect("session destination is already set")206	}207	pub fn set_deploy_kind(&self, kind: DeployKind) {208		self.deploy_kind209			.set(kind)210			.expect("deploy kind is already set");211	}212	pub fn set_legacy_ssh_store(&self, legacy: bool) {213		self.legacy_ssh_store214			.set(legacy)215			.expect("legacy ssh store is already set")216	}217	pub async fn deploy_kind(&self) -> Result<DeployKind> {218		if let Some(kind) = self.deploy_kind.get() {219			return Ok(*kind);220		}221		let remowt = self.remowt().await?;222		let fs = remowt.endpoints::<FsClient<_>>();223		let is_fleet_managed = match fs.file_exists(Utf8PathBuf::from("/etc/FLEET_HOST")).await {224			Ok(v) => v,225			Err(e) => {226				bail!("failed to query remote system kind: {e}");227			}228		};229		if !is_fleet_managed {230			bail!(231				"{}",232				indoc::indoc! {"233				host is not marked as managed by fleet234				if you're not trying to lustrate/install system from scratch,235				you should either236					1. manually create /etc/FLEET_HOST file on the target host,237					2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet238					3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos239				for installation use ?deploy_kind=nixos_install / ?deploy_kind=nixos_lustrate 240			"}241			);242		}243		// TOCTOU is possible244		let _ = self.deploy_kind.set(DeployKind::Fleet);245		Ok(*self.deploy_kind.get().expect("deploy kind is just set"))246	}247	async fn connection(&self) -> Result<Remowt> {248		if let Some(conn) = self.remowt.get() {249			return Ok(conn.clone());250		}251		let bundle = agent_bundle()?;252		let conn = if self.local {253			Remowt::connect_local(&bundle)254				.await255				.context("starting local remowt agent")?256		} else {257			let dest = self258				.session_destination259				.get()260				.cloned()261				.unwrap_or_else(|| self.name.clone());262			Remowt::connect(&dest, &bundle)263				.await264				.map_err(|e| anyhow!("remowt error while connecting to {}: {e:#?}", self.name))?265		};266		PromptEndpoints(PrependSourcePrompter {267			prompter: AutoPrompter::new().await,268			source: if self.local {269				vec![]270			} else {271				vec![Source(std::borrow::Cow::Owned(format!(272					"ssh host: {}",273					self.name274				)))]275			},276			description: "".to_owned(),277		})278		.register_endpoints(&mut conn.rpc());279		let _ = self.remowt.set(conn);280		Ok(self.remowt.get().expect("just set").clone())281	}282283	/// Client for this host's unprivileged agent.284	pub async fn remowt(&self) -> Result<Remowt> {285		Ok(self.connection().await?)286	}287288	pub fn ensure_nix_plugin(&self) -> Pin<Box<dyn Future<Output = Result<u16>> + Send + '_>> {289		Box::pin(async {290			self.nix_plugin291				.get_or_try_init(|| async {292					let pkgs = self.pkgs()?;293					let name = "remowt-plugin-fleet";294					let plugin = nix_go!(pkgs[{ name }]);295					let built = plugin296						.build("out")297						.context("failed to build the fleet nix plugin")?;298					let copied = self299						.remote_derivation(&built)300						.await301						.context("failed to copy the fleet nix plugin to the host store")?;302					let bin = copied.join("bin/remowt-plugin-fleet");303					self.remowt()304						.await?305						.run0_load_plugin_path(NIX_PLUGIN_ID, bin.as_str())306						.await307						.context("failed to load the fleet nix plugin")?;308					self.remowt()309						.await?310						.rpc()311						.wait_for_connection_to(Address::Plugin(NIX_PLUGIN_ID))312						.await313						.map_err(|e| anyhow!("failed to wait for plugin"))?;314					anyhow::Ok(())315				})316				.await?;317			Ok(NIX_PLUGIN_ID)318		})319	}320321	async fn nix_store(&self) -> Result<Arc<Store>> {322		if let Some(store) = self.nix_store.get() {323			return Ok(store.clone());324		}325		let conn = self.connection().await?;326		let socket = match self.deploy_kind().await? {327			DeployKind::NixosInstall => {328				remowt_fleet::nix_store_socket(conn, "/mnt?require-sigs=false").await?329			}330			_ => remowt_fleet::nix_store_socket(conn, "auto").await?,331		};332		let uri = format!("unix://{}", socket.display());333		let store = Arc::new(Store::open(&uri)?);334		let _ = self.nix_store.set(store);335		Ok(self.nix_store.get().expect("just set").clone())336	}337338	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {339		ensure!(data.encrypted, "secret is not encrypted");340		let remowt = self.remowt().await?;341		let mut cmd = remowt.cmd("fleet-install-secrets");342		cmd.arg("decrypt").eqarg("--secret", data.to_string());343		let encoded = cmd344			.sudo()345			.run_string()346			.await347			.context("failed to call remote host for decrypt")?;348		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;349		ensure!(!data.encrypted, "secret came out encrypted");350		Ok(data.data)351	}352	pub async fn reencrypt_distribution(353		&self,354		data: &FleetSecretDistribution,355		targets: BTreeSet<SecretOwner>,356		now: DateTime<Utc>,357	) -> Result<FleetSecretDistribution> {358		let mut parts = BTreeMap::new();359		for (part_name, part) in &data.secret.parts {360			parts.insert(361				part_name.clone(),362				if part.raw.encrypted {363					FleetSecretPart {364						raw: self.reencrypt(part.raw.clone(), targets.clone()).await?,365					}366				} else {367					part.clone()368				},369			);370		}371		let secret = FleetSecretData {372			created_at: data.secret.created_at,373			expires_at: data.secret.expires_at,374			generation_data: data.secret.generation_data.clone(),375			parts,376		};377		Ok(FleetSecretDistribution::new(targets, secret, now))378	}379	pub async fn reencrypt(380		&self,381		data: SecretData,382		targets: BTreeSet<SecretOwner>,383	) -> Result<SecretData> {384		let remowt = self.remowt().await?;385		ensure!(data.encrypted, "secret is not encrypted");386		let mut cmd = remowt.cmd("fleet-install-secrets");387		cmd.arg("reencrypt").eqarg("--secret", data.to_string());388		for target in targets {389			let key = self.config.key(&target).await?;390			cmd.eqarg("--targets", key);391		}392		let encoded = cmd393			.sudo()394			.run_string()395			.await396			.context("failed to call remote host for decrypt")?;397		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;398		ensure!(data.encrypted, "secret came out not encrypted");399		Ok(data)400	}401	/// Returns path for futureproofing, as path might change i.e on conversion to CA402	pub async fn remote_derivation(&self, path: impl AsRef<Utf8Path>) -> Result<Utf8PathBuf> {403		let path = path.as_ref().to_owned();404		if self.local {405			// Path is located locally, thus already trusted.406			return Ok(path);407		}408		let sign: Pin<Box<dyn Future<Output = Result<()>> + Send>> = {409			let path = path.clone();410			Box::pin(async move {411				let local = self.config.local_host();412				let plugin_id = local.ensure_nix_plugin().await?;413				let nix = local414					.remowt()415					.await?416					.plugin_endpoints::<remowt_fleet::NixClient<_>>(plugin_id);417				nix.sign_closure(path, Utf8PathBuf::from("/etc/nix/private-key"))418					.await419					.map_err(|e| anyhow!("{e:?}"))?420					.map_err(|e| anyhow!("{e}"))?;421				Ok(())422			})423		};424		if let Err(e) = sign.await {425			warn!("failed to sign store paths: {e}");426		}427		let store = self.nix_store().await?;428		{429			let path = path.clone();430			spawn_blocking(move || nix_eval::copy_closure_to(&store, path.as_ref()))431				.await?432				.context("copying closure to remote store")?;433		}434		Ok(path)435	}436}437438struct HostSecretDefinition(Value);439440impl ConfigHost {441	// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,442	// assuming getting tags always returns the same value.443	pub fn tags(&self) -> Result<Vec<String>> {444		if let Some(v) = self.groups.get() {445			return Ok(v.clone());446		}447		let Some(host_config) = &self.host_config else {448			return Ok(vec![]);449		};450		let tags: Vec<String> = nix_go_json!(host_config.tags);451452		let _ = self.groups.set(tags.clone());453454		Ok(tags)455	}456	pub fn nixos_config(&self) -> Result<Value> {457		if let Some(v) = self.nixos_config.get() {458			return Ok(v.clone());459		}460		let Some(host_config) = &self.host_config else {461			bail!("local host has no nixos_config");462		};463		let nixos_config = nix_go!(host_config.nixos.config);464		assert_warn("nixos config evaluation", &nixos_config)?;465466		let _ = self.nixos_config.set(nixos_config.clone());467468		Ok(nixos_config)469	}470	pub fn nixos_unchecked_config(&self) -> Result<Value> {471		if let Some(v) = self.nixos_unchecked_config.get() {472			return Ok(v.clone());473		}474		let Some(host_config) = &self.host_config else {475			bail!("local host has no nixos_config");476		};477		let nixos_config = nix_go!(host_config.nixos_unchecked.config);478479		let _ = self.nixos_unchecked_config.set(nixos_config.clone());480481		Ok(nixos_config)482	}483484	pub fn list_defined_secrets(&self) -> Result<Vec<String>> {485		let nixos = self.nixos_unchecked_config()?;486		let secrets = nix_go!(nixos.secrets);487		secrets.list_fields()488	}489490	/// Packages for this host, resolved with nixpkgs overlays491	pub fn pkgs(&self) -> Result<Value> {492		if let Some(value) = &self.pkgs_override {493			return Ok(value.clone());494		}495		let Some(host_config) = &self.host_config else {496			bail!("local host has no host_config");497		};498		// TODO: Should nixos.options be cached?499		Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))500	}501}502503#[derive(Clone)]504pub struct SharedSecretDefinition(Value);505impl SharedSecretDefinition {506	pub fn expected_owners(&self) -> Result<BTreeSet<SecretOwner>> {507		let secret = &self.0;508		Ok(nix_go_json!(secret.expectedOwners))509	}510	pub fn allow_different(&self) -> Result<bool> {511		let secret = &self.0;512		Ok(nix_go_json!(secret.allowDifferent))513	}514	pub fn regenerate_on_owner_added(&self) -> Result<bool> {515		let secret = &self.0;516		Ok(nix_go_json!(secret.regenerateOnOwnerAdded))517	}518	pub fn regenerate_on_owner_removed(&self) -> Result<bool> {519		let secret = &self.0;520		Ok(nix_go_json!(secret.regenerateOnOwnerRemoved))521	}522	pub fn generator(&self) -> Result<Value> {523		let secret = &self.0;524		Ok(nix_go!(secret.generator))525	}526}527528impl Config {529	pub fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {530		let config = &self.config_field;531		let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);532		Ok(tagged)533	}534	pub fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {535		let mut out = BTreeSet::new();536		for owner in owners {537			if let Some(tag) = owner.strip_prefix('@') {538				let hosts = self.tagged_hostnames(tag)?;539				out.extend(hosts);540			} else {541				out.insert(owner);542			}543		}544		Ok(out)545	}546	pub fn local_host(&self) -> Arc<ConfigHost> {547		self.local_host548			.get_or_init(|| {549				Arc::new(ConfigHost {550					config: self.clone(),551					name: "<virtual localhost>".to_owned(),552					host_config: None,553					nixos_config: OnceLock::new(),554					nixos_unchecked_config: OnceLock::new(),555					groups: {556						let cell = OnceLock::new();557						let _ = cell.set(vec![]);558						cell559					},560					pkgs_override: Some(self.default_pkgs.clone()),561562					local: true,563					remowt: OnceLock::new(),564					nix_store: OnceLock::new(),565					nix_plugin: tokio::sync::OnceCell::new(),566					deploy_kind: OnceLock::new(),567					session_destination: OnceLock::new(),568					legacy_ssh_store: OnceLock::new(),569				})570			})571			.clone()572	}573574	pub fn preferred_hosts(575		&self,576		filter: impl Fn(&str) -> bool,577	) -> Result<impl Iterator<Item = Result<ConfigHost>>> {578		let prefer = self579			.prefer_identities580			.iter()581			.filter_map(|v| v.as_host())582			.collect::<HashSet<_>>();583		let config = &self.config_field;584		let mut names = nix_go!(config.hosts).list_fields()?;585		names.retain(|s| filter(s));586		names.sort_by_key(|h| prefer.contains(h.as_str()));587588		Ok(names.into_iter().map(|h| self.host(&h)))589	}590591	pub fn host(&self, name: &str) -> Result<ConfigHost> {592		let config = &self.config_field;593		let host_config = nix_go!(config.hosts[{ name }]);594595		Ok(ConfigHost {596			config: self.clone(),597			name: name.to_owned(),598			host_config: Some(host_config),599			nixos_config: OnceLock::new(),600			nixos_unchecked_config: OnceLock::new(),601			groups: OnceLock::new(),602			pkgs_override: None,603604			// TODO: Remove with connectivit refactor605			local: self.localhost == name,606			remowt: OnceLock::new(),607			nix_store: OnceLock::new(),608			nix_plugin: tokio::sync::OnceCell::new(),609			deploy_kind: OnceLock::new(),610			session_destination: OnceLock::new(),611			legacy_ssh_store: OnceLock::new(),612		})613	}614	pub fn list_hosts(&self) -> Result<Vec<ConfigHost>> {615		let config = &self.config_field;616		let names = nix_go!(config.hosts).list_fields()?;617		let mut out = vec![];618		for name in names {619			out.push(self.host(&name)?);620		}621		Ok(out)622	}623	// TODO: Replace usages with .host().nixos_config624	pub fn system_config(&self, host: &str) -> Result<Value> {625		let fleet_field = &self.config_field;626		Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))627	}628629	pub fn secret_definition(&self, secret: &str) -> Result<Option<SharedSecretDefinition>> {630		let config = &self.config_field;631		let shared_secrets = nix_go!(config.secrets);632		if !shared_secrets.has_field(secret)? {633			return Ok(None);634		}635		Ok(Some(SharedSecretDefinition(nix_go!(636			shared_secrets[secret]637		))))638	}639640	pub fn save(&self) -> Result<()> {641		let mut tempfile = NamedTempFile::new_in(self.directory.clone()).context("failed to create updated version of fleet.nix in the same directory as original.\nDo you have write access to it? Access only to the fleet.nix won't be enough, the directory is used for atomic overwrite operation.\nIt is not recommended to use fleet by root anyway, move fleet project to your home directory.")?;642		let data = nixlike::serialize(&*self.data)?;643		tempfile.write_all(644			format!(645				"# This file contains fleet state and shouldn't be edited by hand\n\n{data}\n\n# vim: ts=2 et nowrap\n"646			)647			.as_bytes(),648		)?;649		let mut fleet_data_path = self.directory.clone();650		fleet_data_path.push("fleet.nix");651		tempfile.persist(fleet_data_path)?;652		Ok(())653	}654}