difftreelog
fix post-refactor
in: trunk
3 files changed
cmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth1use 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;11use itertools::Itertools;12use owo_colors::OwoColorize;13use serde::Deserialize;14use std::{15 collections::{BTreeSet, HashSet},16 io::{self, Cursor, Read},17 path::PathBuf,18};19use tabled::{Table, Tabled};20use tokio::fs::read_to_string;21use tracing::{error, info, info_span, warn, Instrument};2223#[derive(Parser)]24pub enum Secret {25 /// Force load host keys for all defined hosts26 ForceKeys,27 /// Add secret, data should be provided in stdin28 AddShared {29 /// Secret name30 name: String,31 /// Secret owners32 machines: Vec<String>,33 /// Override secret if already present34 #[clap(long)]35 force: bool,36 /// Secret public part37 #[clap(long)]38 public: Option<String>,39 /// Load public part from specified file40 #[clap(long)]41 public_file: Option<PathBuf>,4243 /// Create a notification on secret expiration44 #[clap(long)]45 expires_at: Option<DateTime<Utc>>,4647 /// Secret with this name already exists, override its value while keeping the same owners.48 #[clap(long)]49 re_add: bool,50 },51 /// Add secret, data should be provided in stdin52 Add {53 /// Secret name54 name: String,55 /// Secret owners56 machine: String,57 /// Override secret if already present58 #[clap(long)]59 force: bool,60 #[clap(long)]61 public: Option<String>,62 #[clap(long)]63 public_file: Option<PathBuf>,64 },65 /// Read secret from remote host, requires sudo on said host66 Read {67 name: String,68 machine: String,69 #[clap(long)]70 plaintext: bool,71 },72 UpdateShared {73 name: String,7475 #[clap(long)]76 machines: Option<Vec<String>>,7778 #[clap(long)]79 add_machines: Vec<String>,80 #[clap(long)]81 remove_machines: Vec<String>,8283 /// Which host should we use to decrypt84 #[clap(long)]85 prefer_identities: Vec<String>,86 },87 Regenerate {88 /// Which host should we use to decrypt, in case if reencryption is required, without89 /// regeneration90 #[clap(long)]91 prefer_identities: Vec<String>,92 },93 List {},94}9596#[tracing::instrument(skip(config, secret, field, prefer_identities))]97async fn update_owner_set(98 secret_name: &str,99 config: &Config,100 mut secret: FleetSharedSecret,101 field: Field,102 updated_set: &[String],103 prefer_identities: &[String],104) -> Result<FleetSharedSecret> {105 let original_set = secret.owners.clone();106107 let set = original_set.iter().collect::<BTreeSet<_>>();108 let expected_set = updated_set.iter().collect::<BTreeSet<_>>();109110 if set == expected_set {111 info!("no need to update owner list, it is already correct");112 return Ok(secret);113 }114115 let should_regenerate = if set.difference(&expected_set).next().is_some() {116 // TODO: Remove this warning for revokable secrets.117 warn!("host was removed from secret owners, but until this host rebuild, the secret will still be stored on it.");118 nix_go_json!(field.regenerateOnOwnerRemoved)119 } else if expected_set.difference(&set).next().is_some() {120 nix_go_json!(field.regenerateOnOwnerAdded)121 } else {122 false123 };124125 if should_regenerate {126 info!("secret is owner-dependent, will regenerate");127 let generated = generate_shared(config, secret_name, field, updated_set.to_vec()).await?;128 Ok(generated)129 } else {130 let identity_holder = if !prefer_identities.is_empty() {131 prefer_identities132 .iter()133 .find(|i| original_set.iter().any(|s| s == *i))134 } else {135 secret.owners.first()136 };137 let Some(identity_holder) = identity_holder else {138 bail!("no available holder found");139 };140141 if let Some(data) = secret.secret.secret {142 let host = config.host(identity_holder).await?;143 let encrypted = host.reencrypt(data, updated_set.to_vec()).await?;144 secret.secret.secret = Some(encrypted);145 }146147 secret.owners = updated_set.to_vec();148 Ok(secret)149 }150}151152#[derive(Deserialize)]153#[serde(rename_all = "camelCase")]154enum GeneratorKind {155 Impure,156}157158async fn generate_impure(159 config: &Config,160 _display_name: &str,161 secret: Field,162 default_generator: Field,163 owners: &[String],164) -> Result<FleetSecret> {165 let config_field = &config.config_unchecked_field;166 let generator = nix_go!(secret.generator);167168 let on: String = nix_go_json!(default_generator.impureOn);169 let call_package = nix_go!(170 config_field.hosts[{ on }]171 .nixosSystem172 .config173 .nixpkgs174 .resolvedPkgs175 .callPackage176 );177178 let host = config.host(&on).await?;179180 let generator = nix_go!(call_package(generator)(Obj {}));181 let generator = generator.build().await?;182 let generator = generator183 .get("out")184 .ok_or_else(|| anyhow!("missing generateImpure out"))?;185 let generator = host.remote_derivation(generator).await?;186187 let mut recipients = String::new();188 for owner in owners {189 let key = config.key(owner).await?;190 recipients.push_str(&format!("-r \"{key}\" "));191 }192 recipients.push_str("-e");193194 let out = host.mktemp_dir().await?;195196 let mut gen = host.cmd(generator).await?;197 gen.env("rageArgs", recipients).env("out", &out);198 gen.run().await.context("impure generator")?;199200 {201 let marker = host.read_file_text(format!("{out}/marker")).await?;202 ensure!(marker == "SUCCESS", "generation not succeeded");203 }204205 let public = host.read_file_text(format!("{out}/public")).await.ok();206 let secret = host.read_file_bin(format!("{out}/secret")).await.ok();207 if let Some(secret) = &secret {208 ensure!(209 age::Decryptor::new(Cursor::new(&secret)).is_ok(),210 "builder produced non-encrypted value as secret, this is highly insecure, and not allowed."211 );212 }213214 let created_at = host.read_file_value(format!("{out}/created_at")).await?;215 let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();216217 Ok(FleetSecret {218 created_at,219 expires_at,220 public,221 secret: secret.map(SecretData),222 })223}224async fn generate(225 config: &Config,226 display_name: &str,227 secret: Field,228 owners: &[String],229) -> Result<FleetSecret> {230 let generator = nix_go!(secret.generator);231 // Can't properly check on nix module system level232 {233 let gen_ty = generator.type_of().await?;234 if gen_ty == "null" {235 bail!("secret has no generator defined, can't automatically generate it.");236 }237 if gen_ty != "lambda" {238 bail!("generator should be lambda, got {gen_ty}");239 }240 }241 let default_pkgs = &config.default_pkgs;242 let default_call_package = nix_go!(default_pkgs.callPackage);243 // Generators provide additional information in passthru, to access244 // passthru we should call generator, but information about where this generator is supposed to build245 // is located in passthru... Thus evaluating generator on host.246 //247 // Maybe it is also possible to do some magic with __functor?248 //249 // I don't want to make modules always responsible for additional secret data anyway,250 // so it should be in derivation, and not in the secret data itself.251 let default_generator = nix_go!(default_call_package(generator)(Obj {}));252253 let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);254255 match kind {256 GeneratorKind::Impure => {257 generate_impure(config, display_name, secret, default_generator, owners).await258 }259 }260}261async fn generate_shared(262 config: &Config,263 display_name: &str,264 secret: Field,265 expected_owners: Vec<String>,266) -> Result<FleetSharedSecret> {267 // let owners: Vec<String> = nix_go_json!(secret.expectedOwners);268 Ok(FleetSharedSecret {269 secret: generate(config, display_name, secret, &expected_owners).await?,270 owners: expected_owners,271 })272}273274async fn parse_public(275 public: Option<String>,276 public_file: Option<PathBuf>,277) -> Result<Option<String>> {278 Ok(match (public, public_file) {279 (Some(v), None) => Some(v),280 (None, Some(v)) => Some(read_to_string(v).await?),281 (Some(_), Some(_)) => {282 bail!("only public or public_file should be set")283 }284 (None, None) => None,285 })286}287288fn parse_machines(289 initial: Vec<String>,290 machines: Option<Vec<String>>,291 mut add_machines: Vec<String>,292 mut remove_machines: Vec<String>,293) -> Result<Vec<String>> {294 if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {295 bail!("no operation");296 }297298 let initial_machines = initial.clone();299 let mut target_machines = initial;300 info!("Currently encrypted for {initial_machines:?}");301302 // ensure!(machines.is_some() || !add_machines.is_empty() || )303 if let Some(machines) = machines {304 ensure!(305 add_machines.is_empty() && remove_machines.is_empty(),306 "can't combine --machines and --add-machines/--remove-machines"307 );308 let target = initial_machines.iter().collect::<HashSet<_>>();309 let source = machines.iter().collect::<HashSet<_>>();310 for removed in target.difference(&source) {311 remove_machines.push((*removed).clone());312 }313 for added in source.difference(&target) {314 add_machines.push((*added).clone());315 }316 }317318 for machine in &remove_machines {319 let mut removed = false;320 while let Some(pos) = target_machines.iter().position(|m| m == machine) {321 target_machines.swap_remove(pos);322 removed = true;323 }324 if !removed {325 warn!("secret is not enabled for {machine}");326 }327 }328 for machine in &add_machines {329 if target_machines.iter().any(|m| m == machine) {330 warn!("secret is already added to {machine}");331 } else {332 target_machines.push(machine.to_owned());333 }334 }335 if !remove_machines.is_empty() {336 // TODO: maybe force secret regeneration?337 // Not that useful without revokation.338 warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");339 }340 Ok(target_machines)341}342impl Secret {343 pub async fn run(self, config: &Config) -> Result<()> {344 match self {345 Secret::ForceKeys => {346 for host in config.list_hosts().await? {347 if config.should_skip(&host.name) {348 continue;349 }350 config.key(&host.name).await?;351 }352 }353 Secret::AddShared {354 mut machines,355 name,356 force,357 public,358 public_file,359 expires_at,360 re_add,361 } => {362 let exists = config.has_shared(&name);363 if exists && !force && !re_add {364 bail!("secret already defined");365 }366 if re_add {367 // Fixme: use clap to limit this usage368 ensure!(!force, "--force and --readd are not compatible");369 ensure!(exists, "secret doesn't exists");370 ensure!(371 machines.is_empty(),372 "you can't use machines argument for --readd"373 );374 let shared = config.shared_secret(&name)?;375 machines = shared.owners;376 }377378 let recipients = config.recipients(machines.clone()).await?;379380 let secret = {381 let mut input = vec![];382 io::stdin().read_to_end(&mut input)?;383384 if input.is_empty() {385 None386 } else {387 Some(388 SecretData::encrypt(recipients, input)389 .ok_or_else(|| anyhow!("no recipients provided"))?,390 )391 }392 };393 let public = parse_public(public, public_file).await?;394 config.replace_shared(395 name,396 FleetSharedSecret {397 owners: machines,398 secret: FleetSecret {399 created_at: Utc::now(),400 expires_at,401 secret,402 public,403 },404 },405 );406 }407 Secret::Add {408 machine,409 name,410 force,411 public,412 public_file,413 } => {414 let recipient = config.recipient(&machine).await?;415416 let secret = {417 let mut input = vec![];418 io::stdin().read_to_end(&mut input)?;419 if input.is_empty() {420 bail!("no data provided")421 }422423 Some(SecretData::encrypt(vec![recipient], input).expect("recipient provided"))424 };425426 if config.has_secret(&machine, &name) && !force {427 bail!("secret already defined");428 }429 let public = parse_public(public, public_file).await?;430431 config.insert_secret(432 &machine,433 name,434 FleetSecret {435 created_at: Utc::now(),436 expires_at: None,437 secret,438 public,439 },440 );441 }442 #[allow(clippy::await_holding_refcell_ref)]443 Secret::Read {444 name,445 machine,446 plaintext,447 } => {448 let secret = config.host_secret(&machine, &name)?;449 let Some(secret) = secret.secret else {450 bail!("no secret {name}");451 };452 let host = config.host(&machine).await?;453 let data = host.decrypt(secret).await?;454 if plaintext {455 let s = String::from_utf8(data).context("output is not utf8")?;456 print!("{s}");457 } else {458 println!("{}", z85::encode(&data));459 }460 }461 Secret::UpdateShared {462 name,463 machines,464 add_machines,465 remove_machines,466 prefer_identities,467 } => {468 let secret = config.shared_secret(&name)?;469 if secret.secret.secret.is_none() {470 bail!("no secret");471 }472473 let initial_machines = secret.owners.clone();474 let target_machines = parse_machines(475 initial_machines.clone(),476 machines,477 add_machines,478 remove_machines,479 )?;480481 if target_machines.is_empty() {482 info!("no machines left for secret, removing it");483 config.remove_shared(&name);484 return Ok(());485 }486487 let config_field = &config.config_unchecked_field;488 let field = nix_go!(config_field.sharedSecrets[{ name }]);489490 let updated = update_owner_set(491 &name,492 config,493 secret,494 field,495 &target_machines,496 &prefer_identities,497 )498 .await?;499 config.replace_shared(name, updated);500 }501 Secret::Regenerate { prefer_identities } => {502 info!("checking for secrets to regenerate");503 {504 let _span = info_span!("shared").entered();505 let expected_shared_set = config506 .list_configured_shared()507 .await?508 .into_iter()509 .collect::<HashSet<_>>();510 let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();511 for missing in expected_shared_set.difference(&shared_set) {512 let config_field = &config.config_unchecked_field;513 let secret = nix_go!(config_field.sharedSecrets[{ missing }]);514 let expected_owners: Option<Vec<String>> =515 nix_go_json!(secret.expectedOwners);516 let Some(expected_owners) = expected_owners else {517 // TODO: Might still need to regenerate518 continue;519 };520 info!("generating secret: {missing}");521 let shared = generate_shared(config, missing, secret, expected_owners)522 .in_current_span()523 .await?;524 config.replace_shared(missing.to_string(), shared)525 }526 }527 for host in config.list_hosts().await? {528 let _span = info_span!("host", host = host.name).entered();529 let expected_set = host530 .list_configured_secrets()531 .in_current_span()532 .await?533 .into_iter()534 .collect::<HashSet<_>>();535 let stored_set = config536 .list_secrets(&host.name)537 .into_iter()538 .collect::<HashSet<_>>();539 for missing in expected_set.difference(&stored_set) {540 info!("generating secret: {missing}");541 let secret = host.secret_field(missing).in_current_span().await?;542 let generated =543 match generate(config, missing, secret, &[host.name.clone()])544 .in_current_span()545 .await546 {547 Ok(v) => v,548 Err(e) => {549 error!("{e}");550 continue;551 }552 };553 config.insert_secret(&host.name, missing.to_string(), generated)554 }555 }556 let mut to_remove = Vec::new();557 for name in &config.list_shared() {558 info!("updating secret: {name}");559 let data = config.shared_secret(name)?;560 let config_field = &config.config_unchecked_field;561 let expected_owners: Vec<String> =562 nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);563 if expected_owners.is_empty() {564 warn!("secret was removed from fleet config: {name}, removing from data");565 to_remove.push(name.to_string());566 continue;567 }568569 let secret = nix_go!(config_field.sharedSecrets[{ name }]);570 config.replace_shared(571 name.to_owned(),572 update_owner_set(573 &name,574 config,575 data,576 secret,577 &expected_owners,578 &prefer_identities,579 )580 .await?,581 );582 }583 for k in to_remove {584 config.remove_shared(&k);585 }586 }587 Secret::List {} => {588 let _span = info_span!("loading secrets").entered();589 let configured = config.list_configured_shared().await?;590 #[derive(Tabled)]591 struct SecretDisplay {592 #[tabled(rename = "Name")]593 name: String,594 #[tabled(rename = "Owners")]595 owners: String,596 }597 let mut table = vec![];598 for name in configured.iter().cloned() {599 let config = config.clone();600 let expected_owners = config.shared_secret_expected_owners(&name).await?;601 let data = config.shared_secret(&name)?;602 let owners = data603 .owners604 .iter()605 .map(|o| {606 if expected_owners.contains(o) {607 o.green().to_string()608 } else {609 o.red().to_string()610 }611 })612 .collect::<Vec<_>>();613 table.push(SecretDisplay {614 owners: owners.join(", "),615 name,616 })617 }618 info!("loaded\n{}", Table::new(table).to_string())619 }620 }621 Ok(())622 }623}cmds/fleet/src/host.rsdiffbeforeafterboth--- a/cmds/fleet/src/host.rs
+++ b/cmds/fleet/src/host.rs
@@ -204,7 +204,7 @@
pub async fn host(&self, name: &str) -> Result<ConfigHost> {
let config = &self.config_unchecked_field;
- let nixos_config = nix_go!(config.configuredSystems[{ name }].config);
+ let nixos_config = nix_go!(config.hosts[{ name }].nixosSystem.config);
Ok(ConfigHost {
config: self.clone(),
name: name.to_owned(),
@@ -236,9 +236,7 @@
/// Shared secrets configured in fleet.nix or in flake
pub async fn list_configured_shared(&self) -> Result<Vec<String>> {
let config_field = &self.config_unchecked_field;
- nix_go!(config_field.configUnchecked.sharedSecrets)
- .list_fields()
- .await
+ nix_go!(config_field.sharedSecrets).list_fields().await
}
/// Shared secrets configured in fleet.nix
pub fn list_shared(&self) -> Vec<String> {
@@ -299,7 +297,7 @@
pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {
let config_field = &self.config_unchecked_field;
Ok(nix_go_json!(
- config_field.configUnchecked.sharedSecrets[{ secret }].expectedOwners
+ config_field.sharedSecrets[{ secret }].expectedOwners
))
}
lib/default.nixdiffbeforeafterboth--- a/lib/default.nix
+++ b/lib/default.nix
@@ -22,7 +22,7 @@
++ [
data
({...}: {
- inherit globalModules hosts;
+ inherit globalModules hosts overlays;
})
]
++ modules;
@@ -39,7 +39,6 @@
root,
data,
}: {
- configuredHosts = root.config.hosts;
config = root.config;
};
defaultData = withData {
@@ -49,9 +48,9 @@
uncheckedData = withData {inherit data root;};
in {
inherit nixpkgs overlays;
- inherit (defaultData) configuredHosts configuredSystems config buildableSystems;
+ inherit (defaultData) config;
unchecked = {
- inherit (uncheckedData) configuredHosts configuredSystems config buildableSystems;
+ inherit (uncheckedData) config;
};
};
}