1use std::{2 env::current_dir,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 clap::{ArgGroup, Parser};14use fleet_shared::SecretData;15use openssh::SessionBuilder;16use serde::de::DeserializeOwned;17use tempfile::NamedTempFile;1819use crate::{20 better_nix_eval::{Field, NixSessionPool},21 command::MyCommand,22 fleetdata::{FleetData, FleetSecret, FleetSharedSecret},23 nix_go, nix_go_json,24};2526pub struct FleetConfigInternals {27 pub local_system: String,28 pub directory: PathBuf,29 pub opts: FleetOpts,30 pub data: Mutex<FleetData>,31 pub nix_args: Vec<OsString>,32 33 pub config_field: Field,34 35 pub config_unchecked_field: Field,3637 38 pub default_pkgs: Field,39}4041#[derive(Clone)]42pub struct Config(Arc<FleetConfigInternals>);4344impl Deref for Config {45 type Target = FleetConfigInternals;4647 fn deref(&self) -> &Self::Target {48 &self.049 }50}5152pub struct ConfigHost {53 config: Config,54 pub name: String,55 pub local: bool,56 pub session: OnceLock<Arc<openssh::Session>>,5758 pub nixos_config: Option<Field>,59}60impl ConfigHost {61 async fn open_session(&self) -> Result<Arc<openssh::Session>> {62 assert!(!self.local, "do not open ssh connection to local session");63 64 if let Some(session) = &self.session.get() {65 return Ok((*session).clone());66 };67 let session = SessionBuilder::default();6869 let session = session70 .connect(&self.name)71 .await72 .map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;73 let session = Arc::new(session);74 self.session.set(session.clone()).expect("TOCTOU happened");75 Ok(session)76 }77 pub async fn mktemp_dir(&self) -> Result<String> {78 let mut cmd = self.cmd("mktemp").await?;79 cmd.arg("-d");80 let path = cmd.run_string().await?;81 Ok(path.trim_end().to_owned())82 }83 pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {84 let mut cmd = self.cmd("cat").await?;85 cmd.arg(path);86 cmd.run_bytes().await87 }88 pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {89 let mut cmd = self.cmd("cat").await?;90 cmd.arg(path);91 cmd.run_string().await92 }93 pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {94 let mut cmd = self.cmd("ls").await?;95 cmd.arg(path);96 let out = cmd.run_string().await?;97 let mut lines = out.split('\n');98 if let Some(last) = lines.next_back() {99 ensure!(last == "", "output of ls should end with newline");100 }101 Ok(lines.map(ToOwned::to_owned).collect())102 }103 #[allow(dead_code)]104 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {105 let text = self.read_file_text(path).await?;106 Ok(serde_json::from_str(&text)?)107 }108 pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>109 where110 <D as FromStr>::Err: Display,111 {112 let text = self.read_file_text(path).await?;113 D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))114 }115 pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {116 if self.local {117 Ok(MyCommand::new(cmd))118 } else {119 let session = self.open_session().await?;120 Ok(MyCommand::new_on(cmd, session))121 }122 }123124 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {125 ensure!(data.encrypted, "secret is not encrypted");126 let mut cmd = self.cmd("fleet-install-secrets").await?;127 cmd.arg("decrypt").eqarg("--secret", data.to_string());128 let encoded = cmd129 .sudo()130 .run_string()131 .await132 .context("failed to call remote host for decrypt")?;133 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;134 ensure!(!data.encrypted, "didn't decrypted secret");135 Ok(data.data)136 }137 pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {138 ensure!(data.encrypted, "secret is not encrypted");139 let mut cmd = self.cmd("fleet-install-secrets").await?;140 cmd.arg("reencrypt").eqarg("--secret", data.to_string());141 for target in targets {142 let key = self.config.key(&target).await?;143 cmd.eqarg("--targets", key);144 }145 let encoded = cmd146 .sudo()147 .run_string()148 .await149 .context("failed to call remote host for decrypt")?;150 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;151 ensure!(!data.encrypted, "didn't decrypted secret");152 Ok(data)153 }154 155 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {156 if self.local {157 158 return Ok(path.to_owned());159 }160 let mut nix = MyCommand::new("nix");161 nix.arg("copy")162 .arg("--substitute-on-destination")163 .comparg("--to", format!("ssh-ng://{}", self.name))164 .arg(path);165 nix.run_nix().await.context("nix copy")?;166 Ok(path.to_owned())167 }168 pub async fn systemctl_stop(&self, name: &str) -> Result<()> {169 let mut cmd = self.cmd("systemctl").await?;170 cmd.arg("stop").arg(name);171 cmd.sudo().run().await172 }173 pub async fn systemctl_start(&self, name: &str) -> Result<()> {174 let mut cmd = self.cmd("systemctl").await?;175 cmd.arg("start").arg(name);176 cmd.sudo().run().await177 }178179 pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {180 let mut cmd = self.cmd("rm").await?;181 cmd.arg("-f").arg(path);182 if sudo {183 cmd = cmd.sudo()184 }185 cmd.run().await186 }187188 pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {189 let Some(nixos) = &self.nixos_config else {190 return Ok(vec![]);191 };192 let secrets = nix_go!(nixos.secrets);193 let mut out = Vec::new();194 for name in secrets.list_fields().await? {195 let secret = nix_go!(secrets[{ name }]);196 let is_shared: bool = nix_go_json!(secret.shared);197 if is_shared {198 continue;199 }200 out.push(name);201 }202 Ok(out)203 }204 pub async fn secret_field(&self, name: &str) -> Result<Field> {205 let Some(nixos) = &self.nixos_config else {206 bail!("host is virtual and has no secrets");207 };208 Ok(nix_go!(nixos.secrets[{ name }]))209 }210211 212 pub async fn pkgs(&self) -> Result<Field> {213 let Some(nixos) = &self.nixos_config else {214 return Ok(self.config.default_pkgs.clone());215 };216 Ok(nix_go!(nixos.nixpkgs.resolvedPkgs))217 }218}219220impl Config {221 pub fn should_skip(&self, host: &str) -> bool {222 if !self.opts.skip.is_empty() {223 self.opts.skip.iter().any(|h| h as &str == host)224 } else if !self.opts.only.is_empty() {225 !self.opts.only.iter().any(|h| h as &str == host)226 } else {227 false228 }229 }230 pub fn is_local(&self, host: &str) -> bool {231 self.opts.localhost.as_ref().map(|s| s as &str) == Some(host)232 }233234 pub fn local_host(&self) -> ConfigHost {235 ConfigHost {236 config: self.clone(),237 name: "<virtual localhost>".to_owned(),238 local: true,239 session: OnceLock::new(),240 nixos_config: None,241 }242 }243244 pub async fn host(&self, name: &str) -> Result<ConfigHost> {245 let config = &self.config_unchecked_field;246 let nixos_config = nix_go!(config.hosts[{ name }].nixosSystem.config);247 Ok(ConfigHost {248 config: self.clone(),249 name: name.to_owned(),250 local: self.is_local(name),251 session: OnceLock::new(),252 nixos_config: Some(nixos_config),253 })254 }255 pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {256 let config = &self.config_unchecked_field;257 let names = nix_go!(config.hosts).list_fields().await?;258 let mut out = vec![];259 for name in names {260 out.push(self.host(&name).await?);261 }262 Ok(out)263 }264 pub async fn system_config(&self, host: &str) -> Result<Field> {265 let fleet_field = &self.config_unchecked_field;266 Ok(nix_go!(fleet_field.hosts[{ host }].nixosSystem.config))267 }268269 pub(super) fn data(&self) -> MutexGuard<FleetData> {270 self.data.lock().unwrap()271 }272 pub(super) fn data_mut(&self) -> MutexGuard<FleetData> {273 self.data.lock().unwrap()274 }275 276 pub async fn list_configured_shared(&self) -> Result<Vec<String>> {277 let config_field = &self.config_unchecked_field;278 nix_go!(config_field.sharedSecrets).list_fields().await279 }280 281 pub fn list_shared(&self) -> Vec<String> {282 let data = self.data();283 data.shared_secrets.keys().cloned().collect()284 }285 pub fn has_shared(&self, name: &str) -> bool {286 let data = self.data();287 data.shared_secrets.contains_key(name)288 }289 pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {290 let mut data = self.data_mut();291 data.shared_secrets.insert(name.to_owned(), shared);292 }293 pub fn remove_shared(&self, secret: &str) {294 let mut data = self.data_mut();295 data.shared_secrets.remove(secret);296 }297298 pub fn list_secrets(&self, host: &str) -> Vec<String> {299 let data = self.data();300 let Some(secrets) = data.host_secrets.get(host) else {301 return Vec::new();302 };303 secrets.keys().cloned().collect()304 }305306 pub fn has_secret(&self, host: &str, secret: &str) -> bool {307 let data = self.data();308 let Some(host_secrets) = data.host_secrets.get(host) else {309 return false;310 };311 host_secrets.contains_key(secret)312 }313 pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {314 let mut data = self.data_mut();315 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();316 host_secrets.insert(secret, value);317 }318319 pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {320 let data = self.data();321 let Some(host_secrets) = data.host_secrets.get(host) else {322 bail!("no secrets for machine {host}");323 };324 let Some(secret) = host_secrets.get(secret) else {325 bail!("machine {host} has no secret {secret}");326 };327 Ok(secret.clone())328 }329 pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {330 let data = self.data();331 let Some(secret) = data.shared_secrets.get(secret) else {332 bail!("no shared secret {secret}");333 };334 Ok(secret.clone())335 }336 pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {337 let config_field = &self.config_unchecked_field;338 Ok(nix_go_json!(339 config_field.sharedSecrets[{ secret }].expectedOwners340 ))341 }342343 pub fn save(&self) -> Result<()> {344 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.")?;345 let data = nixlike::serialize(&self.data() as &FleetData)?;346 tempfile.write_all(347 format!(348 "# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",349 data350 )351 .as_bytes(),352 )?;353 let mut fleet_data_path = self.directory.clone();354 fleet_data_path.push("fleet.nix");355 tempfile.persist(fleet_data_path)?;356 Ok(())357 }358}359360#[derive(Parser, Clone)]361#[clap(group = ArgGroup::new("target_hosts"))]362pub struct FleetOpts {363 364 #[clap(long, number_of_values = 1, group = "target_hosts")]365 only: Vec<String>,366367 368 #[clap(long, number_of_values = 1, group = "target_hosts")]369 skip: Vec<String>,370371 372 #[clap(long)]373 pub localhost: Option<String>,374375 376 377 #[clap(long, default_value = "detect")]378 pub local_system: String,379}380381impl FleetOpts {382 pub async fn build(mut self, nix_args: Vec<OsString>) -> Result<Config> {383 if self.localhost.is_none() {384 self.localhost385 .replace(hostname::get().unwrap().to_str().unwrap().to_owned());386 }387 let directory = current_dir()?;388389 let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;390 let root_field = pool.get().await?;391392 let builtins_field = Field::field(root_field.clone(), "builtins").await?;393 if self.local_system == "detect" {394 self.local_system = nix_go_json!(builtins_field.currentSystem);395 }396 let local_system = self.local_system.clone();397398 let fleet_root = Field::field(root_field, "fleetConfigurations").await?;399 let fleet_field = nix_go!(fleet_root.default);400401 let config_field = nix_go!(fleet_field.config);402 let config_unchecked_field = nix_go!(fleet_field.unchecked.config);403404 let import = nix_go!(builtins_field.import);405 let overlays = nix_go!(config_unchecked_field.overlays);406 let nixpkgs = nix_go!(fleet_field.nixpkgs | import);407408 let default_pkgs = nix_go!(nixpkgs(Obj {409 overlays,410 system: { self.local_system.clone() },411 }));412413 let mut fleet_data_path = directory.clone();414 fleet_data_path.push("fleet.nix");415 let bytes = std::fs::read_to_string(fleet_data_path)?;416 let data = nixlike::parse_str(&bytes)?;417418 Ok(Config(Arc::new(FleetConfigInternals {419 opts: self,420 directory,421 data,422 local_system,423 nix_args,424 config_field,425 config_unchecked_field,426 default_pkgs,427 })))428 }429}