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 fleet_field: Field,33 34 pub config_field: Field,35 36 pub config_unchecked_field: Field,37}3839#[derive(Clone)]40pub struct Config(Arc<FleetConfigInternals>);4142impl Deref for Config {43 type Target = FleetConfigInternals;4445 fn deref(&self) -> &Self::Target {46 &self.047 }48}4950pub struct ConfigHost {51 pub name: String,52 pub local: bool,53 pub session: OnceLock<Arc<openssh::Session>>,54}55impl ConfigHost {56 async fn open_session(&self) -> Result<Arc<openssh::Session>> {57 assert!(!self.local, "do not open ssh connection to local session");58 59 if let Some(session) = &self.session.get() {60 return Ok((*session).clone());61 };62 let session = SessionBuilder::default();6364 let session = session65 .connect(&self.name)66 .await67 .map_err(|e| anyhow!("ssh error: {e}"))?;68 let session = Arc::new(session);69 self.session.set(session.clone()).expect("TOCTOU happened");70 Ok(session)71 }72 pub async fn mktemp_dir(&self) -> Result<String> {73 let mut cmd = self.cmd("mktemp").await?;74 cmd.arg("-d");75 let path = cmd.run_string().await?;76 Ok(path.trim_end().to_owned())77 }78 pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {79 let mut cmd = self.cmd("cat").await?;80 cmd.arg(path);81 cmd.run_bytes().await82 }83 pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {84 let mut cmd = self.cmd("cat").await?;85 cmd.arg(path);86 cmd.run_string().await87 }88 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {89 let text = self.read_file_text(path).await?;90 Ok(serde_json::from_str(&text)?)91 }92 pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>93 where94 <D as FromStr>::Err: Display,95 {96 let text = self.read_file_text(path).await?;97 D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))98 }99 pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {100 if self.local {101 Ok(MyCommand::new(cmd))102 } else {103 let session = self.open_session().await?;104 Ok(MyCommand::new_on(cmd, session))105 }106 }107108 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {109 let mut cmd = self.cmd("fleet-install-secrets").await?;110 cmd.arg("decrypt").eqarg("--secret", data.encode_z85());111 let encoded = cmd112 .sudo()113 .run_string()114 .await115 .context("failed to call remote host for decrypt")?;116 z85::decode(encoded.trim_end()).context("bad encoded data? outdated host?")117 }118 pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {119 let mut cmd = self.cmd("fleet-install-secrets").await?;120 cmd.arg("reencrypt").eqarg("--secret", data.encode_z85());121 for target in targets {122 cmd.eqarg("--targets", target);123 }124 let encoded = cmd125 .sudo()126 .run_string()127 .await128 .context("failed to call remote host for decrypt")?;129 SecretData::decode_z85(encoded.trim_end()).context("bad encoded data? outdated host?")130 }131 132 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {133 if self.local {134 135 return Ok(path.to_owned());136 }137 let mut nix = MyCommand::new("nix");138 nix.arg("copy")139 .arg("--substitute-on-destination")140 .comparg("--to", format!("ssh-ng://{}", self.name))141 .arg(path);142 nix.run_nix().await?;143 Ok(path.to_owned())144 }145 pub async fn systemctl_stop(&self, name: &str) -> Result<()> {146 let mut cmd = self.cmd("systemctl").await?;147 cmd.arg("stop").arg(name);148 cmd.sudo().run().await149 }150 pub async fn systemctl_start(&self, name: &str) -> Result<()> {151 let mut cmd = self.cmd("systemctl").await?;152 cmd.arg("start").arg(name);153 cmd.sudo().run().await154 }155156 pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {157 let mut cmd = self.cmd("rm").await?;158 cmd.arg("-f").arg(path);159 if sudo {160 cmd = cmd.sudo()161 }162 cmd.run().await163 }164}165166impl Config {167 pub fn should_skip(&self, host: &str) -> bool {168 if !self.opts.skip.is_empty() {169 self.opts.skip.iter().any(|h| h as &str == host)170 } else if !self.opts.only.is_empty() {171 !self.opts.only.iter().any(|h| h as &str == host)172 } else {173 false174 }175 }176 pub fn is_local(&self, host: &str) -> bool {177 self.opts.localhost.as_ref().map(|s| s as &str) == Some(host)178 }179180 pub async fn host(&self, name: &str) -> Result<ConfigHost> {181 Ok(ConfigHost {182 name: name.to_owned(),183 local: self.is_local(name),184 session: OnceLock::new(),185 })186 }187 pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {188 let fleet_field = &self.fleet_field;189 let names = nix_go!(fleet_field.configuredHosts).list_fields().await?;190 let mut out = vec![];191 for name in names {192 out.push(ConfigHost {193 local: self.is_local(&name),194 name,195 session: OnceLock::new(),196 })197 }198 Ok(out)199 }200 pub async fn system_config(&self, host: &str) -> Result<Field> {201 let fleet_field = &self.fleet_field;202 Ok(nix_go!(fleet_field.configuredSystems[{ host }].config))203 }204205 pub(super) fn data(&self) -> MutexGuard<FleetData> {206 self.data.lock().unwrap()207 }208 pub(super) fn data_mut(&self) -> MutexGuard<FleetData> {209 self.data.lock().unwrap()210 }211 212 pub async fn list_configured_shared(&self) -> Result<Vec<String>> {213 let config_field = &self.config_unchecked_field;214 nix_go!(config_field.configUnchecked.sharedSecrets)215 .list_fields()216 .await217 }218 219 pub fn list_shared(&self) -> Vec<String> {220 let data = self.data();221 data.shared_secrets.keys().cloned().collect()222 }223 pub fn has_shared(&self, name: &str) -> bool {224 let data = self.data();225 data.shared_secrets.contains_key(name)226 }227 pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {228 let mut data = self.data_mut();229 data.shared_secrets.insert(name.to_owned(), shared);230 }231 pub fn remove_shared(&self, secret: &str) {232 let mut data = self.data_mut();233 data.shared_secrets.remove(secret);234 }235236 pub fn has_secret(&self, host: &str, secret: &str) -> bool {237 let data = self.data();238 let Some(host_secrets) = data.host_secrets.get(host) else {239 return false;240 };241 host_secrets.contains_key(secret)242 }243 pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {244 let mut data = self.data_mut();245 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();246 host_secrets.insert(secret, value);247 }248249 pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {250 let data = self.data();251 let Some(host_secrets) = data.host_secrets.get(host) else {252 bail!("no secrets for machine {host}");253 };254 let Some(secret) = host_secrets.get(secret) else {255 bail!("machine {host} has no secret {secret}");256 };257 Ok(secret.clone())258 }259 pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {260 let data = self.data();261 let Some(secret) = data.shared_secrets.get(secret) else {262 bail!("no shared secret {secret}");263 };264 Ok(secret.clone())265 }266 pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {267 let config_field = &self.config_unchecked_field;268 Ok(nix_go_json!(269 config_field.configUnchecked.sharedSecrets[{ secret }].expectedOwners270 ))271 }272273 pub fn save(&self) -> Result<()> {274 let mut tempfile = NamedTempFile::new_in(self.directory.clone())?;275 let data = nixlike::serialize(&self.data() as &FleetData)?;276 tempfile.write_all(277 format!(278 "# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",279 data280 )281 .as_bytes(),282 )?;283 let mut fleet_data_path = self.directory.clone();284 fleet_data_path.push("fleet.nix");285 tempfile.persist(fleet_data_path)?;286 Ok(())287 }288}289290#[derive(Parser, Clone)]291#[clap(group = ArgGroup::new("target_hosts"))]292pub struct FleetOpts {293 294 #[clap(long, number_of_values = 1, group = "target_hosts")]295 only: Vec<String>,296297 298 #[clap(long, number_of_values = 1, group = "target_hosts")]299 skip: Vec<String>,300301 302 #[clap(long)]303 pub localhost: Option<String>,304305 306 307 #[clap(long, default_value = "detect")]308 pub local_system: String,309}310311impl FleetOpts {312 pub async fn build(mut self, nix_args: Vec<OsString>) -> Result<Config> {313 if self.localhost.is_none() {314 self.localhost315 .replace(hostname::get().unwrap().to_str().unwrap().to_owned());316 }317 let directory = current_dir()?;318319 let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;320 let root_field = pool.get().await?;321322 if self.local_system == "detect" {323 let builtins_field = Field::field(root_field.clone(), "builtins").await?;324 self.local_system = nix_go_json!(builtins_field.currentSystem);325 }326 let local_system = self.local_system.clone();327328 let fleet_root = Field::field(root_field, "fleetConfigurations").await?;329330 let fleet_field = nix_go!(fleet_root.default);331 let config_field = nix_go!(fleet_field.configUnchecked);332 let config_unchecked_field = nix_go!(fleet_field.unchecked);333334 let mut fleet_data_path = directory.clone();335 fleet_data_path.push("fleet.nix");336 let bytes = std::fs::read_to_string(fleet_data_path)?;337 let data = nixlike::parse_str(&bytes)?;338339 Ok(Config(Arc::new(FleetConfigInternals {340 opts: self,341 directory,342 data,343 local_system,344 nix_args,345 fleet_field,346 config_field,347 config_unchecked_field,348 })))349 }350}