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};2021pub struct FleetConfigInternals {22 pub local_system: String,23 pub directory: PathBuf,24 pub opts: FleetOpts,25 pub data: Mutex<FleetData>,26 pub nix_args: Vec<OsString>,27 28 pub fleet_field: Field,29 30 pub config_field: Field,31}3233#[derive(Clone)]34pub struct Config(Arc<FleetConfigInternals>);3536impl Deref for Config {37 type Target = FleetConfigInternals;3839 fn deref(&self) -> &Self::Target {40 &self.041 }42}4344pub struct ConfigHost {45 pub name: String,46}47impl ConfigHost {48 async fn open_session(&self) -> Result<openssh::Session> {49 let mut session = SessionBuilder::default();5051 session52 .connect(&self.name)53 .await54 .map_err(|e| anyhow!("ssh error: {e}"))55 }56}5758impl Config {59 pub fn should_skip(&self, host: &str) -> bool {60 if !self.opts.skip.is_empty() {61 self.opts.skip.iter().any(|h| h as &str == host)62 } else if !self.opts.only.is_empty() {63 !self.opts.only.iter().any(|h| h as &str == host)64 } else {65 false66 }67 }68 pub fn is_local(&self, host: &str) -> bool {69 self.opts.localhost.as_ref().map(|s| s as &str) == Some(host)70 }7172 pub async fn run_on(&self, host: &str, mut command: MyCommand, sudo: bool) -> Result<()> {73 if sudo {74 command = command.sudo();75 }76 if !self.is_local(host) {77 command = command.ssh(host);78 }79 command.run().await80 }81 pub async fn run_string_on(82 &self,83 host: &str,84 mut command: MyCommand,85 sudo: bool,86 ) -> Result<String> {87 if sudo {88 command = command.sudo();89 }90 if !self.is_local(host) {91 command = command.ssh(host);92 }93 command.run_string().await94 }9596 pub fn configuration_attr_name(&self, name: &str) -> OsString {97 let mut str = self.directory.as_os_str().to_owned();98 str.push("#");99 str.push(&format!(100 "fleetConfigurations.default.{}.{}",101 self.local_system, name102 ));103 str104 }105106 pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {107 let names = self108 .fleet_field109 .get_field_deep(["configuredHosts"])110 .await?111 .list_fields()112 .await?;113 let mut out = vec![];114 for name in names {115 out.push(ConfigHost { name })116 }117 Ok(out)118 }119 pub async fn system_config(&self, host: &str) -> Result<Field> {120 self.fleet_field121 .get_field_deep(["configuredSystems", host, "config"])122 .await123 }124125 pub(super) fn data(&self) -> MutexGuard<FleetData> {126 self.data.lock().unwrap()127 }128 pub(super) fn data_mut(&self) -> MutexGuard<FleetData> {129 self.data.lock().unwrap()130 }131 132 pub async fn list_configured_shared(&self) -> Result<Vec<String>> {133 self.config_field134 .get_field("sharedSecrets")135 .await?136 .list_fields()137 .await138 }139 140 pub fn list_shared(&self) -> Vec<String> {141 let data = self.data();142 data.shared_secrets.keys().cloned().collect()143 }144 pub fn has_shared(&self, name: &str) -> bool {145 let data = self.data();146 data.shared_secrets.contains_key(name)147 }148 pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {149 let mut data = self.data_mut();150 data.shared_secrets.insert(name.to_owned(), shared);151 }152 pub fn remove_shared(&self, secret: &str) {153 let mut data = self.data_mut();154 data.shared_secrets.remove(secret);155 }156157 pub fn has_secret(&self, host: &str, secret: &str) -> bool {158 let data = self.data();159 let Some(host_secrets) = data.host_secrets.get(host) else {160 return false;161 };162 host_secrets.contains_key(secret)163 }164 pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {165 let mut data = self.data_mut();166 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();167 host_secrets.insert(secret, value);168 }169170 pub async fn decrypt_on_host(&self, host: &str, data: Vec<u8>) -> Result<Vec<u8>> {171 let data = z85::encode(&data);172 let mut cmd = MyCommand::new("fleet-install-secrets");173 cmd.arg("decrypt").eqarg("--secret", data);174 cmd = cmd.sudo().ssh(host);175 let encoded = cmd176 .run_string()177 .await178 .context("failed to call remote host for decrypt")?179 .trim()180 .to_owned();181 z85::decode(encoded).context("bad encoded data? outdated host?")182 }183 pub async fn reencrypt_on_host(184 &self,185 host: &str,186 data: Vec<u8>,187 targets: Vec<String>,188 ) -> Result<Vec<u8>> {189 let data = z85::encode(&data);190 let mut recmd = MyCommand::new("fleet-install-secrets");191 recmd.arg("reencrypt").eqarg("--secret", data);192 for target in targets {193 recmd.eqarg("--targets", target);194 }195 recmd = recmd.sudo().ssh(host);196 let encoded = recmd197 .run_string()198 .await199 .context("failed to call remote host for decrypt")?200 .trim()201 .to_owned();202 z85::decode(encoded).context("bad encoded data? outdated host?")203 }204205 pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {206 let data = self.data();207 let Some(host_secrets) = data.host_secrets.get(host) else {208 bail!("no secrets for machine {host}");209 };210 let Some(secret) = host_secrets.get(secret) else {211 bail!("machine {host} has no secret {secret}");212 };213 Ok(secret.clone())214 }215 pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {216 let data = self.data();217 let Some(secret) = data.shared_secrets.get(secret) else {218 bail!("no shared secret {secret}");219 };220 Ok(secret.clone())221 }222 pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {223 self.config_field224 .get_field_deep(["sharedSecrets", secret, "expectedOwners"])225 .await?226 .as_json()227 .await228 }229230 pub fn save(&self) -> Result<()> {231 let mut tempfile = NamedTempFile::new_in(self.directory.clone())?;232 let data = nixlike::serialize(&self.data() as &FleetData)?;233 tempfile.write_all(234 format!(235 "# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",236 data237 )238 .as_bytes(),239 )?;240 let mut fleet_data_path = self.directory.clone();241 fleet_data_path.push("fleet.nix");242 tempfile.persist(fleet_data_path)?;243 Ok(())244 }245}246247#[derive(Parser, Clone)]248#[clap(group = ArgGroup::new("target_hosts"))]249pub struct FleetOpts {250 251 #[clap(long, number_of_values = 1, group = "target_hosts")]252 only: Vec<String>,253254 255 #[clap(long, number_of_values = 1, group = "target_hosts")]256 skip: Vec<String>,257258 259 #[clap(long)]260 pub localhost: Option<String>,261262 263 264 265 #[clap(long, default_value = "detect")]266 pub local_system: String,267}268269impl FleetOpts {270 pub async fn build(mut self, nix_args: Vec<OsString>) -> Result<Config> {271 if self.localhost.is_none() {272 self.localhost273 .replace(hostname::get().unwrap().to_str().unwrap().to_owned());274 }275 let directory = current_dir()?;276277 let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;278 let root_field = pool.get().await?;279280 if self.local_system == "detect" {281 let builtins_field = Field::field(root_field.clone(), "builtins").await?;282 let system = builtins_field.get_field("currentSystem").await?;283 self.local_system = system.as_json().await?;284 }285 let local_system = self.local_system.clone();286287 let fleet_root = Field::field(root_field, "fleetConfigurations").await?;288289 let fleet_field = fleet_root290 .get_field_deep(["default", &local_system])291 .await?;292 let config_field = fleet_field.get_field("configUnchecked").await?;293294 let mut fleet_data_path = directory.clone();295 fleet_data_path.push("fleet.nix");296 let bytes = std::fs::read_to_string(fleet_data_path)?;297 let data = nixlike::parse_str(&bytes)?;298299 Ok(Config(Arc::new(FleetConfigInternals {300 opts: self,301 directory,302 data,303 local_system,304 nix_args,305 fleet_field,306 config_field,307 })))308 }309}