1use std::{2 env::current_dir,3 ffi::OsString,4 io::Write,5 ops::Deref,6 path::PathBuf,7 sync::{Arc, Mutex, MutexGuard},8};910use anyhow::{anyhow, bail, Context, Result};11use clap::{ArgGroup, Parser};12use openssh::SessionBuilder;13use tempfile::NamedTempFile;1415use crate::{16 better_nix_eval::{Field, NixSessionPool},17 command::MyCommand,18 fleetdata::{FleetData, FleetSecret, FleetSharedSecret},19 nix_go, nix_go_json,20};2122pub struct FleetConfigInternals {23 pub local_system: String,24 pub directory: PathBuf,25 pub opts: FleetOpts,26 pub data: Mutex<FleetData>,27 pub nix_args: Vec<OsString>,28 29 pub fleet_field: Field,30 31 pub config_field: Field,32 33 pub config_unchecked_field: Field,34}3536#[derive(Clone)]37pub struct Config(Arc<FleetConfigInternals>);3839impl Deref for Config {40 type Target = FleetConfigInternals;4142 fn deref(&self) -> &Self::Target {43 &self.044 }45}4647pub struct ConfigHost {48 pub name: String,49}50impl ConfigHost {51 async fn open_session(&self) -> Result<openssh::Session> {52 let mut session = SessionBuilder::default();5354 session55 .connect(&self.name)56 .await57 .map_err(|e| anyhow!("ssh error: {e}"))58 }59}6061impl Config {62 pub fn should_skip(&self, host: &str) -> bool {63 if !self.opts.skip.is_empty() {64 self.opts.skip.iter().any(|h| h as &str == host)65 } else if !self.opts.only.is_empty() {66 !self.opts.only.iter().any(|h| h as &str == host)67 } else {68 false69 }70 }71 pub fn is_local(&self, host: &str) -> bool {72 self.opts.localhost.as_ref().map(|s| s as &str) == Some(host)73 }7475 pub async fn run_on(&self, host: &str, mut command: MyCommand, sudo: bool) -> Result<()> {76 if sudo {77 command = command.sudo();78 }79 if !self.is_local(host) {80 command = command.ssh(host);81 }82 command.run().await83 }84 pub async fn run_string_on(85 &self,86 host: &str,87 mut command: MyCommand,88 sudo: bool,89 ) -> Result<String> {90 if sudo {91 command = command.sudo();92 }93 if !self.is_local(host) {94 command = command.ssh(host);95 }96 command.run_string().await97 }9899 pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {100 let fleet_field = &self.fleet_field;101 let names = nix_go!(fleet_field.configuredHosts).list_fields().await?;102 let mut out = vec![];103 for name in names {104 out.push(ConfigHost { name })105 }106 Ok(out)107 }108 pub async fn system_config(&self, host: &str) -> Result<Field> {109 let fleet_field = &self.fleet_field;110 Ok(nix_go!(fleet_field.configuredSystems[{ host }].config))111 }112113 pub(super) fn data(&self) -> MutexGuard<FleetData> {114 self.data.lock().unwrap()115 }116 pub(super) fn data_mut(&self) -> MutexGuard<FleetData> {117 self.data.lock().unwrap()118 }119 120 pub async fn list_configured_shared(&self) -> Result<Vec<String>> {121 let config_field = &self.config_field;122 nix_go!(config_field.sharedSecrets).list_fields().await123 }124 125 pub fn list_shared(&self) -> Vec<String> {126 let data = self.data();127 data.shared_secrets.keys().cloned().collect()128 }129 pub fn has_shared(&self, name: &str) -> bool {130 let data = self.data();131 data.shared_secrets.contains_key(name)132 }133 pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {134 let mut data = self.data_mut();135 data.shared_secrets.insert(name.to_owned(), shared);136 }137 pub fn remove_shared(&self, secret: &str) {138 let mut data = self.data_mut();139 data.shared_secrets.remove(secret);140 }141142 pub fn has_secret(&self, host: &str, secret: &str) -> bool {143 let data = self.data();144 let Some(host_secrets) = data.host_secrets.get(host) else {145 return false;146 };147 host_secrets.contains_key(secret)148 }149 pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {150 let mut data = self.data_mut();151 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();152 host_secrets.insert(secret, value);153 }154155 pub async fn decrypt_on_host(&self, host: &str, data: Vec<u8>) -> Result<Vec<u8>> {156 let data = z85::encode(&data);157 let mut cmd = MyCommand::new("fleet-install-secrets");158 cmd.arg("decrypt").eqarg("--secret", data);159 cmd = cmd.sudo().ssh(host);160 let encoded = cmd161 .run_string()162 .await163 .context("failed to call remote host for decrypt")?164 .trim()165 .to_owned();166 z85::decode(encoded).context("bad encoded data? outdated host?")167 }168 pub async fn reencrypt_on_host(169 &self,170 host: &str,171 data: Vec<u8>,172 targets: Vec<String>,173 ) -> Result<Vec<u8>> {174 let data = z85::encode(&data);175 let mut recmd = MyCommand::new("fleet-install-secrets");176 recmd.arg("reencrypt").eqarg("--secret", data);177 for target in targets {178 recmd.eqarg("--targets", target);179 }180 recmd = recmd.sudo().ssh(host);181 let encoded = recmd182 .run_string()183 .await184 .context("failed to call remote host for decrypt")?185 .trim()186 .to_owned();187 z85::decode(encoded).context("bad encoded data? outdated host?")188 }189190 pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {191 let data = self.data();192 let Some(host_secrets) = data.host_secrets.get(host) else {193 bail!("no secrets for machine {host}");194 };195 let Some(secret) = host_secrets.get(secret) else {196 bail!("machine {host} has no secret {secret}");197 };198 Ok(secret.clone())199 }200 pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {201 let data = self.data();202 let Some(secret) = data.shared_secrets.get(secret) else {203 bail!("no shared secret {secret}");204 };205 Ok(secret.clone())206 }207 pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {208 let config_field = &self.config_field;209 Ok(nix_go_json!(210 config_field.sharedSecrets[{ secret }].expectedOwners211 ))212 }213214 pub fn save(&self) -> Result<()> {215 let mut tempfile = NamedTempFile::new_in(self.directory.clone())?;216 let data = nixlike::serialize(&self.data() as &FleetData)?;217 tempfile.write_all(218 format!(219 "# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",220 data221 )222 .as_bytes(),223 )?;224 let mut fleet_data_path = self.directory.clone();225 fleet_data_path.push("fleet.nix");226 tempfile.persist(fleet_data_path)?;227 Ok(())228 }229}230231#[derive(Parser, Clone)]232#[clap(group = ArgGroup::new("target_hosts"))]233pub struct FleetOpts {234 235 #[clap(long, number_of_values = 1, group = "target_hosts")]236 only: Vec<String>,237238 239 #[clap(long, number_of_values = 1, group = "target_hosts")]240 skip: Vec<String>,241242 243 #[clap(long)]244 pub localhost: Option<String>,245246 247 248 #[clap(long, default_value = "detect")]249 pub local_system: String,250}251252impl FleetOpts {253 pub async fn build(mut self, nix_args: Vec<OsString>) -> Result<Config> {254 if self.localhost.is_none() {255 self.localhost256 .replace(hostname::get().unwrap().to_str().unwrap().to_owned());257 }258 let directory = current_dir()?;259260 let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;261 let root_field = pool.get().await?;262263 if self.local_system == "detect" {264 let builtins_field = Field::field(root_field.clone(), "builtins").await?;265 self.local_system = nix_go_json!(builtins_field.currentSystem);266 }267 let local_system = self.local_system.clone();268269 let fleet_root = Field::field(root_field, "fleetConfigurations").await?;270271 let fleet_field = nix_go!(fleet_root.default);272 let config_field = nix_go!(fleet_field.configUnchecked);273 let config_unchecked_field = nix_go!(fleet_field.unchecked);274275 let mut fleet_data_path = directory.clone();276 fleet_data_path.push("fleet.nix");277 let bytes = std::fs::read_to_string(fleet_data_path)?;278 let data = nixlike::parse_str(&bytes)?;279280 Ok(Config(Arc::new(FleetConfigInternals {281 opts: self,282 directory,283 data,284 local_system,285 nix_args,286 fleet_field,287 config_field,288 config_unchecked_field,289 })))290 }291}