difftreelog
refactor minor rewrites
in: trunk
7 files changed
cmds/fleet/src/better_nix_eval.rsdiffbeforeafterboth--- a/cmds/fleet/src/better_nix_eval.rs
+++ b/cmds/fleet/src/better_nix_eval.rs
@@ -247,7 +247,7 @@
Ok(())
}
async fn send_command(&mut self, cmd: impl AsRef<[u8]>) -> Result<()> {
- if tracing::enabled!(Level::DEBUG) {
+ if tracing::enabled!(Level::DEBUG) && cmd.as_ref() != REPL_DELIMITER.as_bytes() {
let cmd_str = String::from_utf8_lossy(cmd.as_ref());
tracing::debug!("{cmd_str}");
};
@@ -627,13 +627,6 @@
}
pub async fn field(session: NixSession, field: &str) -> Result<Self> {
Self::root(session).select([Index::var(field)]).await
- }
- pub async fn get_json_deep<'a, V: DeserializeOwned>(
- &self,
- name: impl IntoIterator<Item = Index>,
- ) -> Result<V> {
- let field = self.select(name).await?;
- field.as_json().await
}
pub async fn select<'a>(&self, name: impl IntoIterator<Item = Index>) -> Result<Self> {
let mut used_fields = Vec::new();
@@ -719,6 +712,19 @@
.await
.with_context(|| context(self.0.full_path.as_deref(), &query))
}
+ pub async fn has_field(&self, name: &str) -> Result<bool> {
+ let id = self.0.value.expect("can't list root fields");
+ let key = nixlike::escape_string(name);
+ let query = format!("sess_field_{id} ? {key}");
+ self.0
+ .session
+ .0
+ .lock()
+ .await
+ .execute_expression_to_json(&query)
+ .await
+ .with_context(|| context(self.0.full_path.as_deref(), &query))
+ }
pub async fn list_fields(&self) -> Result<Vec<String>> {
let id = self.0.value.expect("can't list root fields");
let query = format!("builtins.attrNames sess_field_{id}");
@@ -731,6 +737,18 @@
.await
.with_context(|| context(self.0.full_path.as_deref(), &query))
}
+ pub async fn type_of(&self) -> Result<String> {
+ let id = self.0.value.expect("can't list root fields");
+ let query = format!("builtins.typeOf sess_field_{id}");
+ self.0
+ .session
+ .0
+ .lock()
+ .await
+ .execute_expression_to_json(&query)
+ .await
+ .with_context(|| context(self.0.full_path.as_deref(), &query))
+ }
pub async fn build(&self) -> Result<HashMap<String, PathBuf>> {
let id = self.0.value.expect("can't use build on not-value");
let query = format!(":b sess_field_{id}");
cmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth1use crate::{2 command::MyCommand,3 fleetdata::{FleetSecret, FleetSharedSecret},4 host::Config,5 nix_go, nix_go_json,6};7use anyhow::{anyhow, bail, ensure, Context, Result};8use chrono::{DateTime, Utc};9use clap::Parser;10use futures::{StreamExt, TryStreamExt};11use owo_colors::OwoColorize;12use std::{13 collections::HashSet,14 io::{self, Cursor, Read},15 path::PathBuf,16 sync::Arc,17};18use tabled::{Table, Tabled};19use tokio::fs::read_to_string;20use tracing::{error, info, info_span, warn};2122#[derive(Parser)]23pub enum Secret {24 /// Force load host keys for all defined hosts25 ForceKeys,26 /// Add secret, data should be provided in stdin27 AddShared {28 /// Secret name29 name: String,30 /// Secret owners31 machines: Vec<String>,32 /// Override secret if already present33 #[clap(long)]34 force: bool,35 /// Secret public part36 #[clap(long)]37 public: Option<String>,38 /// Load public part from specified file39 #[clap(long)]40 public_file: Option<PathBuf>,4142 /// Create a notification on secret expiration43 #[clap(long)]44 expires_at: Option<DateTime<Utc>>,4546 /// Secret with this name already exists, override its value while keeping the same owners.47 #[clap(long)]48 re_add: bool,49 },50 /// Add secret, data should be provided in stdin51 Add {52 /// Secret name53 name: String,54 /// Secret owners55 machine: String,56 /// Override secret if already present57 #[clap(long)]58 force: bool,59 #[clap(long)]60 public: Option<String>,61 #[clap(long)]62 public_file: Option<PathBuf>,63 },64 /// Read secret from remote host, requires sudo on said host65 Read {66 name: String,67 machine: String,68 #[clap(long)]69 plaintext: bool,70 },71 UpdateShared {72 name: String,7374 #[clap(long)]75 machines: Option<Vec<String>>,7677 #[clap(long)]78 add_machines: Vec<String>,79 #[clap(long)]80 remove_machines: Vec<String>,8182 /// Which host should we use to decrypt83 #[clap(long)]84 prefer_identities: Vec<String>,85 },86 Regenerate {87 /// Which host should we use to decrypt, in case if reencryption is required, without88 /// regeneration89 #[clap(long)]90 prefer_identities: Vec<String>,91 },92 List {},93 InvokeGenerator,94}9596impl Secret {97 pub async fn run(self, config: &Config) -> Result<()> {98 match self {99 Secret::InvokeGenerator => {100 let config_field = &config.config_unchecked_field;101102 let secret =103 nix_go!(config_field.configUnchecked.sharedSecrets["kube-apiserver.pem"]);104 let generate_impure = nix_go!(secret.generateImpure);105 let on = nix_go!(generate_impure.on);106 let call_package = nix_go!(107 config_field.buildableSystems(Obj {108 localSystem: { config.local_system.clone() }109 })[on]110 .config111 .nixpkgs112 .resolvedPkgs113 .callPackage114 );115 let generator = nix_go!(call_package(generate_impure.generator)(Obj {}));116 let built = &generator.build().await?["out"];117 let mut nix = MyCommand::new("nix");118 let on: String = on.as_json().await?;119 nix.arg("copy")120 .arg("--substitute-on-destination")121 .comparg("--to", format!("ssh-ng://{on}"))122 .arg(built);123 nix.run_nix().await?;124125 let session = config.host(&on).await?;126127 let owners: Vec<String> = nix_go_json!(secret.expectedOwners);128 dbg!(&owners);129130 let mut recipients = String::new();131 for owner in owners {132 let key = config.key(&owner).await?;133 recipients.push_str(&format!("-r \"{key}\" "));134 }135 recipients.push_str("-e");136137 // FIXME: security: created directory might be accessible to other users138 // This shouldn't be much of a concern, as data is encrypted right after creation, yet139 // still better to have.140 let tempdir = session.mktemp_dir().await?;141142 let mut gen = session.cmd(built).await?;143 gen.env("rageArgs", recipients).env("out", &tempdir);144 gen.run().await?;145146 {147 let marker = session.read_file_text(format!("{tempdir}/marker")).await?;148 ensure!(marker == "SUCCESS", "generation not succeeded");149 }150151 let public = session152 .read_file_bin(format!("{tempdir}/public"))153 .await154 .ok();155 let secret = session156 .read_file_bin(format!("{tempdir}/secret"))157 .await158 .ok();159 if let Some(secret) = &secret {160 ensure!(161 age::Decryptor::new(Cursor::new(&secret)).is_ok(),162 "builder produced non-encrypted value as secret, this is highly insecure"163 );164 }165 dbg!(&secret);166 // // .as_json().await?;167 // dbg!(&built);168 }169 Secret::ForceKeys => {170 for host in config.list_hosts().await? {171 if config.should_skip(&host.name) {172 continue;173 }174 config.key(&host.name).await?;175 }176 }177 Secret::AddShared {178 mut machines,179 name,180 force,181 public,182 public_file,183 expires_at,184 re_add,185 } => {186 let exists = config.has_shared(&name);187 if exists && !force && !re_add {188 bail!("secret already defined");189 }190 if re_add {191 // Fixme: use clap to limit this usage192 ensure!(!force, "--force and --readd are not compatible");193 ensure!(exists, "secret doesn't exists");194 ensure!(195 machines.is_empty(),196 "you can't use machines argument for --readd"197 );198 let shared = config.shared_secret(&name)?;199 machines = shared.owners;200 }201202 let recipients = futures::stream::iter(machines.iter())203 .then(|m| config.recipient(m))204 .try_collect::<Vec<_>>()205 .await?;206207 let secret = {208 let mut input = vec![];209 io::stdin().read_to_end(&mut input)?;210211 if input.is_empty() {212 input213 } else {214 let mut encrypted = vec![];215 let recipients = recipients216 .iter()217 .cloned()218 .map(|r| Box::new(r) as Box<dyn age::Recipient + Send>)219 .collect();220 let mut encryptor = age::Encryptor::with_recipients(recipients)221 .ok_or_else(|| anyhow!("no recipients provided"))?222 .wrap_output(&mut encrypted)?;223 io::copy(&mut Cursor::new(input), &mut encryptor)?;224 encryptor.finish()?;225 encrypted226 }227 };228 config.replace_shared(229 name,230 FleetSharedSecret {231 owners: machines,232 secret: FleetSecret {233 created_at: Utc::now(),234 expires_at,235 secret,236 public: match (public, public_file) {237 (Some(v), None) => Some(v),238 (None, Some(v)) => Some(read_to_string(v).await?),239 (Some(_), Some(_)) => {240 bail!("only public or public_file should be set")241 }242 (None, None) => None,243 },244 },245 },246 );247 }248 Secret::Add {249 machine,250 name,251 force,252 public,253 public_file,254 } => {255 let recipient = config.recipient(&machine).await?;256257 let secret = {258 let mut input = vec![];259 io::stdin().read_to_end(&mut input)?;260 if input.is_empty() {261 bail!("no data provided")262 }263264 let mut encrypted = vec![];265 let recipient = Box::new(recipient) as Box<dyn age::Recipient + Send>;266 let mut encryptor = age::Encryptor::with_recipients(vec![recipient])267 .expect("recipients provided")268 .wrap_output(&mut encrypted)?;269 io::copy(&mut Cursor::new(input), &mut encryptor)?;270 encryptor.finish()?;271 encrypted272 };273274 if config.has_secret(&machine, &name) && !force {275 bail!("secret already defined");276 }277 config.insert_secret(278 &machine,279 name,280 FleetSecret {281 created_at: Utc::now(),282 expires_at: None,283 secret,284 public: match (public, public_file) {285 (Some(v), None) => Some(v),286 (None, Some(v)) => Some(std::fs::read_to_string(v)?),287 (Some(_), Some(_)) => bail!("only public or public_file should be set"),288 (None, None) => None,289 },290 },291 );292 }293 // TODO: Instead of using sudo, decode secret on remote machine294 #[allow(clippy::await_holding_refcell_ref)]295 Secret::Read {296 name,297 machine,298 plaintext,299 } => {300 let secret = config.host_secret(&machine, &name)?;301 if secret.secret.is_empty() {302 bail!("no secret {name}");303 }304 let host = config.host(&machine).await?;305 let data = host.decrypt(secret.secret).await?;306 if plaintext {307 let s = String::from_utf8(data).context("output is not utf8")?;308 print!("{s}");309 } else {310 println!("{}", z85::encode(&data));311 }312 }313 Secret::UpdateShared {314 name,315 machines,316 mut add_machines,317 mut remove_machines,318 prefer_identities,319 } => {320 if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {321 bail!("no operation");322 }323324 let mut secret = config.shared_secret(&name)?;325 if secret.secret.secret.is_empty() {326 bail!("no secret");327 }328329 let initial_machines = secret.owners.clone();330 let mut target_machines = secret.owners.clone();331 info!("Currently encrypted for {initial_machines:?}");332333 // ensure!(machines.is_some() || !add_machines.is_empty() || )334 if let Some(machines) = machines {335 ensure!(336 add_machines.is_empty() && remove_machines.is_empty(),337 "can't combine --machines and --add-machines/--remove-machines"338 );339 let target = initial_machines.iter().collect::<HashSet<_>>();340 let source = machines.iter().collect::<HashSet<_>>();341 for removed in target.difference(&source) {342 remove_machines.push((*removed).clone());343 }344 for added in source.difference(&target) {345 add_machines.push((*added).clone());346 }347 }348349 for machine in &remove_machines {350 let mut removed = false;351 while let Some(pos) = target_machines.iter().position(|m| m == machine) {352 target_machines.swap_remove(pos);353 removed = true;354 }355 if !removed {356 warn!("secret is not enabled for {machine}");357 }358 }359 for machine in &add_machines {360 if target_machines.iter().any(|m| m == machine) {361 warn!("secret is already added to {machine}");362 } else {363 target_machines.push(machine.to_owned());364 }365 }366 if !remove_machines.is_empty() {367 warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");368 }369370 if target_machines.is_empty() {371 info!("no machines left for secret, removing it");372 config.remove_shared(&name);373 return Ok(());374 }375376 if target_machines == initial_machines {377 warn!("secret owners are already correct");378 return Ok(());379 }380381 let identity_holder = if !prefer_identities.is_empty() {382 prefer_identities383 .iter()384 .find(|i| initial_machines.iter().any(|s| s == *i))385 } else {386 secret.owners.first()387 };388 let Some(identity_holder) = identity_holder else {389 bail!("no available holder found");390 };391 let target_recipients = futures::stream::iter(&target_machines)392 .then(|m| async { config.key(m).await })393 .collect::<Vec<_>>()394 .await;395 let target_recipients =396 target_recipients.into_iter().collect::<Result<Vec<_>>>()?;397398 let encrypted = config399 .reencrypt_on_host(identity_holder, secret.secret.secret, target_recipients)400 .await?;401402 secret.owners = target_machines;403 secret.secret.secret = encrypted;404 config.replace_shared(name, secret);405 }406 Secret::Regenerate { prefer_identities } => {407 {408 let expected_shared_set = config409 .list_configured_shared()410 .await?411 .into_iter()412 .collect::<HashSet<_>>();413 let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();414 for removed in expected_shared_set.difference(&shared_set) {415 error!("secret needs to be generated: {removed}")416 }417 }418 let mut to_remove = Vec::new();419 for name in &config.list_shared() {420 info!("updating secret: {name}");421 let mut data = config.shared_secret(name)?;422 let config_field = &config.config_field;423 let expected_owners: Vec<String> =424 nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);425 if expected_owners.is_empty() {426 warn!("secret was removed from fleet config: {name}, removing from data");427 to_remove.push(name.to_string());428 continue;429 }430 let set = data.owners.iter().collect::<HashSet<_>>();431 let expected_set = expected_owners.iter().collect::<HashSet<_>>();432 let should_remove = set.difference(&expected_set).next().is_some();433 if set != expected_set {434 let owner_dependent: bool =435 nix_go_json!(config_field.sharedSecrets[{ name }].ownerDependent);436 if !owner_dependent {437 warn!("reencrypting secret '{name}' for new owner set");438 // TODO: force regeneration439 if should_remove {440 warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");441 }442443 let identity_holder = if !prefer_identities.is_empty() {444 prefer_identities445 .iter()446 .find(|i| data.owners.iter().any(|s| s == *i))447 } else {448 data.owners.first()449 };450 let Some(identity_holder) = identity_holder else {451 bail!("no available holder found");452 };453454 let target_recipients = futures::stream::iter(&expected_owners)455 .then(|m| async { config.key(m).await })456 .collect::<Vec<_>>()457 .await;458 let target_recipients =459 target_recipients.into_iter().collect::<Result<Vec<_>>>()?;460461 let encrypted = config462 .reencrypt_on_host(463 identity_holder,464 data.secret.secret,465 target_recipients,466 )467 .await?;468469 data.secret.secret = encrypted;470 data.owners = expected_owners;471 config.replace_shared(name.to_owned(), data);472 } else {473 error!("secret '{name}' should be regenerated manually");474 }475 } else {476 info!("secret data is ok")477 }478 }479 for k in to_remove {480 config.remove_shared(&k);481 }482 }483 Secret::List {} => {484 let _span = info_span!("loading secrets").entered();485 let configured = config.list_configured_shared().await?;486 #[derive(Tabled)]487 struct SecretDisplay {488 #[tabled(rename = "Name")]489 name: String,490 #[tabled(rename = "Owners")]491 owners: String,492 }493 let mut table = vec![];494 for name in configured.iter().cloned() {495 let config = config.clone();496 let expected_owners = config.shared_secret_expected_owners(&name).await?;497 let data = config.shared_secret(&name)?;498 let owners = data499 .owners500 .iter()501 .map(|o| {502 if expected_owners.contains(o) {503 o.green().to_string()504 } else {505 o.red().to_string()506 }507 })508 .collect::<Vec<_>>();509 table.push(SecretDisplay {510 owners: owners.join(", "),511 name,512 })513 }514 info!("loaded\n{}", Table::new(table).to_string())515 }516 }517 Ok(())518 }519}1use crate::{2 better_nix_eval::Field,3 fleetdata::{FleetSecret, FleetSharedSecret, SecretData},4 host::Config,5 nix_go, nix_go_json,6};7use anyhow::{anyhow, bail, ensure, Context, Result};8use chrono::{DateTime, Utc};9use clap::Parser;10use futures::{StreamExt, TryStreamExt};11use itertools::Itertools;12use owo_colors::OwoColorize;13use std::{14 collections::HashSet,15 io::{self, Cursor, Read},16 path::PathBuf,17};18use tabled::{Table, Tabled};19use tokio::fs::read_to_string;20use tracing::{info, info_span, warn};2122#[derive(Parser)]23pub enum Secret {24 /// Force load host keys for all defined hosts25 ForceKeys,26 /// Add secret, data should be provided in stdin27 AddShared {28 /// Secret name29 name: String,30 /// Secret owners31 machines: Vec<String>,32 /// Override secret if already present33 #[clap(long)]34 force: bool,35 /// Secret public part36 #[clap(long)]37 public: Option<String>,38 /// Load public part from specified file39 #[clap(long)]40 public_file: Option<PathBuf>,4142 /// Create a notification on secret expiration43 #[clap(long)]44 expires_at: Option<DateTime<Utc>>,4546 /// Secret with this name already exists, override its value while keeping the same owners.47 #[clap(long)]48 re_add: bool,49 },50 /// Add secret, data should be provided in stdin51 Add {52 /// Secret name53 name: String,54 /// Secret owners55 machine: String,56 /// Override secret if already present57 #[clap(long)]58 force: bool,59 #[clap(long)]60 public: Option<String>,61 #[clap(long)]62 public_file: Option<PathBuf>,63 },64 /// Read secret from remote host, requires sudo on said host65 Read {66 name: String,67 machine: String,68 #[clap(long)]69 plaintext: bool,70 },71 UpdateShared {72 name: String,7374 #[clap(long)]75 machines: Option<Vec<String>>,7677 #[clap(long)]78 add_machines: Vec<String>,79 #[clap(long)]80 remove_machines: Vec<String>,8182 /// Which host should we use to decrypt83 #[clap(long)]84 prefer_identities: Vec<String>,85 },86 Regenerate {87 /// Which host should we use to decrypt, in case if reencryption is required, without88 /// regeneration89 #[clap(long)]90 prefer_identities: Vec<String>,91 },92 List {},93}9495async fn generate_shared(96 config: &Config,97 display_name: &str,98 secret: Field,99) -> Result<FleetSharedSecret> {100 Ok(if secret.has_field("generateImpure").await? {101 let config_field = &config.config_unchecked_field;102 let generate = nix_go!(secret.generateImpure);103 let owners: Vec<String> = nix_go_json!(secret.expectedOwners);104105 let on: String = nix_go_json!(generate.on);106 let call_package = nix_go!(107 config_field.buildableSystems(Obj {108 localSystem: { config.local_system.clone() }109 })[{ on }]110 .config111 .nixpkgs112 .resolvedPkgs113 .callPackage114 );115116 let host = config.host(&on).await?;117118 let generator = nix_go!(call_package(generate.generator)(Obj {}));119 let generator = generator.build().await?;120 let generator = generator121 .get("out")122 .ok_or_else(|| anyhow!("missing generateImpure out"))?;123 let generator = host.remote_derivation(generator).await?;124125 let mut recipients = String::new();126 for owner in &owners {127 let key = config.key(owner).await?;128 recipients.push_str(&format!("-r \"{key}\" "));129 }130 recipients.push_str("-e");131132 let out = host.mktemp_dir().await?;133134 let mut gen = host.cmd(generator).await?;135 gen.env("rageArgs", recipients).env("out", &out);136 gen.run().await?;137138 {139 let marker = host.read_file_text(format!("{out}/marker")).await?;140 ensure!(marker == "SUCCESS", "generation not succeeded");141 }142143 let public = host.read_file_text(format!("{out}/public")).await.ok();144 let secret = host.read_file_bin(format!("{out}/secret")).await.ok();145 if let Some(secret) = &secret {146 ensure!(147 age::Decryptor::new(Cursor::new(&secret)).is_ok(),148 "builder produced non-encrypted value as secret, this is highly insecure"149 );150 }151152 let created_at = host.read_file_value(format!("{out}/created_at")).await?;153 let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();154155 FleetSharedSecret {156 owners,157 secret: FleetSecret {158 created_at,159 expires_at,160 public,161 secret: secret.map(SecretData),162 },163 }164 } else {165 bail!("no generator defined for {display_name}")166 })167}168169async fn parse_public(170 public: Option<String>,171 public_file: Option<PathBuf>,172) -> Result<Option<String>> {173 Ok(match (public, public_file) {174 (Some(v), None) => Some(v),175 (None, Some(v)) => Some(read_to_string(v).await?),176 (Some(_), Some(_)) => {177 bail!("only public or public_file should be set")178 }179 (None, None) => None,180 })181}182183fn parse_machines(184 initial: Vec<String>,185 machines: Option<Vec<String>>,186 mut add_machines: Vec<String>,187 mut remove_machines: Vec<String>,188) -> Result<Vec<String>> {189 if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {190 bail!("no operation");191 }192193 let initial_machines = initial.clone();194 let mut target_machines = initial;195 info!("Currently encrypted for {initial_machines:?}");196197 // ensure!(machines.is_some() || !add_machines.is_empty() || )198 if let Some(machines) = machines {199 ensure!(200 add_machines.is_empty() && remove_machines.is_empty(),201 "can't combine --machines and --add-machines/--remove-machines"202 );203 let target = initial_machines.iter().collect::<HashSet<_>>();204 let source = machines.iter().collect::<HashSet<_>>();205 for removed in target.difference(&source) {206 remove_machines.push((*removed).clone());207 }208 for added in source.difference(&target) {209 add_machines.push((*added).clone());210 }211 }212213 for machine in &remove_machines {214 let mut removed = false;215 while let Some(pos) = target_machines.iter().position(|m| m == machine) {216 target_machines.swap_remove(pos);217 removed = true;218 }219 if !removed {220 warn!("secret is not enabled for {machine}");221 }222 }223 for machine in &add_machines {224 if target_machines.iter().any(|m| m == machine) {225 warn!("secret is already added to {machine}");226 } else {227 target_machines.push(machine.to_owned());228 }229 }230 if !remove_machines.is_empty() {231 // TODO: maybe force secret regeneration?232 // Not that useful without revokation.233 warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");234 }235 Ok(target_machines)236}237impl Secret {238 pub async fn run(self, config: &Config) -> Result<()> {239 match self {240 Secret::ForceKeys => {241 for host in config.list_hosts().await? {242 if config.should_skip(&host.name) {243 continue;244 }245 config.key(&host.name).await?;246 }247 }248 Secret::AddShared {249 mut machines,250 name,251 force,252 public,253 public_file,254 expires_at,255 re_add,256 } => {257 let exists = config.has_shared(&name);258 if exists && !force && !re_add {259 bail!("secret already defined");260 }261 if re_add {262 // Fixme: use clap to limit this usage263 ensure!(!force, "--force and --readd are not compatible");264 ensure!(exists, "secret doesn't exists");265 ensure!(266 machines.is_empty(),267 "you can't use machines argument for --readd"268 );269 let shared = config.shared_secret(&name)?;270 machines = shared.owners;271 }272273 let recipients = config274 .recipients(&machines.iter().map(String::as_str).collect_vec())275 .await?;276277 let secret = {278 let mut input = vec![];279 io::stdin().read_to_end(&mut input)?;280281 if input.is_empty() {282 None283 } else {284 Some(285 SecretData::encrypt(recipients, input)286 .ok_or_else(|| anyhow!("no recipients provided"))?,287 )288 }289 };290 let public = parse_public(public, public_file).await?;291 config.replace_shared(292 name,293 FleetSharedSecret {294 owners: machines,295 secret: FleetSecret {296 created_at: Utc::now(),297 expires_at,298 secret,299 public,300 },301 },302 );303 }304 Secret::Add {305 machine,306 name,307 force,308 public,309 public_file,310 } => {311 let recipient = config.recipient(&machine).await?;312313 let secret = {314 let mut input = vec![];315 io::stdin().read_to_end(&mut input)?;316 if input.is_empty() {317 bail!("no data provided")318 }319320 Some(SecretData::encrypt(vec![recipient], input).expect("recipient provided"))321 };322323 if config.has_secret(&machine, &name) && !force {324 bail!("secret already defined");325 }326 let public = parse_public(public, public_file).await?;327328 config.insert_secret(329 &machine,330 name,331 FleetSecret {332 created_at: Utc::now(),333 expires_at: None,334 secret,335 public,336 },337 );338 }339 #[allow(clippy::await_holding_refcell_ref)]340 Secret::Read {341 name,342 machine,343 plaintext,344 } => {345 let secret = config.host_secret(&machine, &name)?;346 let Some(secret) = secret.secret else {347 bail!("no secret {name}");348 };349 let host = config.host(&machine).await?;350 let data = host.decrypt(secret).await?;351 if plaintext {352 let s = String::from_utf8(data).context("output is not utf8")?;353 print!("{s}");354 } else {355 println!("{}", z85::encode(&data));356 }357 }358 Secret::UpdateShared {359 name,360 machines,361 add_machines,362 remove_machines,363 prefer_identities,364 } => {365 let mut secret = config.shared_secret(&name)?;366 if secret.secret.secret.is_none() {367 bail!("no secret");368 }369370 let initial_machines = secret.owners.clone();371 let target_machines = parse_machines(372 initial_machines.clone(),373 machines,374 add_machines,375 remove_machines,376 )?;377378 if target_machines.is_empty() {379 info!("no machines left for secret, removing it");380 config.remove_shared(&name);381 return Ok(());382 }383384 if target_machines == initial_machines {385 warn!("secret owners are already correct");386 return Ok(());387 }388389 let identity_holder = if !prefer_identities.is_empty() {390 prefer_identities391 .iter()392 .find(|i| initial_machines.iter().any(|s| s == *i))393 } else {394 secret.owners.first()395 };396 let Some(identity_holder) = identity_holder else {397 bail!("no available holder found");398 };399 let target_recipients = futures::stream::iter(&target_machines)400 .then(|m| async { config.key(m).await })401 .collect::<Vec<_>>()402 .await;403 let target_recipients =404 target_recipients.into_iter().collect::<Result<Vec<_>>>()?;405406 if let Some(data) = secret.secret.secret {407 let encrypted = config408 .reencrypt_on_host(identity_holder, data, target_recipients)409 .await?;410 secret.secret.secret = Some(encrypted);411 }412413 secret.owners = target_machines;414 config.replace_shared(name, secret);415 }416 Secret::Regenerate { prefer_identities } => {417 {418 let expected_shared_set = config419 .list_configured_shared()420 .await?421 .into_iter()422 .collect::<HashSet<_>>();423 let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();424 for removed in expected_shared_set.difference(&shared_set) {425 info!("generating secret: {removed}");426 let config_field = &config.config_unchecked_field;427 let config_field = nix_go!(config_field.configUnchecked);428 let secret = nix_go!(config_field.sharedSecrets[{ removed }]);429 let shared = generate_shared(config, removed, secret).await?;430 config.replace_shared(removed.to_string(), shared)431 }432 }433 let mut to_remove = Vec::new();434 for name in &config.list_shared() {435 info!("updating secret: {name}");436 let mut data = config.shared_secret(name)?;437 let config_field = &config.config_unchecked_field;438 let config_field = nix_go!(config_field.configUnchecked);439 let expected_owners: Vec<String> =440 nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);441 if expected_owners.is_empty() {442 warn!("secret was removed from fleet config: {name}, removing from data");443 to_remove.push(name.to_string());444 continue;445 }446 let set = data.owners.iter().collect::<HashSet<_>>();447 let expected_set = expected_owners.iter().collect::<HashSet<_>>();448 let should_remove = set.difference(&expected_set).next().is_some();449 if set == expected_set {450 info!("secret data is ok");451 continue;452 }453454 let secret = nix_go!(config_field.sharedSecrets[{ name }]);455 let owner_dependent: bool = nix_go_json!(secret.ownerDependent);456 let regenerate_on_remove: bool = nix_go_json!(secret.regenerateOnOwnerRemoved);457 #[allow(clippy::nonminimal_bool)]458 if !owner_dependent && !(should_remove && regenerate_on_remove) {459 warn!("reencrypting secret '{name}' for new owner set");460 // TODO: force regeneration461 if should_remove {462 warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");463 }464465 let identity_holder = if !prefer_identities.is_empty() {466 prefer_identities467 .iter()468 .find(|i| data.owners.iter().any(|s| s == *i))469 } else {470 data.owners.first()471 };472 let Some(identity_holder) = identity_holder else {473 bail!("no available holder found");474 };475476 let target_recipients = futures::stream::iter(&expected_owners)477 .then(|m| async { config.key(m).await })478 .collect::<Vec<_>>()479 .await;480 let target_recipients =481 target_recipients.into_iter().collect::<Result<Vec<_>>>()?;482483 if let Some(secret) = data.secret.secret {484 let encrypted = config485 .reencrypt_on_host(identity_holder, secret, target_recipients)486 .await?;487488 data.secret.secret = Some(encrypted);489 }490 data.owners = expected_owners;491 config.replace_shared(name.to_owned(), data);492 } else {493 let shared = generate_shared(config, name, secret).await?;494 config.replace_shared(name.to_owned(), shared)495 }496 }497 for k in to_remove {498 config.remove_shared(&k);499 }500 }501 Secret::List {} => {502 let _span = info_span!("loading secrets").entered();503 let configured = config.list_configured_shared().await?;504 #[derive(Tabled)]505 struct SecretDisplay {506 #[tabled(rename = "Name")]507 name: String,508 #[tabled(rename = "Owners")]509 owners: String,510 }511 let mut table = vec![];512 for name in configured.iter().cloned() {513 let config = config.clone();514 let expected_owners = config.shared_secret_expected_owners(&name).await?;515 let data = config.shared_secret(&name)?;516 let owners = data517 .owners518 .iter()519 .map(|o| {520 if expected_owners.contains(o) {521 o.green().to_string()522 } else {523 o.red().to_string()524 }525 })526 .collect::<Vec<_>>();527 table.push(SecretDisplay {528 owners: owners.join(", "),529 name,530 })531 }532 info!("loaded\n{}", Table::new(table).to_string())533 }534 }535 Ok(())536 }537}cmds/fleet/src/fleetdata.rsdiffbeforeafterboth--- a/cmds/fleet/src/fleetdata.rs
+++ b/cmds/fleet/src/fleetdata.rs
@@ -1,8 +1,13 @@
+use age::Recipient;
use anyhow::Result;
use chrono::{DateTime, Utc};
+use itertools::Itertools;
use nixlike::format_nix;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
-use std::collections::BTreeMap;
+use std::{
+ collections::BTreeMap,
+ io::{self, Cursor},
+};
use tempfile::TempDir;
use tokio::{
fs::{self, File},
@@ -41,6 +46,43 @@
}
#[derive(Serialize, Deserialize, Clone)]
+pub struct SecretData(
+ #[serde(
+ default,
+ skip_serializing_if = "Vec::is_empty",
+ serialize_with = "as_z85",
+ deserialize_with = "from_z85"
+ )]
+ pub Vec<u8>,
+);
+impl SecretData {
+ /// Returns None if recipients.is_empty()
+ pub fn encrypt(
+ recipients: impl IntoIterator<Item = impl Recipient + Send + 'static>,
+ data: Vec<u8>,
+ ) -> Option<Self> {
+ let mut encrypted = vec![];
+ let recipients = recipients
+ .into_iter()
+ .map(|v| Box::new(v) as Box<dyn Recipient + Send>)
+ .collect_vec();
+ let mut encryptor = age::Encryptor::with_recipients(recipients)?
+ .wrap_output(&mut encrypted)
+ .expect("in memory write");
+ io::copy(&mut Cursor::new(data), &mut encryptor).expect("in memory copy");
+ encryptor.finish().expect("in memory flush");
+ Some(Self(encrypted))
+ }
+ pub fn encode_z85(&self) -> String {
+ z85::encode(&self.0)
+ }
+ pub fn decode_z85(v: &str) -> Result<Self> {
+ let v = z85::decode(v)?;
+ Ok(Self(v))
+ }
+}
+
+#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[must_use]
pub struct FleetSecret {
@@ -51,13 +93,8 @@
pub expires_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub public: Option<String>,
- #[serde(
- default,
- skip_serializing_if = "Vec::is_empty",
- serialize_with = "as_z85",
- deserialize_with = "from_z85"
- )]
- pub secret: Vec<u8>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub secret: Option<SecretData>,
}
fn as_z85<S>(key: &[u8], serializer: S) -> Result<S::Ok, S::Error>
cmds/fleet/src/host.rsdiffbeforeafterboth--- a/cmds/fleet/src/host.rs
+++ b/cmds/fleet/src/host.rs
@@ -1,21 +1,25 @@
use std::{
env::current_dir,
ffi::{OsStr, OsString},
+ fmt::Display,
io::Write,
ops::Deref,
path::PathBuf,
+ str::FromStr,
sync::{Arc, Mutex, MutexGuard, OnceLock},
};
+use age::Recipient;
use anyhow::{anyhow, bail, Context, Result};
use clap::{ArgGroup, Parser};
use openssh::SessionBuilder;
+use serde::de::DeserializeOwned;
use tempfile::NamedTempFile;
use crate::{
better_nix_eval::{Field, NixSessionPool},
command::MyCommand,
- fleetdata::{FleetData, FleetSecret, FleetSharedSecret},
+ fleetdata::{FleetData, FleetSecret, FleetSharedSecret, SecretData},
nix_go, nix_go_json,
};
@@ -80,14 +84,25 @@
cmd.arg(path);
cmd.run_string().await
}
+ pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {
+ let text = self.read_file_text(path).await?;
+ Ok(serde_json::from_str(&text)?)
+ }
+ pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>
+ where
+ <D as FromStr>::Err: Display,
+ {
+ let text = self.read_file_text(path).await?;
+ D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))
+ }
pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {
let session = self.open_session().await?;
Ok(MyCommand::new_on(cmd, session))
}
- pub async fn decrypt(&self, data: Vec<u8>) -> Result<Vec<u8>> {
+ pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {
let mut cmd = self.cmd("fleet-install-secrets").await?;
- cmd.arg("decrypt").eqarg("--secret", z85::encode(&data));
+ cmd.arg("decrypt").eqarg("--secret", data.encode_z85());
let encoded = cmd
.sudo()
.run_string()
@@ -95,6 +110,16 @@
.context("failed to call remote host for decrypt")?;
z85::decode(encoded.trim_end()).context("bad encoded data? outdated host?")
}
+ /// Returns path for futureproofing, as path might change i.e on conversion to CA
+ pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {
+ let mut nix = MyCommand::new("nix");
+ nix.arg("copy")
+ .arg("--substitute-on-destination")
+ .comparg("--to", format!("ssh-ng://{}", self.name))
+ .arg(path);
+ nix.run_nix().await?;
+ Ok(path.to_owned())
+ }
}
impl Config {
@@ -166,8 +191,10 @@
}
/// Shared secrets configured in fleet.nix or in flake
pub async fn list_configured_shared(&self) -> Result<Vec<String>> {
- let config_field = &self.config_field;
- nix_go!(config_field.sharedSecrets).list_fields().await
+ let config_field = &self.config_unchecked_field;
+ nix_go!(config_field.configUnchecked.sharedSecrets)
+ .list_fields()
+ .await
}
/// Shared secrets configured in fleet.nix
pub fn list_shared(&self) -> Vec<String> {
@@ -203,12 +230,11 @@
pub async fn reencrypt_on_host(
&self,
host: &str,
- data: Vec<u8>,
+ data: SecretData,
targets: Vec<String>,
- ) -> Result<Vec<u8>> {
- let data = z85::encode(&data);
+ ) -> Result<SecretData> {
let mut recmd = MyCommand::new("fleet-install-secrets");
- recmd.arg("reencrypt").eqarg("--secret", data);
+ recmd.arg("reencrypt").eqarg("--secret", data.encode_z85());
for target in targets {
recmd.eqarg("--targets", target);
}
@@ -219,7 +245,7 @@
.context("failed to call remote host for decrypt")?
.trim()
.to_owned();
- z85::decode(encoded).context("bad encoded data? outdated host?")
+ SecretData::decode_z85(&encoded)
}
pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {
@@ -240,9 +266,9 @@
Ok(secret.clone())
}
pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {
- let config_field = &self.config_field;
+ let config_field = &self.config_unchecked_field;
Ok(nix_go_json!(
- config_field.sharedSecrets[{ secret }].expectedOwners
+ config_field.configUnchecked.sharedSecrets[{ secret }].expectedOwners
))
}
cmds/fleet/src/keys.rsdiffbeforeafterboth--- a/cmds/fleet/src/keys.rs
+++ b/cmds/fleet/src/keys.rs
@@ -2,7 +2,9 @@
use crate::command::MyCommand;
use crate::host::Config;
+use age::Recipient;
use anyhow::{anyhow, Result};
+use futures::{StreamExt, TryStreamExt};
use itertools::Itertools;
use tracing::warn;
@@ -36,11 +38,18 @@
}
}
/// Insecure, requires root
- pub async fn recipient(&self, host: &str) -> anyhow::Result<age::ssh::Recipient> {
+ pub async fn recipient(&self, host: &str) -> anyhow::Result<impl Recipient> {
let key = self.key(host).await?;
age::ssh::Recipient::from_str(&key).map_err(|e| anyhow!("parse recipient error: {:?}", e))
}
+ pub async fn recipients(&self, hosts: &[&str]) -> Result<Vec<impl Recipient>> {
+ futures::stream::iter(hosts.iter())
+ .then(|m| self.recipient(m))
+ .try_collect::<Vec<_>>()
+ .await
+ }
+
#[allow(dead_code)]
pub async fn orphaned_data(&self) -> Result<Vec<String>> {
let mut out = Vec::new();
crates/nixlike/src/lib.rsdiffbeforeafterboth--- a/crates/nixlike/src/lib.rs
+++ b/crates/nixlike/src/lib.rs
@@ -10,6 +10,8 @@
mod se_impl;
mod to_string;
+pub use to_string::escape_string;
+
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("bad number")]
crates/nixlike/src/to_string.rsdiffbeforeafterboth--- a/crates/nixlike/src/to_string.rs
+++ b/crates/nixlike/src/to_string.rs
@@ -25,8 +25,8 @@
}
}
-fn write_nix_str(str: &str, out: &mut String) {
- out.push_str(&format!(
+pub fn escape_string(str: &str) -> String {
+ format!(
"\"{}\"",
str.replace('\\', "\\\\")
.replace('"', "\\\"")
@@ -34,7 +34,11 @@
.replace('\t', "\\t")
.replace('\r', "\\r")
.replace('$', "\\$")
- ))
+ )
+}
+
+pub fn write_nix_str(str: &str, out: &mut String) {
+ out.push_str(&escape_string(str))
}
fn write_nix_buf(value: &Value, out: &mut String) {