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, NixSession, 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 30 pub config_field: Value,31 32 pub localhost: String,3334 35 pub default_pkgs: Value,3637 pub nix_session: NixSession,38}394041#[derive(Clone)]42pub struct Config(pub Arc<FleetConfigInternals>);4344impl Deref for Config {45 type Target = FleetConfigInternals;4647 fn deref(&self) -> &Self::Target {48 &self.049 }50}5152#[derive(Clone, Copy, Debug)]53pub enum EscalationStrategy {54 Sudo,55 Run0,56 Su,57}5859pub struct ConfigHost {60 config: Config,61 pub name: String,62 groups: OnceCell<Vec<String>>,6364 pub host_config: Option<Value>,65 pub nixos_config: OnceCell<Value>,66 pub pkgs_override: Option<Value>,6768 69 pub local: bool,70 pub session: OnceLock<Arc<openssh::Session>>,71}7273impl ConfigHost {74 pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {75 76 77 if (self.find_in_path("sudo").await).is_ok() {78 return Ok(EscalationStrategy::Sudo);79 }80 if (self.find_in_path("run0").await).is_ok() {81 return Ok(EscalationStrategy::Run0);82 }83 Ok(EscalationStrategy::Su)84 }85 async fn open_session(&self) -> Result<Arc<openssh::Session>> {86 assert!(!self.local, "do not open ssh connection to local session");87 88 if let Some(session) = &self.session.get() {89 return Ok((*session).clone());90 };91 let session = SessionBuilder::default();92 let session = session93 .connect(&self.name)94 .await95 .map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;96 let session = Arc::new(session);97 self.session.set(session.clone()).expect("TOCTOU happened");98 Ok(session)99 }100 pub async fn mktemp_dir(&self) -> Result<String> {101 let mut cmd = self.cmd("mktemp").await?;102 cmd.arg("-d");103 let path = cmd.run_string().await?;104 Ok(path.trim_end().to_owned())105 }106 pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {107 let mut cmd = self.cmd("cat").await?;108 cmd.arg(path);109 cmd.run_bytes().await110 }111 pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {112 let mut cmd = self.cmd("cat").await?;113 cmd.arg(path);114 cmd.run_string().await115 }116 pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {117 let mut cmd = self.cmd("ls").await?;118 cmd.arg(path);119 let out = cmd.run_string().await?;120 let mut lines = out.split('\n');121 if let Some(last) = lines.next_back() {122 ensure!(last.is_empty(), "output of ls should end with newline");123 }124 Ok(lines.map(ToOwned::to_owned).collect())125 }126 #[allow(dead_code)]127 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {128 let text = self.read_file_text(path).await?;129 Ok(serde_json::from_str(&text)?)130 }131 pub async fn read_env(&self, env: &str) -> Result<String> {132 let mut cmd = self.cmd("printenv").await?;133 cmd.arg(env);134 cmd.run_string().await135 }136 pub async fn find_in_path(&self, command: &str) -> Result<String> {137 138 139 140 141 142 143 144 145 146 147 148 149 let mut cmd = self150 .cmd_escalation(151 152 EscalationStrategy::Su,153 "which",154 )155 .await?;156 cmd.arg(command);157 cmd.run_string().await158 }159 pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>160 where161 <D as FromStr>::Err: Display,162 {163 let text = self.read_file_text(path).await?;164 D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))165 }166 pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {167 self.cmd_escalation(self.escalation_strategy().await?, cmd)168 .await169 }170 pub async fn cmd_escalation(171 &self,172 escalation: EscalationStrategy,173 cmd: impl AsRef<OsStr>,174 ) -> Result<MyCommand> {175 if self.local {176 Ok(MyCommand::new(escalation, cmd))177 } else {178 let session = self.open_session().await?;179 Ok(MyCommand::new_on(escalation, cmd, session))180 }181 }182183 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {184 ensure!(data.encrypted, "secret is not encrypted");185 let mut cmd = self.cmd("fleet-install-secrets").await?;186 cmd.arg("decrypt").eqarg("--secret", data.to_string());187 let encoded = cmd188 .sudo()189 .run_string()190 .await191 .context("failed to call remote host for decrypt")?;192 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;193 ensure!(!data.encrypted, "secret came out encrypted");194 Ok(data.data)195 }196 pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {197 ensure!(data.encrypted, "secret is not encrypted");198 let mut cmd = self.cmd("fleet-install-secrets").await?;199 cmd.arg("reencrypt").eqarg("--secret", data.to_string());200 for target in targets {201 let key = self.config.key(&target).await?;202 cmd.eqarg("--targets", key);203 }204 let encoded = cmd205 .sudo()206 .run_string()207 .await208 .context("failed to call remote host for decrypt")?;209 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;210 ensure!(data.encrypted, "secret came out not encrypted");211 Ok(data)212 }213 214 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {215 if self.local {216 217 return Ok(path.to_owned());218 }219 let mut nix = MyCommand::new(220 221 EscalationStrategy::Su,222 "nix",223 );224 nix.arg("copy")225 .arg("--substitute-on-destination")226 .comparg("--to", format!("ssh-ng://{}", self.name))227 .arg(path);228 nix.run_nix().await.context("nix copy")?;229 Ok(path.to_owned())230 }231 pub async fn systemctl_stop(&self, name: &str) -> Result<()> {232 let mut cmd = self.cmd("systemctl").await?;233 cmd.arg("stop").arg(name);234 cmd.sudo().run().await235 }236 pub async fn systemctl_start(&self, name: &str) -> Result<()> {237 let mut cmd = self.cmd("systemctl").await?;238 cmd.arg("start").arg(name);239 cmd.sudo().run().await240 }241242 pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {243 let mut cmd = self.cmd("rm").await?;244 cmd.arg("-f").arg(path);245 if sudo {246 cmd = cmd.sudo()247 }248 cmd.run().await249 }250}251impl ConfigHost {252 253 254 pub async fn tags(&self) -> Result<Vec<String>> {255 if let Some(v) = self.groups.get() {256 return Ok(v.clone());257 }258 let Some(host_config) = &self.host_config else {259 return Ok(vec![]);260 };261 let tags: Vec<String> = nix_go_json!(host_config.tags);262263 let _ = self.groups.set(tags.clone());264265 Ok(tags)266 }267 pub async fn nixos_config(&self) -> Result<Value> {268 if let Some(v) = self.nixos_config.get() {269 return Ok(v.clone());270 }271 let Some(host_config) = &self.host_config else {272 bail!("local host has no nixos_config");273 };274 let nixos_config = nix_go!(host_config.nixos.config);275 assert_warn("nixos config evaluation", &nixos_config).await?;276277 let _ = self.nixos_config.set(nixos_config.clone());278279 Ok(nixos_config)280 }281282 pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {283 let nixos = self.nixos_config().await?;284 let secrets = nix_go!(nixos.secrets);285 let mut out = Vec::new();286 for name in secrets.list_fields().await? {287 let secret = nix_go!(secrets[{ name }]);288 let is_shared: bool = nix_go_json!(secret.shared);289 if is_shared {290 continue;291 }292 out.push(name);293 }294 Ok(out)295 }296 pub async fn secret_field(&self, name: &str) -> Result<Value> {297 let nixos = self.nixos_config().await?;298 Ok(nix_go!(nixos.secrets[{ name }]))299 }300301 302 pub async fn pkgs(&self) -> Result<Value> {303 if let Some(value) = &self.pkgs_override {304 return Ok(value.clone());305 }306 let Some(host_config) = &self.host_config else {307 bail!("local host has no host_config");308 };309 310 Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))311 }312}313314impl Config {315 pub fn local_host(&self) -> ConfigHost {316 ConfigHost {317 config: self.clone(),318 name: "<virtual localhost>".to_owned(),319 host_config: None,320 nixos_config: OnceCell::new(),321 groups: {322 let cell = OnceCell::new();323 let _ = cell.set(vec![]);324 cell325 },326 pkgs_override: Some(self.default_pkgs.clone()),327328 local: true,329 session: OnceLock::new(),330 }331 }332333 pub async fn host(&self, name: &str) -> Result<ConfigHost> {334 let config = &self.config_field;335 let host_config = nix_go!(config.hosts[{ name }]);336337 Ok(ConfigHost {338 config: self.clone(),339 name: name.to_owned(),340 host_config: Some(host_config),341 nixos_config: OnceCell::new(),342 groups: OnceCell::new(),343 pkgs_override: None,344345 346 local: self.localhost == name,347 session: OnceLock::new(),348 })349 }350 pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {351 let config = &self.config_field;352 let names = nix_go!(config.hosts).list_fields().await?;353 let mut out = vec![];354 for name in names {355 out.push(self.host(&name).await?);356 }357 Ok(out)358 }359 360 pub async fn system_config(&self, host: &str) -> Result<Value> {361 let fleet_field = &self.config_field;362 Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))363 }364365 366 pub async fn list_configured_shared(&self) -> Result<Vec<String>> {367 let config_field = &self.config_field;368 Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)369 }370 371 pub fn list_shared(&self) -> Vec<String> {372 let data = self.data();373 data.shared_secrets.keys().cloned().collect()374 }375 pub fn has_shared(&self, name: &str) -> bool {376 let data = self.data();377 data.shared_secrets.contains_key(name)378 }379 pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {380 let mut data = self.data_mut();381 data.shared_secrets.insert(name.to_owned(), shared);382 }383 pub fn remove_shared(&self, secret: &str) {384 let mut data = self.data_mut();385 data.shared_secrets.remove(secret);386 }387388 pub fn list_secrets(&self, host: &str) -> Vec<String> {389 let data = self.data();390 let Some(secrets) = data.host_secrets.get(host) else {391 return Vec::new();392 };393 secrets.keys().cloned().collect()394 }395396 pub fn has_secret(&self, host: &str, secret: &str) -> bool {397 let data = self.data();398 let Some(host_secrets) = data.host_secrets.get(host) else {399 return false;400 };401 host_secrets.contains_key(secret)402 }403 pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {404 let mut data = self.data_mut();405 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();406 host_secrets.insert(secret, value);407 }408409 pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {410 let data = self.data();411 let Some(host_secrets) = data.host_secrets.get(host) else {412 bail!("no secrets for machine {host}");413 };414 let Some(secret) = host_secrets.get(secret) else {415 bail!("machine {host} has no secret {secret}");416 };417 Ok(secret.clone())418 }419 pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {420 let data = self.data();421 let Some(secret) = data.shared_secrets.get(secret) else {422 bail!("no shared secret {secret}");423 };424 Ok(secret.clone())425 }426 pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {427 let config_field = &self.config_field;428 Ok(nix_go_json!(429 config_field.sharedSecrets[{ secret }].expectedOwners430 ))431 }432433 434 435 436 437 438 439 440 pub fn data(&self) -> MutexGuard<FleetData> {441 self.data.lock().unwrap()442 }443 pub fn data_mut(&self) -> MutexGuard<FleetData> {444 self.data.lock().unwrap()445 }446 pub fn save(&self) -> Result<()> {447 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.")?;448 let data = nixlike::serialize(&self.data() as &FleetData)?;449 tempfile.write_all(450 format!(451 "# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",452 data453 )454 .as_bytes(),455 )?;456 let mut fleet_data_path = self.directory.clone();457 fleet_data_path.push("fleet.nix");458 tempfile.persist(fleet_data_path)?;459 Ok(())460 }461}