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, Context, Result};13use clap::{ArgGroup, Parser};14use openssh::SessionBuilder;15use serde::de::DeserializeOwned;16use tempfile::NamedTempFile;1718use crate::{19 better_nix_eval::{Field, NixSessionPool},20 command::MyCommand,21 fleetdata::{FleetData, FleetSecret, FleetSharedSecret, SecretData},22 nix_go, nix_go_json,23};2425pub struct FleetConfigInternals {26 pub local_system: String,27 pub directory: PathBuf,28 pub opts: FleetOpts,29 pub data: Mutex<FleetData>,30 pub nix_args: Vec<OsString>,31 32 pub config_field: Field,33 34 pub config_unchecked_field: Field,3536 37 pub default_pkgs: Field,38}3940#[derive(Clone)]41pub struct Config(Arc<FleetConfigInternals>);4243impl Deref for Config {44 type Target = FleetConfigInternals;4546 fn deref(&self) -> &Self::Target {47 &self.048 }49}5051pub struct ConfigHost {52 config: Config,53 pub name: String,54 pub local: bool,55 pub session: OnceLock<Arc<openssh::Session>>,5657 pub nixos_config: Option<Field>,58}59impl ConfigHost {60 async fn open_session(&self) -> Result<Arc<openssh::Session>> {61 assert!(!self.local, "do not open ssh connection to local session");62 63 if let Some(session) = &self.session.get() {64 return Ok((*session).clone());65 };66 let session = SessionBuilder::default();6768 let session = session69 .connect(&self.name)70 .await71 .map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;72 let session = Arc::new(session);73 self.session.set(session.clone()).expect("TOCTOU happened");74 Ok(session)75 }76 pub async fn mktemp_dir(&self) -> Result<String> {77 let mut cmd = self.cmd("mktemp").await?;78 cmd.arg("-d");79 let path = cmd.run_string().await?;80 Ok(path.trim_end().to_owned())81 }82 pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {83 let mut cmd = self.cmd("cat").await?;84 cmd.arg(path);85 cmd.run_bytes().await86 }87 pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {88 let mut cmd = self.cmd("cat").await?;89 cmd.arg(path);90 cmd.run_string().await91 }92 #[allow(dead_code)]93 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {94 let text = self.read_file_text(path).await?;95 Ok(serde_json::from_str(&text)?)96 }97 pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>98 where99 <D as FromStr>::Err: Display,100 {101 let text = self.read_file_text(path).await?;102 D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))103 }104 pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {105 if self.local {106 Ok(MyCommand::new(cmd))107 } else {108 let session = self.open_session().await?;109 Ok(MyCommand::new_on(cmd, session))110 }111 }112113 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {114 let mut cmd = self.cmd("fleet-install-secrets").await?;115 cmd.arg("decrypt").eqarg("--secret", data.encode_z85());116 let encoded = cmd117 .sudo()118 .run_string()119 .await120 .context("failed to call remote host for decrypt")?;121 z85::decode(encoded.trim_end()).context("bad encoded data? outdated host?")122 }123 pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {124 let mut cmd = self.cmd("fleet-install-secrets").await?;125 cmd.arg("reencrypt").eqarg("--secret", data.encode_z85());126 for target in targets {127 let key = self.config.key(&target).await?;128 cmd.eqarg("--targets", key);129 }130 let encoded = cmd131 .sudo()132 .run_string()133 .await134 .context("failed to call remote host for decrypt")?;135 SecretData::decode_z85(encoded.trim_end()).context("bad encoded data? outdated host?")136 }137 138 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {139 if self.local {140 141 return Ok(path.to_owned());142 }143 let mut nix = MyCommand::new("nix");144 nix.arg("copy")145 .arg("--substitute-on-destination")146 .comparg("--to", format!("ssh-ng://{}", self.name))147 .arg(path);148 nix.run_nix().await.context("nix copy")?;149 Ok(path.to_owned())150 }151 pub async fn systemctl_stop(&self, name: &str) -> Result<()> {152 let mut cmd = self.cmd("systemctl").await?;153 cmd.arg("stop").arg(name);154 cmd.sudo().run().await155 }156 pub async fn systemctl_start(&self, name: &str) -> Result<()> {157 let mut cmd = self.cmd("systemctl").await?;158 cmd.arg("start").arg(name);159 cmd.sudo().run().await160 }161162 pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {163 let mut cmd = self.cmd("rm").await?;164 cmd.arg("-f").arg(path);165 if sudo {166 cmd = cmd.sudo()167 }168 cmd.run().await169 }170171 pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {172 let Some(nixos) = &self.nixos_config else {173 return Ok(vec![]);174 };175 let secrets = nix_go!(nixos.secrets);176 let mut out = Vec::new();177 for name in secrets.list_fields().await? {178 let secret = nix_go!(secrets[{ name }]);179 let is_shared: bool = nix_go_json!(secret.shared);180 if is_shared {181 continue;182 }183 out.push(name);184 }185 Ok(out)186 }187 pub async fn secret_field(&self, name: &str) -> Result<Field> {188 let Some(nixos) = &self.nixos_config else {189 bail!("host is virtual and has no secrets");190 };191 Ok(nix_go!(nixos.secrets[{ name }]))192 }193194 195 pub async fn pkgs(&self) -> Result<Field> {196 let Some(nixos) = &self.nixos_config else {197 return Ok(self.config.default_pkgs.clone());198 };199 Ok(nix_go!(nixos.nixpkgs.resolvedPkgs))200 }201}202203impl Config {204 pub fn should_skip(&self, host: &str) -> bool {205 if !self.opts.skip.is_empty() {206 self.opts.skip.iter().any(|h| h as &str == host)207 } else if !self.opts.only.is_empty() {208 !self.opts.only.iter().any(|h| h as &str == host)209 } else {210 false211 }212 }213 pub fn is_local(&self, host: &str) -> bool {214 self.opts.localhost.as_ref().map(|s| s as &str) == Some(host)215 }216217 pub fn local_host(&self) -> ConfigHost {218 ConfigHost {219 config: self.clone(),220 name: "<virtual localhost>".to_owned(),221 local: true,222 session: OnceLock::new(),223 nixos_config: None,224 }225 }226227 pub async fn host(&self, name: &str) -> Result<ConfigHost> {228 let config = &self.config_unchecked_field;229 let nixos_config = nix_go!(config.hosts[{ name }].nixosSystem.config);230 Ok(ConfigHost {231 config: self.clone(),232 name: name.to_owned(),233 local: self.is_local(name),234 session: OnceLock::new(),235 nixos_config: Some(nixos_config),236 })237 }238 pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {239 let config = &self.config_unchecked_field;240 let names = nix_go!(config.hosts).list_fields().await?;241 let mut out = vec![];242 for name in names {243 out.push(self.host(&name).await?);244 }245 Ok(out)246 }247 pub async fn system_config(&self, host: &str) -> Result<Field> {248 let fleet_field = &self.config_unchecked_field;249 Ok(nix_go!(fleet_field.hosts[{ host }].nixosSystem.config))250 }251252 pub(super) fn data(&self) -> MutexGuard<FleetData> {253 self.data.lock().unwrap()254 }255 pub(super) fn data_mut(&self) -> MutexGuard<FleetData> {256 self.data.lock().unwrap()257 }258 259 pub async fn list_configured_shared(&self) -> Result<Vec<String>> {260 let config_field = &self.config_unchecked_field;261 nix_go!(config_field.sharedSecrets).list_fields().await262 }263 264 pub fn list_shared(&self) -> Vec<String> {265 let data = self.data();266 data.shared_secrets.keys().cloned().collect()267 }268 pub fn has_shared(&self, name: &str) -> bool {269 let data = self.data();270 data.shared_secrets.contains_key(name)271 }272 pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {273 let mut data = self.data_mut();274 data.shared_secrets.insert(name.to_owned(), shared);275 }276 pub fn remove_shared(&self, secret: &str) {277 let mut data = self.data_mut();278 data.shared_secrets.remove(secret);279 }280281 pub fn list_secrets(&self, host: &str) -> Vec<String> {282 let data = self.data();283 let Some(secrets) = data.host_secrets.get(host) else {284 return Vec::new();285 };286 secrets.keys().cloned().collect()287 }288289 pub fn has_secret(&self, host: &str, secret: &str) -> bool {290 let data = self.data();291 let Some(host_secrets) = data.host_secrets.get(host) else {292 return false;293 };294 host_secrets.contains_key(secret)295 }296 pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {297 let mut data = self.data_mut();298 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();299 host_secrets.insert(secret, value);300 }301302 pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {303 let data = self.data();304 let Some(host_secrets) = data.host_secrets.get(host) else {305 bail!("no secrets for machine {host}");306 };307 let Some(secret) = host_secrets.get(secret) else {308 bail!("machine {host} has no secret {secret}");309 };310 Ok(secret.clone())311 }312 pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {313 let data = self.data();314 let Some(secret) = data.shared_secrets.get(secret) else {315 bail!("no shared secret {secret}");316 };317 Ok(secret.clone())318 }319 pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {320 let config_field = &self.config_unchecked_field;321 Ok(nix_go_json!(322 config_field.sharedSecrets[{ secret }].expectedOwners323 ))324 }325326 pub fn save(&self) -> Result<()> {327 let mut tempfile = NamedTempFile::new_in(self.directory.clone())?;328 let data = nixlike::serialize(&self.data() as &FleetData)?;329 tempfile.write_all(330 format!(331 "# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",332 data333 )334 .as_bytes(),335 )?;336 let mut fleet_data_path = self.directory.clone();337 fleet_data_path.push("fleet.nix");338 tempfile.persist(fleet_data_path)?;339 Ok(())340 }341}342343#[derive(Parser, Clone)]344#[clap(group = ArgGroup::new("target_hosts"))]345pub struct FleetOpts {346 347 #[clap(long, number_of_values = 1, group = "target_hosts")]348 only: Vec<String>,349350 351 #[clap(long, number_of_values = 1, group = "target_hosts")]352 skip: Vec<String>,353354 355 #[clap(long)]356 pub localhost: Option<String>,357358 359 360 #[clap(long, default_value = "detect")]361 pub local_system: String,362}363364impl FleetOpts {365 pub async fn build(mut self, nix_args: Vec<OsString>) -> Result<Config> {366 if self.localhost.is_none() {367 self.localhost368 .replace(hostname::get().unwrap().to_str().unwrap().to_owned());369 }370 let directory = current_dir()?;371372 let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;373 let root_field = pool.get().await?;374375 let builtins_field = Field::field(root_field.clone(), "builtins").await?;376 if self.local_system == "detect" {377 self.local_system = nix_go_json!(builtins_field.currentSystem);378 }379 let local_system = self.local_system.clone();380381 let fleet_root = Field::field(root_field, "fleetConfigurations").await?;382 let fleet_field = nix_go!(fleet_root.default);383384 let config_field = nix_go!(fleet_field.config);385 let config_unchecked_field = nix_go!(fleet_field.unchecked.config);386387 let import = nix_go!(builtins_field.import);388 let overlays = nix_go!(config_unchecked_field.overlays);389 let nixpkgs = nix_go!(fleet_field.nixpkgs | import);390391 let default_pkgs = nix_go!(nixpkgs(Obj {392 overlays,393 system: { self.local_system.clone() },394 }));395396 let mut fleet_data_path = directory.clone();397 fleet_data_path.push("fleet.nix");398 let bytes = std::fs::read_to_string(fleet_data_path)?;399 let data = nixlike::parse_str(&bytes)?;400401 Ok(Config(Arc::new(FleetConfigInternals {402 opts: self,403 directory,404 data,405 local_system,406 nix_args,407 config_field,408 config_unchecked_field,409 default_pkgs,410 })))411 }412}