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, Index, NixSessionPool},17 command::MyCommand,18 fleetdata::{FleetData, FleetSecret, FleetSharedSecret},19 nix_path,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}3334#[derive(Clone)]35pub struct Config(Arc<FleetConfigInternals>);3637impl Deref for Config {38 type Target = FleetConfigInternals;3940 fn deref(&self) -> &Self::Target {41 &self.042 }43}4445pub struct ConfigHost {46 pub name: String,47}48impl ConfigHost {49 async fn open_session(&self) -> Result<openssh::Session> {50 let mut session = SessionBuilder::default();5152 session53 .connect(&self.name)54 .await55 .map_err(|e| anyhow!("ssh error: {e}"))56 }57}5859impl Config {60 pub fn should_skip(&self, host: &str) -> bool {61 if !self.opts.skip.is_empty() {62 self.opts.skip.iter().any(|h| h as &str == host)63 } else if !self.opts.only.is_empty() {64 !self.opts.only.iter().any(|h| h as &str == host)65 } else {66 false67 }68 }69 pub fn is_local(&self, host: &str) -> bool {70 self.opts.localhost.as_ref().map(|s| s as &str) == Some(host)71 }7273 pub async fn run_on(&self, host: &str, mut command: MyCommand, sudo: bool) -> Result<()> {74 if sudo {75 command = command.sudo();76 }77 if !self.is_local(host) {78 command = command.ssh(host);79 }80 command.run().await81 }82 pub async fn run_string_on(83 &self,84 host: &str,85 mut command: MyCommand,86 sudo: bool,87 ) -> Result<String> {88 if sudo {89 command = command.sudo();90 }91 if !self.is_local(host) {92 command = command.ssh(host);93 }94 command.run_string().await95 }9697 pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {98 let names = self99 .fleet_field100 .select(nix_path!(.configuredHosts))101 .await?102 .list_fields()103 .await?;104 let mut out = vec![];105 for name in names {106 out.push(ConfigHost { name })107 }108 Ok(out)109 }110 pub async fn system_config(&self, host: &str) -> Result<Field> {111 self.fleet_field112 .select(nix_path!(.configuredSystems.{host}.config))113 .await114 }115116 pub(super) fn data(&self) -> MutexGuard<FleetData> {117 self.data.lock().unwrap()118 }119 pub(super) fn data_mut(&self) -> MutexGuard<FleetData> {120 self.data.lock().unwrap()121 }122 123 pub async fn list_configured_shared(&self) -> Result<Vec<String>> {124 self.config_field125 .select(nix_path!(.sharedSecrets))126 .await?127 .list_fields()128 .await129 }130 131 pub fn list_shared(&self) -> Vec<String> {132 let data = self.data();133 data.shared_secrets.keys().cloned().collect()134 }135 pub fn has_shared(&self, name: &str) -> bool {136 let data = self.data();137 data.shared_secrets.contains_key(name)138 }139 pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {140 let mut data = self.data_mut();141 data.shared_secrets.insert(name.to_owned(), shared);142 }143 pub fn remove_shared(&self, secret: &str) {144 let mut data = self.data_mut();145 data.shared_secrets.remove(secret);146 }147148 pub fn has_secret(&self, host: &str, secret: &str) -> bool {149 let data = self.data();150 let Some(host_secrets) = data.host_secrets.get(host) else {151 return false;152 };153 host_secrets.contains_key(secret)154 }155 pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {156 let mut data = self.data_mut();157 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();158 host_secrets.insert(secret, value);159 }160161 pub async fn decrypt_on_host(&self, host: &str, data: Vec<u8>) -> Result<Vec<u8>> {162 let data = z85::encode(&data);163 let mut cmd = MyCommand::new("fleet-install-secrets");164 cmd.arg("decrypt").eqarg("--secret", data);165 cmd = cmd.sudo().ssh(host);166 let encoded = cmd167 .run_string()168 .await169 .context("failed to call remote host for decrypt")?170 .trim()171 .to_owned();172 z85::decode(encoded).context("bad encoded data? outdated host?")173 }174 pub async fn reencrypt_on_host(175 &self,176 host: &str,177 data: Vec<u8>,178 targets: Vec<String>,179 ) -> Result<Vec<u8>> {180 let data = z85::encode(&data);181 let mut recmd = MyCommand::new("fleet-install-secrets");182 recmd.arg("reencrypt").eqarg("--secret", data);183 for target in targets {184 recmd.eqarg("--targets", target);185 }186 recmd = recmd.sudo().ssh(host);187 let encoded = recmd188 .run_string()189 .await190 .context("failed to call remote host for decrypt")?191 .trim()192 .to_owned();193 z85::decode(encoded).context("bad encoded data? outdated host?")194 }195196 pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {197 let data = self.data();198 let Some(host_secrets) = data.host_secrets.get(host) else {199 bail!("no secrets for machine {host}");200 };201 let Some(secret) = host_secrets.get(secret) else {202 bail!("machine {host} has no secret {secret}");203 };204 Ok(secret.clone())205 }206 pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {207 let data = self.data();208 let Some(secret) = data.shared_secrets.get(secret) else {209 bail!("no shared secret {secret}");210 };211 Ok(secret.clone())212 }213 pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {214 self.config_field215 .select(nix_path!(.sharedSecrets.{secret}.expectedOwners))216 .await?217 .as_json()218 .await219 }220221 pub fn save(&self) -> Result<()> {222 let mut tempfile = NamedTempFile::new_in(self.directory.clone())?;223 let data = nixlike::serialize(&self.data() as &FleetData)?;224 tempfile.write_all(225 format!(226 "# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",227 data228 )229 .as_bytes(),230 )?;231 let mut fleet_data_path = self.directory.clone();232 fleet_data_path.push("fleet.nix");233 tempfile.persist(fleet_data_path)?;234 Ok(())235 }236}237238#[derive(Parser, Clone)]239#[clap(group = ArgGroup::new("target_hosts"))]240pub struct FleetOpts {241 242 #[clap(long, number_of_values = 1, group = "target_hosts")]243 only: Vec<String>,244245 246 #[clap(long, number_of_values = 1, group = "target_hosts")]247 skip: Vec<String>,248249 250 #[clap(long)]251 pub localhost: Option<String>,252253 254 255 256 #[clap(long, default_value = "detect")]257 pub local_system: String,258}259260impl FleetOpts {261 pub async fn build(mut self, nix_args: Vec<OsString>) -> Result<Config> {262 if self.localhost.is_none() {263 self.localhost264 .replace(hostname::get().unwrap().to_str().unwrap().to_owned());265 }266 let directory = current_dir()?;267268 let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;269 let root_field = pool.get().await?;270271 if self.local_system == "detect" {272 let builtins_field = Field::field(root_field.clone(), "builtins").await?;273 let system = builtins_field274 .select(nix_path!(.currentSystem))275 .await?;276 self.local_system = system.as_json().await?;277 }278 let local_system = self.local_system.clone();279280 let fleet_root = Field::field(root_field, "fleetConfigurations").await?;281282 let fleet_field = fleet_root283 .select(nix_path!(.default.{&local_system}))284 .await?;285 let config_field = fleet_field286 .select(nix_path!(.configUnchecked))287 .await?;288289 let mut fleet_data_path = directory.clone();290 fleet_data_path.push("fleet.nix");291 let bytes = std::fs::read_to_string(fleet_data_path)?;292 let data = nixlike::parse_str(&bytes)?;293294 Ok(Config(Arc::new(FleetConfigInternals {295 opts: self,296 directory,297 data,298 local_system,299 nix_args,300 fleet_field,301 config_field,302 })))303 }304}