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 30 pub config_field: Value,31 32 pub localhost: String,3334 35 pub default_pkgs: Value,36}373839#[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 66 pub local: bool,67 pub session: OnceLock<Arc<openssh::Session>>,68}6970impl ConfigHost {71 pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {72 73 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 85 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 135 136 137 138 139 140 141 142 143 144 145 146 let mut cmd = self147 .cmd_escalation(148 149 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 211 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {212 if self.local {213 214 return Ok(path.to_owned());215 }216 let mut nix = MyCommand::new(217 218 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 250 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 299 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 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 337 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 351 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 357 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 362 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 425 426 427 428 429 430 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}