git.delta.rocks / jrsonnet / refs/commits / 13f8ec8b964e

difftreelog

fix secret generation

Yaroslav Bolyukin2024-09-09parent: #ef18a9f.patch.diff
in: trunk

3 files changed

modifiedcrates/fleet-base/src/host.rsdiffbeforeafterboth
before · crates/fleet-base/src/host.rs
1use std::{2	cell::OnceCell,3	ffi::{OsStr, OsString},4	fmt::Display,5	io::Write,6	ops::Deref,7	path::PathBuf,8	str::FromStr,9	sync::{Arc, Mutex, MutexGuard, OnceLock},10};1112use anyhow::{anyhow, bail, ensure, Context, Result};13use fleet_shared::SecretData;14use nix_eval::{nix_go, nix_go_json, util::assert_warn, Value};15use openssh::SessionBuilder;16use serde::de::DeserializeOwned;17use tempfile::NamedTempFile;1819use crate::{20	command::MyCommand,21	fleetdata::{FleetData, FleetSecret, FleetSharedSecret},22};2324pub struct FleetConfigInternals {25	pub local_system: String,26	pub directory: PathBuf,27	pub data: Mutex<FleetData>,28	pub nix_args: Vec<OsString>,29	/// fleet_config.config30	pub config_field: Value,31	// TODO: Remove with connectivity refactor32	pub localhost: String,3334	/// import nixpkgs {system = local};35	pub default_pkgs: Value,36}3738// TODO: Make field not pub39#[derive(Clone)]40pub struct Config(pub Arc<FleetConfigInternals>);4142impl Deref for Config {43	type Target = FleetConfigInternals;4445	fn deref(&self) -> &Self::Target {46		&self.047	}48}4950#[derive(Clone, Copy, Debug)]51pub enum EscalationStrategy {52	Sudo,53	Run0,54	Su,55}5657pub struct ConfigHost {58	config: Config,59	pub name: String,60	groups: OnceCell<Vec<String>>,6162	pub host_config: Option<Value>,63	pub nixos_config: OnceCell<Value>,6465	// TODO: Move command helpers away with connectivity refactor66	pub local: bool,67	pub session: OnceLock<Arc<openssh::Session>>,68}69// TODO: Move command helpers away with connectivity refactor70impl ConfigHost {71	pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {72		// Prefer sudo, as run0 has some gotchas with polkit73		// and too many repeating prompts.74		if (self.find_in_path("sudo").await).is_ok() {75			return Ok(EscalationStrategy::Sudo);76		}77		if (self.find_in_path("run0").await).is_ok() {78			return Ok(EscalationStrategy::Run0);79		}80		Ok(EscalationStrategy::Su)81	}82	async fn open_session(&self) -> Result<Arc<openssh::Session>> {83		assert!(!self.local, "do not open ssh connection to local session");84		// FIXME: TOCTOU85		if let Some(session) = &self.session.get() {86			return Ok((*session).clone());87		};88		let session = SessionBuilder::default();89		let session = session90			.connect(&self.name)91			.await92			.map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;93		let session = Arc::new(session);94		self.session.set(session.clone()).expect("TOCTOU happened");95		Ok(session)96	}97	pub async fn mktemp_dir(&self) -> Result<String> {98		let mut cmd = self.cmd("mktemp").await?;99		cmd.arg("-d");100		let path = cmd.run_string().await?;101		Ok(path.trim_end().to_owned())102	}103	pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {104		let mut cmd = self.cmd("cat").await?;105		cmd.arg(path);106		cmd.run_bytes().await107	}108	pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {109		let mut cmd = self.cmd("cat").await?;110		cmd.arg(path);111		cmd.run_string().await112	}113	pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {114		let mut cmd = self.cmd("ls").await?;115		cmd.arg(path);116		let out = cmd.run_string().await?;117		let mut lines = out.split('\n');118		if let Some(last) = lines.next_back() {119			ensure!(last.is_empty(), "output of ls should end with newline");120		}121		Ok(lines.map(ToOwned::to_owned).collect())122	}123	#[allow(dead_code)]124	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {125		let text = self.read_file_text(path).await?;126		Ok(serde_json::from_str(&text)?)127	}128	pub async fn read_env(&self, env: &str) -> Result<String> {129		let mut cmd = self.cmd("printenv").await?;130		cmd.arg(env);131		cmd.run_string().await132	}133	pub async fn find_in_path(&self, command: &str) -> Result<String> {134		// // `which` is not a part of coreutils, and it might not exist on machine.135		// let path = self.read_env("PATH").await?;136		// // Assuming delimiter is :, we don't work with windows host, this check will be much137		// // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)138		// for ele in path.split(':') {139		// 	let test_path = format!("{ele}/{cmd}");140		// 	test -x etc141		// }142		// let mut cmd = self.cmd("printenv").await?;143		// cmd.arg(env);144		// Ok(cmd.run_string().await?)145		// Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.146		let mut cmd = self147			.cmd_escalation(148				// Not used149				EscalationStrategy::Su,150				"which",151			)152			.await?;153		cmd.arg(command);154		cmd.run_string().await155	}156	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>157	where158		<D as FromStr>::Err: Display,159	{160		let text = self.read_file_text(path).await?;161		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))162	}163	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {164		self.cmd_escalation(self.escalation_strategy().await?, cmd)165			.await166	}167	pub async fn cmd_escalation(168		&self,169		escalation: EscalationStrategy,170		cmd: impl AsRef<OsStr>,171	) -> Result<MyCommand> {172		if self.local {173			Ok(MyCommand::new(escalation, cmd))174		} else {175			let session = self.open_session().await?;176			Ok(MyCommand::new_on(escalation, cmd, session))177		}178	}179180	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {181		ensure!(data.encrypted, "secret is not encrypted");182		let mut cmd = self.cmd("fleet-install-secrets").await?;183		cmd.arg("decrypt").eqarg("--secret", data.to_string());184		let encoded = cmd185			.sudo()186			.run_string()187			.await188			.context("failed to call remote host for decrypt")?;189		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;190		ensure!(!data.encrypted, "secret came out encrypted");191		Ok(data.data)192	}193	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {194		ensure!(data.encrypted, "secret is not encrypted");195		let mut cmd = self.cmd("fleet-install-secrets").await?;196		cmd.arg("reencrypt").eqarg("--secret", data.to_string());197		for target in targets {198			let key = self.config.key(&target).await?;199			cmd.eqarg("--targets", key);200		}201		let encoded = cmd202			.sudo()203			.run_string()204			.await205			.context("failed to call remote host for decrypt")?;206		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;207		ensure!(data.encrypted, "secret came out not encrypted");208		Ok(data)209	}210	/// Returns path for futureproofing, as path might change i.e on conversion to CA211	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {212		if self.local {213			// Path is located locally, thus already trusted.214			return Ok(path.to_owned());215		}216		let mut nix = MyCommand::new(217			// Not used218			EscalationStrategy::Su,219			"nix",220		);221		nix.arg("copy")222			.arg("--substitute-on-destination")223			.comparg("--to", format!("ssh-ng://{}", self.name))224			.arg(path);225		nix.run_nix().await.context("nix copy")?;226		Ok(path.to_owned())227	}228	pub async fn systemctl_stop(&self, name: &str) -> Result<()> {229		let mut cmd = self.cmd("systemctl").await?;230		cmd.arg("stop").arg(name);231		cmd.sudo().run().await232	}233	pub async fn systemctl_start(&self, name: &str) -> Result<()> {234		let mut cmd = self.cmd("systemctl").await?;235		cmd.arg("start").arg(name);236		cmd.sudo().run().await237	}238239	pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {240		let mut cmd = self.cmd("rm").await?;241		cmd.arg("-f").arg(path);242		if sudo {243			cmd = cmd.sudo()244		}245		cmd.run().await246	}247}248impl ConfigHost {249	// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,250	// assuming getting tags always returns the same value.251	pub async fn tags(&self) -> Result<Vec<String>> {252		if let Some(v) = self.groups.get() {253			return Ok(v.clone());254		}255		let Some(host_config) = &self.host_config else {256			return Ok(vec![]);257		};258		let tags: Vec<String> = nix_go_json!(host_config.tags);259260		let _ = self.groups.set(tags.clone());261262		Ok(tags)263	}264	pub async fn nixos_config(&self) -> Result<Value> {265		if let Some(v) = self.nixos_config.get() {266			return Ok(v.clone());267		}268		let Some(host_config) = &self.host_config else {269			bail!("local host has no nixos_config");270		};271		let nixos_config = nix_go!(host_config.nixos.config);272		assert_warn("nixos config evaluation", &nixos_config).await?;273274		let _ = self.nixos_config.set(nixos_config.clone());275276		Ok(nixos_config)277	}278279	pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {280		let nixos = self.nixos_config().await?;281		let secrets = nix_go!(nixos.secrets);282		let mut out = Vec::new();283		for name in secrets.list_fields().await? {284			let secret = nix_go!(secrets[{ name }]);285			let is_shared: bool = nix_go_json!(secret.shared);286			if is_shared {287				continue;288			}289			out.push(name);290		}291		Ok(out)292	}293	pub async fn secret_field(&self, name: &str) -> Result<Value> {294		let nixos = self.nixos_config().await?;295		Ok(nix_go!(nixos.secrets[{ name }]))296	}297298	/// Packages for this host, resolved with nixpkgs overlays299	pub async fn pkgs(&self) -> Result<Value> {300		let Some(host_config) = &self.host_config else {301			bail!("local host has no host_config");302		};303		// TODO: Should nixos.options be cached?304		Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))305	}306}307308impl Config {309	pub fn local_host(&self) -> ConfigHost {310		ConfigHost {311			config: self.clone(),312			name: "<virtual localhost>".to_owned(),313			local: true,314			session: OnceLock::new(),315			host_config: None,316			nixos_config: OnceCell::new(),317			groups: {318				let cell = OnceCell::new();319				let _ = cell.set(vec![]);320				cell321			},322		}323	}324325	pub async fn host(&self, name: &str) -> Result<ConfigHost> {326		let config = &self.config_field;327		let host_config = nix_go!(config.hosts[{ name }]);328329		Ok(ConfigHost {330			config: self.clone(),331			name: name.to_owned(),332			host_config: Some(host_config),333			nixos_config: OnceCell::new(),334			groups: OnceCell::new(),335			336			// TODO: Remove with connectivit refactor337			local: self.localhost == name,338			session: OnceLock::new(),339		})340	}341	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {342		let config = &self.config_field;343		let names = nix_go!(config.hosts).list_fields().await?;344		let mut out = vec![];345		for name in names {346			out.push(self.host(&name).await?);347		}348		Ok(out)349	}350	// TODO: Replace usages with .host().nixos_config351	pub async fn system_config(&self, host: &str) -> Result<Value> {352		let fleet_field = &self.config_field;353		Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))354	}355356	/// Shared secrets configured in fleet.nix or in flake357	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {358		let config_field = &self.config_field;359		Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)360	}361	/// Shared secrets configured in fleet.nix362	pub fn list_shared(&self) -> Vec<String> {363		let data = self.data();364		data.shared_secrets.keys().cloned().collect()365	}366	pub fn has_shared(&self, name: &str) -> bool {367		let data = self.data();368		data.shared_secrets.contains_key(name)369	}370	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {371		let mut data = self.data_mut();372		data.shared_secrets.insert(name.to_owned(), shared);373	}374	pub fn remove_shared(&self, secret: &str) {375		let mut data = self.data_mut();376		data.shared_secrets.remove(secret);377	}378379	pub fn list_secrets(&self, host: &str) -> Vec<String> {380		let data = self.data();381		let Some(secrets) = data.host_secrets.get(host) else {382			return Vec::new();383		};384		secrets.keys().cloned().collect()385	}386387	pub fn has_secret(&self, host: &str, secret: &str) -> bool {388		let data = self.data();389		let Some(host_secrets) = data.host_secrets.get(host) else {390			return false;391		};392		host_secrets.contains_key(secret)393	}394	pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {395		let mut data = self.data_mut();396		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();397		host_secrets.insert(secret, value);398	}399400	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {401		let data = self.data();402		let Some(host_secrets) = data.host_secrets.get(host) else {403			bail!("no secrets for machine {host}");404		};405		let Some(secret) = host_secrets.get(secret) else {406			bail!("machine {host} has no secret {secret}");407		};408		Ok(secret.clone())409	}410	pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {411		let data = self.data();412		let Some(secret) = data.shared_secrets.get(secret) else {413			bail!("no shared secret {secret}");414		};415		Ok(secret.clone())416	}417	pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {418		let config_field = &self.config_field;419		Ok(nix_go_json!(420			config_field.sharedSecrets[{ secret }].expectedOwners421		))422	}423424	// TODO: Should this be something modifiable from other processes?425	// E.g terraform provider might want to update FleetData (e.g secrets),426	// and current implementation assumes only one process holds current fleet.nix427	// Given that it is no longer needs to be a file for nix evaluation,428	// maybe it can be a .nix file for persistence, but accessible only429	// thru some shared state controller? Might it be stored in terraform430	// state provider?431	pub fn data(&self) -> MutexGuard<FleetData> {432		self.data.lock().unwrap()433	}434	pub fn data_mut(&self) -> MutexGuard<FleetData> {435		self.data.lock().unwrap()436	}437	pub fn save(&self) -> Result<()> {438		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.")?;439		let data = nixlike::serialize(&self.data() as &FleetData)?;440		tempfile.write_all(441			format!(442				"# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",443				data444			)445			.as_bytes(),446		)?;447		let mut fleet_data_path = self.directory.clone();448		fleet_data_path.push("fleet.nix");449		tempfile.persist(fleet_data_path)?;450		Ok(())451	}452}
after · crates/fleet-base/src/host.rs
1use std::{2	cell::OnceCell,3	ffi::{OsStr, OsString},4	fmt::Display,5	io::Write,6	ops::Deref,7	path::PathBuf,8	str::FromStr,9	sync::{Arc, Mutex, MutexGuard, OnceLock},10};1112use anyhow::{anyhow, bail, ensure, Context, Result};13use fleet_shared::SecretData;14use nix_eval::{nix_go, nix_go_json, util::assert_warn, Value};15use openssh::SessionBuilder;16use serde::de::DeserializeOwned;17use tempfile::NamedTempFile;1819use crate::{20	command::MyCommand,21	fleetdata::{FleetData, FleetSecret, FleetSharedSecret},22};2324pub struct FleetConfigInternals {25	pub local_system: String,26	pub directory: PathBuf,27	pub data: Mutex<FleetData>,28	pub nix_args: Vec<OsString>,29	/// fleet_config.config30	pub config_field: Value,31	// TODO: Remove with connectivity refactor32	pub localhost: String,3334	/// import nixpkgs {system = local};35	pub default_pkgs: Value,36}3738// TODO: Make field not pub39#[derive(Clone)]40pub struct Config(pub Arc<FleetConfigInternals>);4142impl Deref for Config {43	type Target = FleetConfigInternals;4445	fn deref(&self) -> &Self::Target {46		&self.047	}48}4950#[derive(Clone, Copy, Debug)]51pub enum EscalationStrategy {52	Sudo,53	Run0,54	Su,55}5657pub struct ConfigHost {58	config: Config,59	pub name: String,60	groups: OnceCell<Vec<String>>,6162	pub host_config: Option<Value>,63	pub nixos_config: OnceCell<Value>,64	pub pkgs_override: Option<Value>,6566	// TODO: Move command helpers away with connectivity refactor67	pub local: bool,68	pub session: OnceLock<Arc<openssh::Session>>,69}70// TODO: Move command helpers away with connectivity refactor71impl ConfigHost {72	pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {73		// Prefer sudo, as run0 has some gotchas with polkit74		// and too many repeating prompts.75		if (self.find_in_path("sudo").await).is_ok() {76			return Ok(EscalationStrategy::Sudo);77		}78		if (self.find_in_path("run0").await).is_ok() {79			return Ok(EscalationStrategy::Run0);80		}81		Ok(EscalationStrategy::Su)82	}83	async fn open_session(&self) -> Result<Arc<openssh::Session>> {84		assert!(!self.local, "do not open ssh connection to local session");85		// FIXME: TOCTOU86		if let Some(session) = &self.session.get() {87			return Ok((*session).clone());88		};89		let session = SessionBuilder::default();90		let session = session91			.connect(&self.name)92			.await93			.map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;94		let session = Arc::new(session);95		self.session.set(session.clone()).expect("TOCTOU happened");96		Ok(session)97	}98	pub async fn mktemp_dir(&self) -> Result<String> {99		let mut cmd = self.cmd("mktemp").await?;100		cmd.arg("-d");101		let path = cmd.run_string().await?;102		Ok(path.trim_end().to_owned())103	}104	pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {105		let mut cmd = self.cmd("cat").await?;106		cmd.arg(path);107		cmd.run_bytes().await108	}109	pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {110		let mut cmd = self.cmd("cat").await?;111		cmd.arg(path);112		cmd.run_string().await113	}114	pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {115		let mut cmd = self.cmd("ls").await?;116		cmd.arg(path);117		let out = cmd.run_string().await?;118		let mut lines = out.split('\n');119		if let Some(last) = lines.next_back() {120			ensure!(last.is_empty(), "output of ls should end with newline");121		}122		Ok(lines.map(ToOwned::to_owned).collect())123	}124	#[allow(dead_code)]125	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {126		let text = self.read_file_text(path).await?;127		Ok(serde_json::from_str(&text)?)128	}129	pub async fn read_env(&self, env: &str) -> Result<String> {130		let mut cmd = self.cmd("printenv").await?;131		cmd.arg(env);132		cmd.run_string().await133	}134	pub async fn find_in_path(&self, command: &str) -> Result<String> {135		// // `which` is not a part of coreutils, and it might not exist on machine.136		// let path = self.read_env("PATH").await?;137		// // Assuming delimiter is :, we don't work with windows host, this check will be much138		// // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)139		// for ele in path.split(':') {140		// 	let test_path = format!("{ele}/{cmd}");141		// 	test -x etc142		// }143		// let mut cmd = self.cmd("printenv").await?;144		// cmd.arg(env);145		// Ok(cmd.run_string().await?)146		// Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.147		let mut cmd = self148			.cmd_escalation(149				// Not used150				EscalationStrategy::Su,151				"which",152			)153			.await?;154		cmd.arg(command);155		cmd.run_string().await156	}157	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>158	where159		<D as FromStr>::Err: Display,160	{161		let text = self.read_file_text(path).await?;162		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))163	}164	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {165		self.cmd_escalation(self.escalation_strategy().await?, cmd)166			.await167	}168	pub async fn cmd_escalation(169		&self,170		escalation: EscalationStrategy,171		cmd: impl AsRef<OsStr>,172	) -> Result<MyCommand> {173		if self.local {174			Ok(MyCommand::new(escalation, cmd))175		} else {176			let session = self.open_session().await?;177			Ok(MyCommand::new_on(escalation, cmd, session))178		}179	}180181	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {182		ensure!(data.encrypted, "secret is not encrypted");183		let mut cmd = self.cmd("fleet-install-secrets").await?;184		cmd.arg("decrypt").eqarg("--secret", data.to_string());185		let encoded = cmd186			.sudo()187			.run_string()188			.await189			.context("failed to call remote host for decrypt")?;190		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;191		ensure!(!data.encrypted, "secret came out encrypted");192		Ok(data.data)193	}194	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {195		ensure!(data.encrypted, "secret is not encrypted");196		let mut cmd = self.cmd("fleet-install-secrets").await?;197		cmd.arg("reencrypt").eqarg("--secret", data.to_string());198		for target in targets {199			let key = self.config.key(&target).await?;200			cmd.eqarg("--targets", key);201		}202		let encoded = cmd203			.sudo()204			.run_string()205			.await206			.context("failed to call remote host for decrypt")?;207		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;208		ensure!(data.encrypted, "secret came out not encrypted");209		Ok(data)210	}211	/// Returns path for futureproofing, as path might change i.e on conversion to CA212	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {213		if self.local {214			// Path is located locally, thus already trusted.215			return Ok(path.to_owned());216		}217		let mut nix = MyCommand::new(218			// Not used219			EscalationStrategy::Su,220			"nix",221		);222		nix.arg("copy")223			.arg("--substitute-on-destination")224			.comparg("--to", format!("ssh-ng://{}", self.name))225			.arg(path);226		nix.run_nix().await.context("nix copy")?;227		Ok(path.to_owned())228	}229	pub async fn systemctl_stop(&self, name: &str) -> Result<()> {230		let mut cmd = self.cmd("systemctl").await?;231		cmd.arg("stop").arg(name);232		cmd.sudo().run().await233	}234	pub async fn systemctl_start(&self, name: &str) -> Result<()> {235		let mut cmd = self.cmd("systemctl").await?;236		cmd.arg("start").arg(name);237		cmd.sudo().run().await238	}239240	pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {241		let mut cmd = self.cmd("rm").await?;242		cmd.arg("-f").arg(path);243		if sudo {244			cmd = cmd.sudo()245		}246		cmd.run().await247	}248}249impl ConfigHost {250	// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,251	// assuming getting tags always returns the same value.252	pub async fn tags(&self) -> Result<Vec<String>> {253		if let Some(v) = self.groups.get() {254			return Ok(v.clone());255		}256		let Some(host_config) = &self.host_config else {257			return Ok(vec![]);258		};259		let tags: Vec<String> = nix_go_json!(host_config.tags);260261		let _ = self.groups.set(tags.clone());262263		Ok(tags)264	}265	pub async fn nixos_config(&self) -> Result<Value> {266		if let Some(v) = self.nixos_config.get() {267			return Ok(v.clone());268		}269		let Some(host_config) = &self.host_config else {270			bail!("local host has no nixos_config");271		};272		let nixos_config = nix_go!(host_config.nixos.config);273		assert_warn("nixos config evaluation", &nixos_config).await?;274275		let _ = self.nixos_config.set(nixos_config.clone());276277		Ok(nixos_config)278	}279280	pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {281		let nixos = self.nixos_config().await?;282		let secrets = nix_go!(nixos.secrets);283		let mut out = Vec::new();284		for name in secrets.list_fields().await? {285			let secret = nix_go!(secrets[{ name }]);286			let is_shared: bool = nix_go_json!(secret.shared);287			if is_shared {288				continue;289			}290			out.push(name);291		}292		Ok(out)293	}294	pub async fn secret_field(&self, name: &str) -> Result<Value> {295		let nixos = self.nixos_config().await?;296		Ok(nix_go!(nixos.secrets[{ name }]))297	}298299	/// Packages for this host, resolved with nixpkgs overlays300	pub async fn pkgs(&self) -> Result<Value> {301		if let Some(value) = &self.pkgs_override {302			return Ok(value.clone());303		}304		let Some(host_config) = &self.host_config else {305			bail!("local host has no host_config");306		};307		// TODO: Should nixos.options be cached?308		Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))309	}310}311312impl Config {313	pub fn local_host(&self) -> ConfigHost {314		ConfigHost {315			config: self.clone(),316			name: "<virtual localhost>".to_owned(),317			host_config: None,318			nixos_config: OnceCell::new(),319			groups: {320				let cell = OnceCell::new();321				let _ = cell.set(vec![]);322				cell323			},324			pkgs_override: Some(self.default_pkgs.clone()),325326			local: true,327			session: OnceLock::new(),328		}329	}330331	pub async fn host(&self, name: &str) -> Result<ConfigHost> {332		let config = &self.config_field;333		let host_config = nix_go!(config.hosts[{ name }]);334335		Ok(ConfigHost {336			config: self.clone(),337			name: name.to_owned(),338			host_config: Some(host_config),339			nixos_config: OnceCell::new(),340			groups: OnceCell::new(),341			pkgs_override: None,342343			// TODO: Remove with connectivit refactor344			local: self.localhost == name,345			session: OnceLock::new(),346		})347	}348	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {349		let config = &self.config_field;350		let names = nix_go!(config.hosts).list_fields().await?;351		let mut out = vec![];352		for name in names {353			out.push(self.host(&name).await?);354		}355		Ok(out)356	}357	// TODO: Replace usages with .host().nixos_config358	pub async fn system_config(&self, host: &str) -> Result<Value> {359		let fleet_field = &self.config_field;360		Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))361	}362363	/// Shared secrets configured in fleet.nix or in flake364	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {365		let config_field = &self.config_field;366		Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)367	}368	/// Shared secrets configured in fleet.nix369	pub fn list_shared(&self) -> Vec<String> {370		let data = self.data();371		data.shared_secrets.keys().cloned().collect()372	}373	pub fn has_shared(&self, name: &str) -> bool {374		let data = self.data();375		data.shared_secrets.contains_key(name)376	}377	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {378		let mut data = self.data_mut();379		data.shared_secrets.insert(name.to_owned(), shared);380	}381	pub fn remove_shared(&self, secret: &str) {382		let mut data = self.data_mut();383		data.shared_secrets.remove(secret);384	}385386	pub fn list_secrets(&self, host: &str) -> Vec<String> {387		let data = self.data();388		let Some(secrets) = data.host_secrets.get(host) else {389			return Vec::new();390		};391		secrets.keys().cloned().collect()392	}393394	pub fn has_secret(&self, host: &str, secret: &str) -> bool {395		let data = self.data();396		let Some(host_secrets) = data.host_secrets.get(host) else {397			return false;398		};399		host_secrets.contains_key(secret)400	}401	pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {402		let mut data = self.data_mut();403		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();404		host_secrets.insert(secret, value);405	}406407	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {408		let data = self.data();409		let Some(host_secrets) = data.host_secrets.get(host) else {410			bail!("no secrets for machine {host}");411		};412		let Some(secret) = host_secrets.get(secret) else {413			bail!("machine {host} has no secret {secret}");414		};415		Ok(secret.clone())416	}417	pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {418		let data = self.data();419		let Some(secret) = data.shared_secrets.get(secret) else {420			bail!("no shared secret {secret}");421		};422		Ok(secret.clone())423	}424	pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {425		let config_field = &self.config_field;426		Ok(nix_go_json!(427			config_field.sharedSecrets[{ secret }].expectedOwners428		))429	}430431	// TODO: Should this be something modifiable from other processes?432	// E.g terraform provider might want to update FleetData (e.g secrets),433	// and current implementation assumes only one process holds current fleet.nix434	// Given that it is no longer needs to be a file for nix evaluation,435	// maybe it can be a .nix file for persistence, but accessible only436	// thru some shared state controller? Might it be stored in terraform437	// state provider?438	pub fn data(&self) -> MutexGuard<FleetData> {439		self.data.lock().unwrap()440	}441	pub fn data_mut(&self) -> MutexGuard<FleetData> {442		self.data.lock().unwrap()443	}444	pub fn save(&self) -> Result<()> {445		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.")?;446		let data = nixlike::serialize(&self.data() as &FleetData)?;447		tempfile.write_all(448			format!(449				"# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",450				data451			)452			.as_bytes(),453		)?;454		let mut fleet_data_path = self.directory.clone();455		fleet_data_path.push("fleet.nix");456		tempfile.persist(fleet_data_path)?;457		Ok(())458	}459}
modifiedcrates/fleet-base/src/opts.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/opts.rs
+++ b/crates/fleet-base/src/opts.rs
@@ -196,11 +196,11 @@
 
 		let import = nix_go!(builtins_field.import);
 		let overlays = nix_go!(config_field.nixpkgs.overlays);
-		let nixpkgs = nix_go!(fleet_field.nixpkgs.buildUsing | import);
+		let nixpkgs = nix_go!(config_field.nixpkgs.buildUsing | import);
 
 		let default_pkgs = nix_go!(nixpkgs(Obj {
 			overlays,
-			system: { self.local_system.clone() },
+			system: { local_system.clone() },
 		}));
 
 		Ok(Config(Arc::new(FleetConfigInternals {
modifiedlib/flakePart.nixdiffbeforeafterboth
--- a/lib/flakePart.nix
+++ b/lib/flakePart.nix
@@ -9,7 +9,6 @@
   inherit (lib.attrsets) mapAttrs;
   inherit (lib.types) lazyAttrsOf deferredModule unspecified;
   inherit (lib.strings) isPath;
-  inherit (fleetLib.options) mkHostsOption;
 in {
   options.fleetModules = mkOption {
     type = lazyAttrsOf unspecified;
@@ -42,20 +41,18 @@
               ++ [
                 module
                 {
-                  options.hosts = mkHostsOption {
-                    nixos.nixpkgs.overlays = [
+                  config = {
+                    data =
+                      if isPath data
+                      then import data
+                      else data;
+                    nixpkgs.overlays = [
                       (final: prev:
                         import ../pkgs {
                           inherit (prev) callPackage;
                           craneLib = crane.mkLib prev;
                         })
                     ];
-                  };
-                  config = {
-                    data =
-                      if isPath data
-                      then import data
-                      else data;
                   };
                 }
               ];