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>,64 pub pkgs_override: Option<Value>,6566 67 pub local: bool,68 pub session: OnceLock<Arc<openssh::Session>>,69}7071impl ConfigHost {72 pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {73 74 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 86 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 136 137 138 139 140 141 142 143 144 145 146 147 let mut cmd = self148 .cmd_escalation(149 150 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 212 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {213 if self.local {214 215 return Ok(path.to_owned());216 }217 let mut nix = MyCommand::new(218 219 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 251 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 300 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 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 344 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 358 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 364 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 369 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 432 433 434 435 436 437 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}