difftreelog
fix post secret management refactor
in: trunk
1 file 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::{error::ErrorKind, Parser};10use crossterm::{terminal, tty::IsTty};11use itertools::Itertools;12use owo_colors::OwoColorize;13use serde::Deserialize;14use std::{15 collections::{BTreeSet, HashSet},16 ffi::OsString,17 io::{self, stdin, Cursor, Read, Write},18 path::PathBuf,19};20use tabled::{Table, Tabled};21use tempfile::NamedTempFile;22use tokio::{fs::read_to_string, process::Command};23use tracing::{error, info, info_span, warn, Instrument};2425#[derive(Parser)]26pub enum Secret {27 /// Force load host keys for all defined hosts28 ForceKeys,29 /// Add secret, data should be provided in stdin30 AddShared {31 /// Secret name32 name: String,33 /// Secret owners34 machines: Vec<String>,35 /// Override secret if already present36 #[clap(long)]37 force: bool,38 /// Secret public part39 #[clap(long)]40 public: Option<String>,41 /// Load public part from specified file42 #[clap(long)]43 public_file: Option<PathBuf>,4445 /// Create a notification on secret expiration46 #[clap(long)]47 expires_at: Option<DateTime<Utc>>,4849 /// Secret with this name already exists, override its value while keeping the same owners.50 #[clap(long)]51 re_add: bool,52 },53 /// Add secret, data should be provided in stdin54 Add {55 /// Secret name56 name: String,57 /// Secret owners58 machine: String,59 /// Override secret if already present60 #[clap(long)]61 force: bool,62 #[clap(long)]63 public: Option<String>,64 #[clap(long)]65 public_file: Option<PathBuf>,66 },67 /// Read secret from remote host, requires sudo on said host68 Read {69 name: String,70 machine: String,71 #[clap(long)]72 plaintext: bool,73 },74 ReadPublic {75 name: String,76 machine: String,77 },78 UpdateShared {79 name: String,8081 #[clap(long)]82 machines: Option<Vec<String>>,8384 #[clap(long)]85 add_machines: Vec<String>,86 #[clap(long)]87 remove_machines: Vec<String>,8889 /// Which host should we use to decrypt90 #[clap(long)]91 prefer_identities: Vec<String>,92 },93 Regenerate {94 /// Which host should we use to decrypt, in case if reencryption is required, without95 /// regeneration96 #[clap(long)]97 prefer_identities: Vec<String>,98 },99 List {},100}101102#[tracing::instrument(skip(config, secret, field, prefer_identities))]103async fn update_owner_set(104 secret_name: &str,105 config: &Config,106 mut secret: FleetSharedSecret,107 field: Field,108 updated_set: &[String],109 prefer_identities: &[String],110) -> Result<FleetSharedSecret> {111 let original_set = secret.owners.clone();112113 let set = original_set.iter().collect::<BTreeSet<_>>();114 let expected_set = updated_set.iter().collect::<BTreeSet<_>>();115116 if set == expected_set {117 info!("no need to update owner list, it is already correct");118 return Ok(secret);119 }120121 let should_regenerate = if set.difference(&expected_set).next().is_some() {122 // TODO: Remove this warning for revokable secrets.123 warn!("host was removed from secret owners, but until this host rebuild, the secret will still be stored on it.");124 nix_go_json!(field.regenerateOnOwnerRemoved)125 } else if expected_set.difference(&set).next().is_some() {126 nix_go_json!(field.regenerateOnOwnerAdded)127 } else {128 false129 };130131 if should_regenerate {132 info!("secret is owner-dependent, will regenerate");133 let generated = generate_shared(config, secret_name, field, updated_set.to_vec()).await?;134 Ok(generated)135 } else {136 let identity_holder = if !prefer_identities.is_empty() {137 prefer_identities138 .iter()139 .find(|i| original_set.iter().any(|s| s == *i))140 } else {141 secret.owners.first()142 };143 let Some(identity_holder) = identity_holder else {144 bail!("no available holder found");145 };146147 if let Some(data) = secret.secret.secret {148 let host = config.host(identity_holder).await?;149 let encrypted = host.reencrypt(data, updated_set.to_vec()).await?;150 secret.secret.secret = Some(encrypted);151 }152153 secret.owners = updated_set.to_vec();154 Ok(secret)155 }156}157158#[derive(Deserialize)]159#[serde(rename_all = "camelCase")]160enum GeneratorKind {161 Impure,162 Pure,163}164165async fn generate_pure(166 _config: &Config,167 _display_name: &str,168 _secret: Field,169 _default_generator: Field,170 _owners: &[String],171) -> Result<FleetSecret> {172 bail!("pure generators are broken for now")173}174async fn generate_impure(175 config: &Config,176 _display_name: &str,177 secret: Field,178 default_generator: Field,179 owners: &[String],180) -> Result<FleetSecret> {181 let generator = nix_go!(secret.generator);182 let on: Option<String> = nix_go_json!(default_generator.impureOn);183184 let host = if let Some(on) = &on {185 config.host(on).await?186 } else {187 config.local_host()188 };189 let on_pkgs = host.pkgs().await?;190 let call_package = nix_go!(on_pkgs.callPackage);191 let mk_encrypt_secret = nix_go!(on_pkgs.mkEncryptSecret);192193 let mut recipients = Vec::new();194 for owner in owners {195 let key = config.key(owner).await?;196 recipients.push(key);197 }198 let encrypt = nix_go!(mk_encrypt_secret(Obj {199 recipients: { recipients },200 }));201202 let generator = nix_go!(call_package(generator)(Obj {203 encrypt,204 rustfmt_please_newline: { true },205 }));206207 let generator = generator.build().await?;208 let generator = generator209 .get("out")210 .ok_or_else(|| anyhow!("missing generateImpure out"))?;211 let generator = host.remote_derivation(generator).await?;212213 let out_parent = host.mktemp_dir().await?;214 let out = format!("{out_parent}/out");215216 let mut gen = host.cmd(generator).await?;217 gen.env("out", &out);218 if on.is_none() {219 // This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.220 let project_path: String = config221 .directory222 .clone()223 .into_os_string()224 .into_string()225 .map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;226 gen.env("FLEET_PROJECT", project_path);227 }228 gen.run().await.context("impure generator")?;229230 {231 let marker = host.read_file_text(format!("{out}/marker")).await?;232 ensure!(marker == "SUCCESS", "generation not succeeded");233 }234235 let public = host.read_file_text(format!("{out}/public")).await.ok();236 let secret = host.read_file_bin(format!("{out}/secret")).await.ok();237 if let Some(secret) = &secret {238 ensure!(239 age::Decryptor::new(Cursor::new(&secret)).is_ok(),240 "builder produced non-encrypted value as secret, this is highly insecure, and not allowed."241 );242 }243244 let created_at = host.read_file_value(format!("{out}/created_at")).await?;245 let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();246247 Ok(FleetSecret {248 created_at,249 expires_at,250 public,251 secret: secret.map(SecretData),252 })253}254async fn generate(255 config: &Config,256 display_name: &str,257 secret: Field,258 owners: &[String],259) -> Result<FleetSecret> {260 let generator = nix_go!(secret.generator);261 // Can't properly check on nix module system level262 {263 let gen_ty = generator.type_of().await?;264 if gen_ty == "null" {265 bail!("secret has no generator defined, can't automatically generate it.");266 }267 if gen_ty != "lambda" {268 bail!("generator should be lambda, got {gen_ty}");269 }270 }271 let default_pkgs = &config.default_pkgs;272 let default_call_package = nix_go!(default_pkgs.callPackage);273 // Generators provide additional information in passthru, to access274 // passthru we should call generator, but information about where this generator is supposed to build275 // is located in passthru... Thus evaluating generator on host.276 //277 // Maybe it is also possible to do some magic with __functor?278 //279 // I don't want to make modules always responsible for additional secret data anyway,280 // so it should be in derivation, and not in the secret data itself.281 let default_generator = nix_go!(default_call_package(generator)(Obj {}));282283 let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);284285 match kind {286 GeneratorKind::Impure => {287 generate_impure(config, display_name, secret, default_generator, owners).await288 }289 GeneratorKind::Pure => {290 generate_pure(config, display_name, secret, default_generator, owners).await291 }292 }293}294async fn generate_shared(295 config: &Config,296 display_name: &str,297 secret: Field,298 expected_owners: Vec<String>,299) -> Result<FleetSharedSecret> {300 // let owners: Vec<String> = nix_go_json!(secret.expectedOwners);301 Ok(FleetSharedSecret {302 secret: generate(config, display_name, secret, &expected_owners).await?,303 owners: expected_owners,304 })305}306307async fn parse_public(308 public: Option<String>,309 public_file: Option<PathBuf>,310) -> Result<Option<String>> {311 Ok(match (public, public_file) {312 (Some(v), None) => Some(v),313 (None, Some(v)) => Some(read_to_string(v).await?),314 (Some(_), Some(_)) => {315 bail!("only public or public_file should be set")316 }317 (None, None) => None,318 })319}320321fn parse_machines(322 initial: Vec<String>,323 machines: Option<Vec<String>>,324 mut add_machines: Vec<String>,325 mut remove_machines: Vec<String>,326) -> Result<Vec<String>> {327 if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {328 bail!("no operation");329 }330331 let initial_machines = initial.clone();332 let mut target_machines = initial;333 info!("Currently encrypted for {initial_machines:?}");334335 // ensure!(machines.is_some() || !add_machines.is_empty() || )336 if let Some(machines) = machines {337 ensure!(338 add_machines.is_empty() && remove_machines.is_empty(),339 "can't combine --machines and --add-machines/--remove-machines"340 );341 let target = initial_machines.iter().collect::<HashSet<_>>();342 let source = machines.iter().collect::<HashSet<_>>();343 for removed in target.difference(&source) {344 remove_machines.push((*removed).clone());345 }346 for added in source.difference(&target) {347 add_machines.push((*added).clone());348 }349 }350351 for machine in &remove_machines {352 let mut removed = false;353 while let Some(pos) = target_machines.iter().position(|m| m == machine) {354 target_machines.swap_remove(pos);355 removed = true;356 }357 if !removed {358 warn!("secret is not enabled for {machine}");359 }360 }361 for machine in &add_machines {362 if target_machines.iter().any(|m| m == machine) {363 warn!("secret is already added to {machine}");364 } else {365 target_machines.push(machine.to_owned());366 }367 }368 if !remove_machines.is_empty() {369 // TODO: maybe force secret regeneration?370 // Not that useful without revokation.371 warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");372 }373 Ok(target_machines)374}375impl Secret {376 pub async fn run(self, config: &Config) -> Result<()> {377 match self {378 Secret::ForceKeys => {379 for host in config.list_hosts().await? {380 if config.should_skip(&host.name) {381 continue;382 }383 config.key(&host.name).await?;384 }385 }386 Secret::AddShared {387 mut machines,388 name,389 force,390 public,391 public_file,392 expires_at,393 re_add,394 } => {395 // TODO: Forbid updating secrets with set expectedOwners (= not user-managed).396397 let exists = config.has_shared(&name);398 if exists && !force && !re_add {399 bail!("secret already defined");400 }401 if re_add {402 // Fixme: use clap to limit this usage403 ensure!(!force, "--force and --readd are not compatible");404 ensure!(exists, "secret doesn't exists");405 ensure!(406 machines.is_empty(),407 "you can't use machines argument for --readd"408 );409 let shared = config.shared_secret(&name)?;410 machines = shared.owners;411 }412413 let recipients = config.recipients(machines.clone()).await?;414415 let secret = {416 let mut input = vec![];417 io::stdin().read_to_end(&mut input)?;418419 if input.is_empty() {420 None421 } else {422 Some(423 SecretData::encrypt(recipients, input)424 .ok_or_else(|| anyhow!("no recipients provided"))?,425 )426 }427 };428 let public = parse_public(public, public_file).await?;429 config.replace_shared(430 name,431 FleetSharedSecret {432 owners: machines,433 secret: FleetSecret {434 created_at: Utc::now(),435 expires_at,436 secret,437 public,438 },439 },440 );441 }442 Secret::Add {443 machine,444 name,445 force,446 public,447 public_file,448 } => {449 let recipient = config.recipient(&machine).await?;450451 let secret = {452 let mut input = vec![];453 io::stdin().read_to_end(&mut input)?;454 if input.is_empty() {455 bail!("no data provided")456 }457458 Some(SecretData::encrypt(vec![recipient], input).expect("recipient provided"))459 };460461 if config.has_secret(&machine, &name) && !force {462 bail!("secret already defined");463 }464 let public = parse_public(public, public_file).await?;465466 config.insert_secret(467 &machine,468 name,469 FleetSecret {470 created_at: Utc::now(),471 expires_at: None,472 secret,473 public,474 },475 );476 }477 #[allow(clippy::await_holding_refcell_ref)]478 Secret::Read {479 name,480 machine,481 plaintext,482 } => {483 let secret = config.host_secret(&machine, &name)?;484 let Some(secret) = secret.secret else {485 bail!("no secret {name}");486 };487 let host = config.host(&machine).await?;488 let data = host.decrypt(secret).await?;489 if plaintext {490 let s = String::from_utf8(data).context("output is not utf8")?;491 print!("{s}");492 } else {493 println!("{}", z85::encode(&data));494 }495 }496 Secret::ReadPublic { name, machine } => {497 let secret = config.host_secret(&machine, &name)?;498 let Some(public) = secret.public else {499 bail!("no secret {name}");500 };501 print!("{public}");502 }503 Secret::UpdateShared {504 name,505 machines,506 add_machines,507 remove_machines,508 prefer_identities,509 } => {510 // TODO: Forbid updating secrets with set expectedOwners (= not user-managed).511512 let secret = config.shared_secret(&name)?;513 if secret.secret.secret.is_none() {514 bail!("no secret");515 }516517 let initial_machines = secret.owners.clone();518 let target_machines = parse_machines(519 initial_machines.clone(),520 machines,521 add_machines,522 remove_machines,523 )?;524525 if target_machines.is_empty() {526 info!("no machines left for secret, removing it");527 config.remove_shared(&name);528 return Ok(());529 }530531 let config_field = &config.config_unchecked_field;532 let field = nix_go!(config_field.sharedSecrets[{ name }]);533534 let updated = update_owner_set(535 &name,536 config,537 secret,538 field,539 &target_machines,540 &prefer_identities,541 )542 .await?;543 config.replace_shared(name, updated);544 }545 Secret::Regenerate { prefer_identities } => {546 info!("checking for secrets to regenerate");547 {548 let _span = info_span!("shared").entered();549 let expected_shared_set = config550 .list_configured_shared()551 .await?552 .into_iter()553 .collect::<HashSet<_>>();554 let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();555 for missing in expected_shared_set.difference(&shared_set) {556 let config_field = &config.config_unchecked_field;557 let secret = nix_go!(config_field.sharedSecrets[{ missing }]);558 let expected_owners: Option<Vec<String>> =559 nix_go_json!(secret.expectedOwners);560 let Some(expected_owners) = expected_owners else {561 // TODO: Might still need to regenerate562 continue;563 };564 info!("generating secret: {missing}");565 let shared = generate_shared(config, missing, secret, expected_owners)566 .in_current_span()567 .await?;568 config.replace_shared(missing.to_string(), shared)569 }570 }571 for host in config.list_hosts().await? {572 let _span = info_span!("host", host = host.name).entered();573 let expected_set = host574 .list_configured_secrets()575 .in_current_span()576 .await?577 .into_iter()578 .collect::<HashSet<_>>();579 let stored_set = config580 .list_secrets(&host.name)581 .into_iter()582 .collect::<HashSet<_>>();583 for missing in expected_set.difference(&stored_set) {584 info!("generating secret: {missing}");585 let secret = host.secret_field(missing).in_current_span().await?;586 let generated =587 match generate(config, missing, secret, &[host.name.clone()])588 .in_current_span()589 .await590 {591 Ok(v) => v,592 Err(e) => {593 error!("{e:?}");594 continue;595 }596 };597 config.insert_secret(&host.name, missing.to_string(), generated)598 }599 }600 let mut to_remove = Vec::new();601 for name in &config.list_shared() {602 info!("updating secret: {name}");603 let data = config.shared_secret(name)?;604 let config_field = &config.config_unchecked_field;605 let expected_owners: Vec<String> =606 nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);607 if expected_owners.is_empty() {608 warn!("secret was removed from fleet config: {name}, removing from data");609 to_remove.push(name.to_string());610 continue;611 }612613 let secret = nix_go!(config_field.sharedSecrets[{ name }]);614 config.replace_shared(615 name.to_owned(),616 update_owner_set(617 name,618 config,619 data,620 secret,621 &expected_owners,622 &prefer_identities,623 )624 .await?,625 );626 }627 for k in to_remove {628 config.remove_shared(&k);629 }630 }631 Secret::List {} => {632 let _span = info_span!("loading secrets").entered();633 let configured = config.list_configured_shared().await?;634 #[derive(Tabled)]635 struct SecretDisplay {636 #[tabled(rename = "Name")]637 name: String,638 #[tabled(rename = "Owners")]639 owners: String,640 }641 let mut table = vec![];642 for name in configured.iter().cloned() {643 let config = config.clone();644 let expected_owners = config.shared_secret_expected_owners(&name).await?;645 let data = config.shared_secret(&name)?;646 let owners = data647 .owners648 .iter()649 .map(|o| {650 if expected_owners.contains(o) {651 o.green().to_string()652 } else {653 o.red().to_string()654 }655 })656 .collect::<Vec<_>>();657 table.push(SecretDisplay {658 owners: owners.join(", "),659 name,660 })661 }662 info!("loaded\n{}", Table::new(table).to_string())663 }664 }665 Ok(())666 }667}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::{error::ErrorKind, Parser};10use crossterm::{terminal, tty::IsTty};11use itertools::Itertools;12use owo_colors::OwoColorize;13use serde::Deserialize;14use std::{15 collections::{BTreeSet, HashSet},16 ffi::OsString,17 io::{self, stdin, Cursor, Read, Write},18 path::PathBuf,19};20use tabled::{Table, Tabled};21use tempfile::NamedTempFile;22use tokio::{fs::read_to_string, process::Command};23use tracing::{error, info, info_span, warn, Instrument};2425#[derive(Parser)]26pub enum Secret {27 /// Force load host keys for all defined hosts28 ForceKeys,29 /// Add secret, data should be provided in stdin30 AddShared {31 /// Secret name32 name: String,33 /// Secret owners34 machines: Vec<String>,35 /// Override secret if already present36 #[clap(long)]37 force: bool,38 /// Secret public part39 #[clap(long)]40 public: Option<String>,41 /// Load public part from specified file42 #[clap(long)]43 public_file: Option<PathBuf>,4445 /// Create a notification on secret expiration46 #[clap(long)]47 expires_at: Option<DateTime<Utc>>,4849 /// Secret with this name already exists, override its value while keeping the same owners.50 #[clap(long)]51 re_add: bool,52 },53 /// Add secret, data should be provided in stdin54 Add {55 /// Secret name56 name: String,57 /// Secret owners58 machine: String,59 /// Override secret if already present60 #[clap(long)]61 force: bool,62 #[clap(long)]63 public: Option<String>,64 #[clap(long)]65 public_file: Option<PathBuf>,66 },67 /// Read secret from remote host, requires sudo on said host68 Read {69 name: String,70 machine: String,71 #[clap(long)]72 plaintext: bool,73 },74 ReadPublic {75 name: String,76 machine: String,77 },78 UpdateShared {79 name: String,8081 #[clap(long)]82 machines: Option<Vec<String>>,8384 #[clap(long)]85 add_machines: Vec<String>,86 #[clap(long)]87 remove_machines: Vec<String>,8889 /// Which host should we use to decrypt90 #[clap(long)]91 prefer_identities: Vec<String>,92 },93 Regenerate {94 /// Which host should we use to decrypt, in case if reencryption is required, without95 /// regeneration96 #[clap(long)]97 prefer_identities: Vec<String>,98 },99 List {},100}101102#[tracing::instrument(skip(config, secret, field, prefer_identities))]103async fn update_owner_set(104 secret_name: &str,105 config: &Config,106 mut secret: FleetSharedSecret,107 field: Field,108 updated_set: &[String],109 prefer_identities: &[String],110) -> Result<FleetSharedSecret> {111 let original_set = secret.owners.clone();112113 let set = original_set.iter().collect::<BTreeSet<_>>();114 let expected_set = updated_set.iter().collect::<BTreeSet<_>>();115116 if set == expected_set {117 info!("no need to update owner list, it is already correct");118 return Ok(secret);119 }120121 let should_regenerate = if set.difference(&expected_set).next().is_some() {122 // TODO: Remove this warning for revokable secrets.123 warn!("host was removed from secret owners, but until this host rebuild, the secret will still be stored on it.");124 nix_go_json!(field.regenerateOnOwnerRemoved)125 } else if expected_set.difference(&set).next().is_some() {126 nix_go_json!(field.regenerateOnOwnerAdded)127 } else {128 false129 };130131 if should_regenerate {132 info!("secret is owner-dependent, will regenerate");133 let generated = generate_shared(config, secret_name, field, updated_set.to_vec()).await?;134 Ok(generated)135 } else {136 let identity_holder = if !prefer_identities.is_empty() {137 prefer_identities138 .iter()139 .find(|i| original_set.iter().any(|s| s == *i))140 } else {141 secret.owners.first()142 };143 let Some(identity_holder) = identity_holder else {144 bail!("no available holder found");145 };146147 if let Some(data) = secret.secret.secret {148 let host = config.host(identity_holder).await?;149 let encrypted = host.reencrypt(data, updated_set.to_vec()).await?;150 secret.secret.secret = Some(encrypted);151 }152153 secret.owners = updated_set.to_vec();154 Ok(secret)155 }156}157158#[derive(Deserialize)]159#[serde(rename_all = "camelCase")]160enum GeneratorKind {161 Impure,162 Pure,163}164165async fn generate_pure(166 _config: &Config,167 _display_name: &str,168 _secret: Field,169 _default_generator: Field,170 _owners: &[String],171) -> Result<FleetSecret> {172 bail!("pure generators are broken for now")173}174async fn generate_impure(175 config: &Config,176 _display_name: &str,177 secret: Field,178 default_generator: Field,179 owners: &[String],180) -> Result<FleetSecret> {181 let generator = nix_go!(secret.generator);182 let on: Option<String> = nix_go_json!(default_generator.impureOn);183184 let host = if let Some(on) = &on {185 config.host(on).await?186 } else {187 config.local_host()188 };189 let on_pkgs = host.pkgs().await?;190 let call_package = nix_go!(on_pkgs.callPackage);191 let mk_encrypt_secret = nix_go!(on_pkgs.mkEncryptSecret);192193 let mut recipients = Vec::new();194 for owner in owners {195 let key = config.key(owner).await?;196 recipients.push(key);197 }198 let encrypt = nix_go!(mk_encrypt_secret(Obj {199 recipients: { recipients },200 }));201202 let generator = nix_go!(call_package(generator)(Obj {203 encrypt,204 // rustfmt_please_newline205 }));206207 let generator = generator.build().await?;208 let generator = generator209 .get("out")210 .ok_or_else(|| anyhow!("missing generateImpure out"))?;211 let generator = host.remote_derivation(generator).await?;212213 let out_parent = host.mktemp_dir().await?;214 let out = format!("{out_parent}/out");215216 let mut gen = host.cmd(generator).await?;217 gen.env("out", &out);218 if on.is_none() {219 // This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.220 let project_path: String = config221 .directory222 .clone()223 .into_os_string()224 .into_string()225 .map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;226 gen.env("FLEET_PROJECT", project_path);227 }228 gen.run().await.context("impure generator")?;229230 {231 let marker = host.read_file_text(format!("{out}/marker")).await?;232 ensure!(marker == "SUCCESS", "generation not succeeded");233 }234235 let public = host.read_file_text(format!("{out}/public")).await.ok();236 let secret = host.read_file_bin(format!("{out}/secret")).await.ok();237 if let Some(secret) = &secret {238 ensure!(239 age::Decryptor::new(Cursor::new(&secret)).is_ok(),240 "builder produced non-encrypted value as secret, this is highly insecure, and not allowed."241 );242 }243244 let created_at = host.read_file_value(format!("{out}/created_at")).await?;245 let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();246247 Ok(FleetSecret {248 created_at,249 expires_at,250 public,251 secret: secret.map(SecretData),252 })253}254async fn generate(255 config: &Config,256 display_name: &str,257 secret: Field,258 owners: &[String],259) -> Result<FleetSecret> {260 let generator = nix_go!(secret.generator);261 // Can't properly check on nix module system level262 {263 let gen_ty = generator.type_of().await?;264 if gen_ty == "null" {265 bail!("secret has no generator defined, can't automatically generate it.");266 }267 if gen_ty != "lambda" {268 bail!("generator should be lambda, got {gen_ty}");269 }270 }271 let default_pkgs = &config.default_pkgs;272 let default_call_package = nix_go!(default_pkgs.callPackage);273 // Generators provide additional information in passthru, to access274 // passthru we should call generator, but information about where this generator is supposed to build275 // is located in passthru... Thus evaluating generator on host.276 //277 // Maybe it is also possible to do some magic with __functor?278 //279 // I don't want to make modules always responsible for additional secret data anyway,280 // so it should be in derivation, and not in the secret data itself.281 let default_generator = nix_go!(default_call_package(generator)(Obj {282 encrypt: { "exit 1" },283 // rustfmt_please_newline284 }));285286 let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);287288 match kind {289 GeneratorKind::Impure => {290 generate_impure(config, display_name, secret, default_generator, owners).await291 }292 GeneratorKind::Pure => {293 generate_pure(config, display_name, secret, default_generator, owners).await294 }295 }296}297async fn generate_shared(298 config: &Config,299 display_name: &str,300 secret: Field,301 expected_owners: Vec<String>,302) -> Result<FleetSharedSecret> {303 // let owners: Vec<String> = nix_go_json!(secret.expectedOwners);304 Ok(FleetSharedSecret {305 secret: generate(config, display_name, secret, &expected_owners).await?,306 owners: expected_owners,307 })308}309310async fn parse_public(311 public: Option<String>,312 public_file: Option<PathBuf>,313) -> Result<Option<String>> {314 Ok(match (public, public_file) {315 (Some(v), None) => Some(v),316 (None, Some(v)) => Some(read_to_string(v).await?),317 (Some(_), Some(_)) => {318 bail!("only public or public_file should be set")319 }320 (None, None) => None,321 })322}323324fn parse_machines(325 initial: Vec<String>,326 machines: Option<Vec<String>>,327 mut add_machines: Vec<String>,328 mut remove_machines: Vec<String>,329) -> Result<Vec<String>> {330 if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {331 bail!("no operation");332 }333334 let initial_machines = initial.clone();335 let mut target_machines = initial;336 info!("Currently encrypted for {initial_machines:?}");337338 // ensure!(machines.is_some() || !add_machines.is_empty() || )339 if let Some(machines) = machines {340 ensure!(341 add_machines.is_empty() && remove_machines.is_empty(),342 "can't combine --machines and --add-machines/--remove-machines"343 );344 let target = initial_machines.iter().collect::<HashSet<_>>();345 let source = machines.iter().collect::<HashSet<_>>();346 for removed in target.difference(&source) {347 remove_machines.push((*removed).clone());348 }349 for added in source.difference(&target) {350 add_machines.push((*added).clone());351 }352 }353354 for machine in &remove_machines {355 let mut removed = false;356 while let Some(pos) = target_machines.iter().position(|m| m == machine) {357 target_machines.swap_remove(pos);358 removed = true;359 }360 if !removed {361 warn!("secret is not enabled for {machine}");362 }363 }364 for machine in &add_machines {365 if target_machines.iter().any(|m| m == machine) {366 warn!("secret is already added to {machine}");367 } else {368 target_machines.push(machine.to_owned());369 }370 }371 if !remove_machines.is_empty() {372 // TODO: maybe force secret regeneration?373 // Not that useful without revokation.374 warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");375 }376 Ok(target_machines)377}378impl Secret {379 pub async fn run(self, config: &Config) -> Result<()> {380 match self {381 Secret::ForceKeys => {382 for host in config.list_hosts().await? {383 if config.should_skip(&host.name) {384 continue;385 }386 config.key(&host.name).await?;387 }388 }389 Secret::AddShared {390 mut machines,391 name,392 force,393 public,394 public_file,395 expires_at,396 re_add,397 } => {398 // TODO: Forbid updating secrets with set expectedOwners (= not user-managed).399400 let exists = config.has_shared(&name);401 if exists && !force && !re_add {402 bail!("secret already defined");403 }404 if re_add {405 // Fixme: use clap to limit this usage406 ensure!(!force, "--force and --readd are not compatible");407 ensure!(exists, "secret doesn't exists");408 ensure!(409 machines.is_empty(),410 "you can't use machines argument for --readd"411 );412 let shared = config.shared_secret(&name)?;413 machines = shared.owners;414 }415416 let recipients = config.recipients(machines.clone()).await?;417418 let secret = {419 let mut input = vec![];420 io::stdin().read_to_end(&mut input)?;421422 if input.is_empty() {423 None424 } else {425 Some(426 SecretData::encrypt(recipients, input)427 .ok_or_else(|| anyhow!("no recipients provided"))?,428 )429 }430 };431 let public = parse_public(public, public_file).await?;432 config.replace_shared(433 name,434 FleetSharedSecret {435 owners: machines,436 secret: FleetSecret {437 created_at: Utc::now(),438 expires_at,439 secret,440 public,441 },442 },443 );444 }445 Secret::Add {446 machine,447 name,448 force,449 public,450 public_file,451 } => {452 let recipient = config.recipient(&machine).await?;453454 let secret = {455 let mut input = vec![];456 io::stdin().read_to_end(&mut input)?;457 if input.is_empty() {458 bail!("no data provided")459 }460461 Some(SecretData::encrypt(vec![recipient], input).expect("recipient provided"))462 };463464 if config.has_secret(&machine, &name) && !force {465 bail!("secret already defined");466 }467 let public = parse_public(public, public_file).await?;468469 config.insert_secret(470 &machine,471 name,472 FleetSecret {473 created_at: Utc::now(),474 expires_at: None,475 secret,476 public,477 },478 );479 }480 #[allow(clippy::await_holding_refcell_ref)]481 Secret::Read {482 name,483 machine,484 plaintext,485 } => {486 let secret = config.host_secret(&machine, &name)?;487 let Some(secret) = secret.secret else {488 bail!("no secret {name}");489 };490 let host = config.host(&machine).await?;491 let data = host.decrypt(secret).await?;492 if plaintext {493 let s = String::from_utf8(data).context("output is not utf8")?;494 print!("{s}");495 } else {496 println!("{}", z85::encode(&data));497 }498 }499 Secret::ReadPublic { name, machine } => {500 let secret = config.host_secret(&machine, &name)?;501 let Some(public) = secret.public else {502 bail!("no secret {name}");503 };504 print!("{public}");505 }506 Secret::UpdateShared {507 name,508 machines,509 add_machines,510 remove_machines,511 prefer_identities,512 } => {513 // TODO: Forbid updating secrets with set expectedOwners (= not user-managed).514515 let secret = config.shared_secret(&name)?;516 if secret.secret.secret.is_none() {517 bail!("no secret");518 }519520 let initial_machines = secret.owners.clone();521 let target_machines = parse_machines(522 initial_machines.clone(),523 machines,524 add_machines,525 remove_machines,526 )?;527528 if target_machines.is_empty() {529 info!("no machines left for secret, removing it");530 config.remove_shared(&name);531 return Ok(());532 }533534 let config_field = &config.config_unchecked_field;535 let field = nix_go!(config_field.sharedSecrets[{ name }]);536537 let updated = update_owner_set(538 &name,539 config,540 secret,541 field,542 &target_machines,543 &prefer_identities,544 )545 .await?;546 config.replace_shared(name, updated);547 }548 Secret::Regenerate { prefer_identities } => {549 info!("checking for secrets to regenerate");550 {551 let _span = info_span!("shared").entered();552 let expected_shared_set = config553 .list_configured_shared()554 .await?555 .into_iter()556 .collect::<HashSet<_>>();557 let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();558 for missing in expected_shared_set.difference(&shared_set) {559 let config_field = &config.config_unchecked_field;560 let secret = nix_go!(config_field.sharedSecrets[{ missing }]);561 let expected_owners: Option<Vec<String>> =562 nix_go_json!(secret.expectedOwners);563 let Some(expected_owners) = expected_owners else {564 // TODO: Might still need to regenerate565 continue;566 };567 info!("generating secret: {missing}");568 let shared = generate_shared(config, missing, secret, expected_owners)569 .in_current_span()570 .await?;571 config.replace_shared(missing.to_string(), shared)572 }573 }574 for host in config.list_hosts().await? {575 let _span = info_span!("host", host = host.name).entered();576 let expected_set = host577 .list_configured_secrets()578 .in_current_span()579 .await?580 .into_iter()581 .collect::<HashSet<_>>();582 let stored_set = config583 .list_secrets(&host.name)584 .into_iter()585 .collect::<HashSet<_>>();586 for missing in expected_set.difference(&stored_set) {587 info!("generating secret: {missing}");588 let secret = host.secret_field(missing).in_current_span().await?;589 let generated =590 match generate(config, missing, secret, &[host.name.clone()])591 .in_current_span()592 .await593 {594 Ok(v) => v,595 Err(e) => {596 error!("{e:?}");597 continue;598 }599 };600 config.insert_secret(&host.name, missing.to_string(), generated)601 }602 }603 let mut to_remove = Vec::new();604 for name in &config.list_shared() {605 info!("updating secret: {name}");606 let data = config.shared_secret(name)?;607 let config_field = &config.config_unchecked_field;608 let expected_owners: Vec<String> =609 nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);610 if expected_owners.is_empty() {611 warn!("secret was removed from fleet config: {name}, removing from data");612 to_remove.push(name.to_string());613 continue;614 }615616 let secret = nix_go!(config_field.sharedSecrets[{ name }]);617 config.replace_shared(618 name.to_owned(),619 update_owner_set(620 name,621 config,622 data,623 secret,624 &expected_owners,625 &prefer_identities,626 )627 .await?,628 );629 }630 for k in to_remove {631 config.remove_shared(&k);632 }633 }634 Secret::List {} => {635 let _span = info_span!("loading secrets").entered();636 let configured = config.list_configured_shared().await?;637 #[derive(Tabled)]638 struct SecretDisplay {639 #[tabled(rename = "Name")]640 name: String,641 #[tabled(rename = "Owners")]642 owners: String,643 }644 let mut table = vec![];645 for name in configured.iter().cloned() {646 let config = config.clone();647 let expected_owners = config.shared_secret_expected_owners(&name).await?;648 let data = config.shared_secret(&name)?;649 let owners = data650 .owners651 .iter()652 .map(|o| {653 if expected_owners.contains(o) {654 o.green().to_string()655 } else {656 o.red().to_string()657 }658 })659 .collect::<Vec<_>>();660 table.push(SecretDisplay {661 owners: owners.join(", "),662 name,663 })664 }665 info!("loaded\n{}", Table::new(table).to_string())666 }667 }668 Ok(())669 }670}