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 age::Recipient;13use anyhow::{anyhow, bail, Context, Result};14use clap::{ArgGroup, Parser};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, SecretData},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 fleet_field: Field,34 35 pub config_field: Field,36 37 pub config_unchecked_field: 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 pub name: String,53 pub session: OnceLock<Arc<openssh::Session>>,54}55impl ConfigHost {56 pub async fn open_session(&self) -> Result<Arc<openssh::Session>> {57 58 if let Some(session) = &self.session.get() {59 return Ok((*session).clone());60 };61 let session = SessionBuilder::default();6263 let session = session64 .connect(&self.name)65 .await66 .map_err(|e| anyhow!("ssh error: {e}"))?;67 let session = Arc::new(session);68 self.session.set(session.clone()).expect("TOCTOU happened");69 Ok(session)70 }71 pub async fn mktemp_dir(&self) -> Result<String> {72 let mut cmd = self.cmd("mktemp").await?;73 cmd.arg("-d");74 let path = cmd.run_string().await?;75 Ok(path.trim_end().to_owned())76 }77 pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {78 let mut cmd = self.cmd("cat").await?;79 cmd.arg(path);80 cmd.run_bytes().await81 }82 pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {83 let mut cmd = self.cmd("cat").await?;84 cmd.arg(path);85 cmd.run_string().await86 }87 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {88 let text = self.read_file_text(path).await?;89 Ok(serde_json::from_str(&text)?)90 }91 pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>92 where93 <D as FromStr>::Err: Display,94 {95 let text = self.read_file_text(path).await?;96 D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))97 }98 pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {99 let session = self.open_session().await?;100 Ok(MyCommand::new_on(cmd, session))101 }102103 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {104 let mut cmd = self.cmd("fleet-install-secrets").await?;105 cmd.arg("decrypt").eqarg("--secret", data.encode_z85());106 let encoded = cmd107 .sudo()108 .run_string()109 .await110 .context("failed to call remote host for decrypt")?;111 z85::decode(encoded.trim_end()).context("bad encoded data? outdated host?")112 }113 114 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {115 let mut nix = MyCommand::new("nix");116 nix.arg("copy")117 .arg("--substitute-on-destination")118 .comparg("--to", format!("ssh-ng://{}", self.name))119 .arg(path);120 nix.run_nix().await?;121 Ok(path.to_owned())122 }123}124125impl Config {126 pub fn should_skip(&self, host: &str) -> bool {127 if !self.opts.skip.is_empty() {128 self.opts.skip.iter().any(|h| h as &str == host)129 } else if !self.opts.only.is_empty() {130 !self.opts.only.iter().any(|h| h as &str == host)131 } else {132 false133 }134 }135 pub fn is_local(&self, host: &str) -> bool {136 self.opts.localhost.as_ref().map(|s| s as &str) == Some(host)137 }138139 pub async fn run_on(&self, host: &str, mut command: MyCommand, sudo: bool) -> Result<()> {140 if sudo {141 command = command.sudo();142 }143 if !self.is_local(host) {144 command = command.ssh(host);145 }146 command.run().await147 }148 pub async fn run_string_on(149 &self,150 host: &str,151 mut command: MyCommand,152 sudo: bool,153 ) -> Result<String> {154 if sudo {155 command = command.sudo();156 }157 if !self.is_local(host) {158 command = command.ssh(host);159 }160 command.run_string().await161 }162163 pub async fn host(&self, name: &str) -> Result<ConfigHost> {164 Ok(ConfigHost {165 name: name.to_owned(),166 session: OnceLock::new(),167 })168 }169 pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {170 let fleet_field = &self.fleet_field;171 let names = nix_go!(fleet_field.configuredHosts).list_fields().await?;172 let mut out = vec![];173 for name in names {174 out.push(ConfigHost {175 name,176 session: OnceLock::new(),177 })178 }179 Ok(out)180 }181 pub async fn system_config(&self, host: &str) -> Result<Field> {182 let fleet_field = &self.fleet_field;183 Ok(nix_go!(fleet_field.configuredSystems[{ host }].config))184 }185186 pub(super) fn data(&self) -> MutexGuard<FleetData> {187 self.data.lock().unwrap()188 }189 pub(super) fn data_mut(&self) -> MutexGuard<FleetData> {190 self.data.lock().unwrap()191 }192 193 pub async fn list_configured_shared(&self) -> Result<Vec<String>> {194 let config_field = &self.config_unchecked_field;195 nix_go!(config_field.configUnchecked.sharedSecrets)196 .list_fields()197 .await198 }199 200 pub fn list_shared(&self) -> Vec<String> {201 let data = self.data();202 data.shared_secrets.keys().cloned().collect()203 }204 pub fn has_shared(&self, name: &str) -> bool {205 let data = self.data();206 data.shared_secrets.contains_key(name)207 }208 pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {209 let mut data = self.data_mut();210 data.shared_secrets.insert(name.to_owned(), shared);211 }212 pub fn remove_shared(&self, secret: &str) {213 let mut data = self.data_mut();214 data.shared_secrets.remove(secret);215 }216217 pub fn has_secret(&self, host: &str, secret: &str) -> bool {218 let data = self.data();219 let Some(host_secrets) = data.host_secrets.get(host) else {220 return false;221 };222 host_secrets.contains_key(secret)223 }224 pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {225 let mut data = self.data_mut();226 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();227 host_secrets.insert(secret, value);228 }229230 pub async fn reencrypt_on_host(231 &self,232 host: &str,233 data: SecretData,234 targets: Vec<String>,235 ) -> Result<SecretData> {236 let mut recmd = MyCommand::new("fleet-install-secrets");237 recmd.arg("reencrypt").eqarg("--secret", data.encode_z85());238 for target in targets {239 recmd.eqarg("--targets", target);240 }241 recmd = recmd.sudo().ssh(host);242 let encoded = recmd243 .run_string()244 .await245 .context("failed to call remote host for decrypt")?246 .trim()247 .to_owned();248 SecretData::decode_z85(&encoded)249 }250251 pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {252 let data = self.data();253 let Some(host_secrets) = data.host_secrets.get(host) else {254 bail!("no secrets for machine {host}");255 };256 let Some(secret) = host_secrets.get(secret) else {257 bail!("machine {host} has no secret {secret}");258 };259 Ok(secret.clone())260 }261 pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {262 let data = self.data();263 let Some(secret) = data.shared_secrets.get(secret) else {264 bail!("no shared secret {secret}");265 };266 Ok(secret.clone())267 }268 pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {269 let config_field = &self.config_unchecked_field;270 Ok(nix_go_json!(271 config_field.configUnchecked.sharedSecrets[{ secret }].expectedOwners272 ))273 }274275 pub fn save(&self) -> Result<()> {276 let mut tempfile = NamedTempFile::new_in(self.directory.clone())?;277 let data = nixlike::serialize(&self.data() as &FleetData)?;278 tempfile.write_all(279 format!(280 "# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",281 data282 )283 .as_bytes(),284 )?;285 let mut fleet_data_path = self.directory.clone();286 fleet_data_path.push("fleet.nix");287 tempfile.persist(fleet_data_path)?;288 Ok(())289 }290}291292#[derive(Parser, Clone)]293#[clap(group = ArgGroup::new("target_hosts"))]294pub struct FleetOpts {295 296 #[clap(long, number_of_values = 1, group = "target_hosts")]297 only: Vec<String>,298299 300 #[clap(long, number_of_values = 1, group = "target_hosts")]301 skip: Vec<String>,302303 304 #[clap(long)]305 pub localhost: Option<String>,306307 308 309 #[clap(long, default_value = "detect")]310 pub local_system: String,311}312313impl FleetOpts {314 pub async fn build(mut self, nix_args: Vec<OsString>) -> Result<Config> {315 if self.localhost.is_none() {316 self.localhost317 .replace(hostname::get().unwrap().to_str().unwrap().to_owned());318 }319 let directory = current_dir()?;320321 let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;322 let root_field = pool.get().await?;323324 if self.local_system == "detect" {325 let builtins_field = Field::field(root_field.clone(), "builtins").await?;326 self.local_system = nix_go_json!(builtins_field.currentSystem);327 }328 let local_system = self.local_system.clone();329330 let fleet_root = Field::field(root_field, "fleetConfigurations").await?;331332 let fleet_field = nix_go!(fleet_root.default);333 let config_field = nix_go!(fleet_field.configUnchecked);334 let config_unchecked_field = nix_go!(fleet_field.unchecked);335336 let mut fleet_data_path = directory.clone();337 fleet_data_path.push("fleet.nix");338 let bytes = std::fs::read_to_string(fleet_data_path)?;339 let data = nixlike::parse_str(&bytes)?;340341 Ok(Config(Arc::new(FleetConfigInternals {342 opts: self,343 directory,344 data,345 local_system,346 nix_args,347 fleet_field,348 config_field,349 config_unchecked_field,350 })))351 }352}