difftreelog
refactor prepare batching for secrets
in: trunk
1 file changed
cmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth1use std::{2 collections::{BTreeMap, BTreeSet, HashSet},3 io::{self, stdin, stdout, Read, Write},4 path::PathBuf,5};67use anyhow::{anyhow, bail, ensure, Context, Result};8use chrono::{DateTime, Utc};9use clap::Parser;10use fleet_base::{11 fleetdata::{encrypt_secret_data, FleetSecret, FleetSecretPart, FleetSharedSecret},12 host::Config,13 opts::FleetOpts,14};15use fleet_shared::SecretData;16use nix_eval::{nix_go, nix_go_json, Value};17use owo_colors::OwoColorize;18use serde::Deserialize;19use tabled::{Table, Tabled};20use tokio::fs::read;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 #[clap(long, short)]33 machines: Vec<String>,34 /// Override secret if already present35 #[clap(long)]36 force: bool,37 /// Secret public part38 #[clap(long)]39 public: Option<String>,40 /// Load public part from specified file41 #[clap(long)]42 public_file: Option<PathBuf>,4344 /// Create a notification on secret expiration45 #[clap(long)]46 expires_at: Option<DateTime<Utc>>,4748 /// Secret with this name already exists, override its value while keeping the same owners.49 #[clap(long)]50 re_add: bool,5152 /// How to name public secret part53 #[clap(long, short = 'p', default_value = "public")]54 public_part: String,55 /// How to name private secret part56 #[clap(short = 's', long, default_value = "secret")]57 part: String,58 },59 /// Add secret, data should be provided in stdin60 Add {61 /// Secret name62 name: String,63 /// Secret owner64 #[clap(short = 'm', long)]65 machine: String,66 /// Replace secret if already present67 #[clap(long)]68 replace: bool,69 /// Add new parts to existing secret70 #[clap(long)]71 merge: bool,72 /// Secret public part73 #[clap(long)]74 public: Option<String>,75 /// Load public part from specified file76 #[clap(long)]77 public_file: Option<PathBuf>,7879 /// How to name public secret part80 #[clap(short = 'p', long, default_value = "public")]81 public_part: String,82 /// How to name private secret part83 #[clap(short = 's', long, default_value = "secret")]84 part: String,85 },86 /// Read secret from remote host, requires sudo on said host87 Read {88 name: String,89 #[clap(short = 'm', long)]90 machine: String,9192 /// Which private secret part to read93 #[clap(short = 'p', long, default_value = "secret")]94 part: String,95 },96 UpdateShared {97 name: String,9899 #[clap(short = 'm', long)]100 machine: Option<Vec<String>>,101102 #[clap(long)]103 add_machine: Vec<String>,104 #[clap(long)]105 remove_machine: Vec<String>,106107 /// Which host should we use to decrypt108 #[clap(long)]109 prefer_identities: Vec<String>,110 },111 Regenerate {112 /// Which host should we use to decrypt, in case if reencryption is required, without113 /// regeneration114 #[clap(long)]115 prefer_identities: Vec<String>,116 },117 List {},118 Edit {119 name: String,120 #[clap(short = 'm', long)]121 machine: String,122123 #[clap(long)]124 add: bool,125126 /// Which private secret part to read127 #[clap(short = 'p', long, default_value = "secret")]128 part: String,129 },130}131132#[tracing::instrument(skip(config, secret, field, prefer_identities))]133async fn update_owner_set(134 secret_name: &str,135 config: &Config,136 mut secret: FleetSharedSecret,137 field: Value,138 updated_set: &[String],139 prefer_identities: &[String],140) -> Result<FleetSharedSecret> {141 let original_set = secret.owners.clone();142143 let set = original_set.iter().collect::<BTreeSet<_>>();144 let expected_set = updated_set.iter().collect::<BTreeSet<_>>();145146 if set == expected_set {147 info!("no need to update owner list, it is already correct");148 return Ok(secret);149 }150151 let should_regenerate = if set.difference(&expected_set).next().is_some() {152 // TODO: Remove this warning for revokable secrets.153 warn!("host was removed from secret owners, but until this host rebuild, the secret will still be stored on it.");154 nix_go_json!(field.regenerateOnOwnerRemoved)155 } else if expected_set.difference(&set).next().is_some() {156 nix_go_json!(field.regenerateOnOwnerAdded)157 } else {158 false159 };160161 if should_regenerate {162 info!("secret is owner-dependent, will regenerate");163 let generated = generate_shared(config, secret_name, field, updated_set.to_vec()).await?;164 Ok(generated)165 } else {166 let identity_holder = if !prefer_identities.is_empty() {167 prefer_identities168 .iter()169 .find(|i| original_set.iter().any(|s| s == *i))170 } else {171 secret.owners.first()172 };173 let Some(identity_holder) = identity_holder else {174 bail!("no available holder found");175 };176177 for (part_name, part) in secret.secret.parts.iter_mut() {178 let _span = info_span!("part reencryption", part_name);179 if !part.raw.encrypted {180 continue;181 }182 let host = config.host(identity_holder).await?;183 let encrypted = host184 .reencrypt(part.raw.clone(), updated_set.to_vec())185 .await?;186 part.raw = encrypted;187 }188189 secret.owners = updated_set.to_vec();190 Ok(secret)191 }192}193194#[derive(Deserialize)]195#[serde(rename_all = "camelCase")]196enum GeneratorKind {197 Impure,198 Pure,199}200201async fn generate_pure(202 _config: &Config,203 _display_name: &str,204 _secret: Value,205 _default_generator: Value,206 _owners: &[String],207) -> Result<FleetSecret> {208 bail!("pure generators are broken for now")209}210async fn generate_impure(211 config: &Config,212 _display_name: &str,213 secret: Value,214 default_generator: Value,215 owners: &[String],216) -> Result<FleetSecret> {217 let generator = nix_go!(secret.generator);218 let on: Option<String> = nix_go_json!(default_generator.impureOn);219220 let host = if let Some(on) = &on {221 config.host(on).await?222 } else {223 config.local_host()224 };225 let on_pkgs = host.pkgs().await?;226 let call_package = nix_go!(on_pkgs.callPackage);227 let mk_secret_generators = nix_go!(on_pkgs.mkSecretGenerators);228229 let mut recipients = Vec::new();230 for owner in owners {231 let key = config.key(owner).await?;232 recipients.push(key);233 }234 let generators = nix_go!(mk_secret_generators(Obj { recipients }));235236 let generator = nix_go!(call_package(generator)(generators));237238 let generator = generator.build().await?;239 let generator = generator240 .get("out")241 .ok_or_else(|| anyhow!("missing generateImpure out"))?;242 let generator = host.remote_derivation(generator).await?;243244 let out_parent = host.mktemp_dir().await?;245 let out = format!("{out_parent}/out");246247 let mut gen = host.cmd(generator).await?;248 gen.env("out", &out);249 if on.is_none() {250 // This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.251 let project_path: String = config252 .directory253 .clone()254 .into_os_string()255 .into_string()256 .map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;257 gen.env("FLEET_PROJECT", project_path);258 }259 gen.run().await.context("impure generator")?;260261 {262 let marker = host.read_file_text(format!("{out}/marker")).await?;263 ensure!(marker == "SUCCESS", "generation not succeeded");264 }265266 let mut parts = BTreeMap::new();267 for part in host.read_dir(&out).await? {268 if part == "created_at" || part == "expired_at" || part == "marker" {269 continue;270 }271 let contents: SecretData = host272 .read_file_text(format!("{out}/{part}"))273 .await?274 .parse()275 .map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;276 parts.insert(part.to_owned(), FleetSecretPart { raw: contents });277 }278279 let created_at = host.read_file_value(format!("{out}/created_at")).await?;280 let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();281282 Ok(FleetSecret {283 created_at,284 expires_at,285 parts,286 })287}288async fn generate(289 config: &Config,290 display_name: &str,291 secret: Value,292 owners: &[String],293) -> Result<FleetSecret> {294 let generator = nix_go!(secret.generator);295 // Can't properly check on nix module system level296 {297 let gen_ty = generator.type_of().await?;298 if gen_ty == "null" {299 bail!("secret has no generator defined, can't automatically generate it.");300 }301 if gen_ty != "lambda" {302 bail!("generator should be lambda, got {gen_ty}");303 }304 }305 let default_pkgs = &config.default_pkgs;306 let default_call_package = nix_go!(default_pkgs.callPackage);307 let default_mk_secret_generators = nix_go!(default_pkgs.mkSecretGenerators);308 // Generators provide additional information in passthru, to access309 // passthru we should call generator, but information about where this generator is supposed to build310 // is located in passthru... Thus evaluating generator on host.311 //312 // Maybe it is also possible to do some magic with __functor?313 //314 // I don't want to make modules always responsible for additional secret data anyway,315 // so it should be in derivation, and not in the secret data itself.316 let generators = nix_go!(default_mk_secret_generators(Obj {317 recipients: <Vec<String>>::new(),318 }));319 let default_generator = nix_go!(default_call_package(generator)(generators));320321 let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);322323 match kind {324 GeneratorKind::Impure => {325 generate_impure(config, display_name, secret, default_generator, owners).await326 }327 GeneratorKind::Pure => {328 generate_pure(config, display_name, secret, default_generator, owners).await329 }330 }331}332async fn generate_shared(333 config: &Config,334 display_name: &str,335 secret: Value,336 expected_owners: Vec<String>,337) -> Result<FleetSharedSecret> {338 // let owners: Vec<String> = nix_go_json!(secret.expectedOwners);339 Ok(FleetSharedSecret {340 secret: generate(config, display_name, secret, &expected_owners).await?,341 owners: expected_owners,342 })343}344345async fn parse_public(346 public: Option<String>,347 public_file: Option<PathBuf>,348) -> Result<Option<SecretData>> {349 Ok(match (public, public_file) {350 (Some(v), None) => Some(SecretData {351 data: v.into(),352 encrypted: false,353 }),354 (None, Some(v)) => Some(SecretData {355 data: read(v).await?,356 encrypted: false,357 }),358 (Some(_), Some(_)) => {359 bail!("only public or public_file should be set")360 }361 (None, None) => None,362 })363}364365async fn parse_secret() -> Result<Option<Vec<u8>>> {366 let mut input = vec![];367 stdin().read_to_end(&mut input)?;368 if input.is_empty() {369 Ok(None)370 } else {371 Ok(Some(input))372 }373}374375fn parse_machines(376 initial: Vec<String>,377 machines: Option<Vec<String>>,378 mut add_machines: Vec<String>,379 mut remove_machines: Vec<String>,380) -> Result<Vec<String>> {381 if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {382 bail!("no operation");383 }384385 let initial_machines = initial.clone();386 let mut target_machines = initial;387 info!("Currently encrypted for {initial_machines:?}");388389 // ensure!(machines.is_some() || !add_machines.is_empty() || )390 if let Some(machines) = machines {391 ensure!(392 add_machines.is_empty() && remove_machines.is_empty(),393 "can't combine --machines and --add-machines/--remove-machines"394 );395 let target = initial_machines.iter().collect::<HashSet<_>>();396 let source = machines.iter().collect::<HashSet<_>>();397 for removed in target.difference(&source) {398 remove_machines.push((*removed).clone());399 }400 for added in source.difference(&target) {401 add_machines.push((*added).clone());402 }403 }404405 for machine in &remove_machines {406 let mut removed = false;407 while let Some(pos) = target_machines.iter().position(|m| m == machine) {408 target_machines.swap_remove(pos);409 removed = true;410 }411 if !removed {412 warn!("secret is not enabled for {machine}");413 }414 }415 for machine in &add_machines {416 if target_machines.iter().any(|m| m == machine) {417 warn!("secret is already added to {machine}");418 } else {419 target_machines.push(machine.to_owned());420 }421 }422 if !remove_machines.is_empty() {423 // TODO: maybe force secret regeneration?424 // Not that useful without revokation.425 warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");426 }427 Ok(target_machines)428}429impl Secret {430 pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {431 match self {432 Secret::ForceKeys => {433 for host in config.list_hosts().await? {434 if opts.should_skip(&host).await? {435 continue;436 }437 config.key(&host.name).await?;438 }439 }440 Secret::AddShared {441 mut machines,442 name,443 force,444 public,445 public_part: public_name,446 public_file,447 expires_at,448 re_add,449 part: part_name,450 } => {451 // TODO: Forbid updating secrets with set expectedOwners (= not user-managed).452453 let exists = config.has_shared(&name);454 if exists && !force && !re_add {455 bail!("secret already defined");456 }457 if re_add {458 // Fixme: use clap to limit this usage459 ensure!(!force, "--force and --readd are not compatible");460 ensure!(exists, "secret doesn't exists");461 ensure!(462 machines.is_empty(),463 "you can't use machines argument for --readd"464 );465 let shared = config.shared_secret(&name)?;466 machines = shared.owners;467 }468469 let recipients = config.recipients(machines.clone()).await?;470471 let mut parts = BTreeMap::new();472473 let mut input = vec![];474 io::stdin().read_to_end(&mut input)?;475476 if !input.is_empty() {477 let encrypted = encrypt_secret_data(recipients, input)478 .ok_or_else(|| anyhow!("no recipients provided"))?;479 parts.insert(part_name, FleetSecretPart { raw: encrypted });480 }481482 if let Some(public) = parse_public(public, public_file).await? {483 parts.insert(public_name, FleetSecretPart { raw: public });484 }485486 config.replace_shared(487 name,488 FleetSharedSecret {489 owners: machines,490 secret: FleetSecret {491 created_at: Utc::now(),492 expires_at,493 parts,494 },495 },496 );497 }498 Secret::Add {499 machine,500 name,501 replace,502 merge,503 public,504 public_part: public_name,505 public_file,506 part: part_name,507 } => {508 if config.has_secret(&machine, &name) && !replace && !merge {509 bail!("secret already defined.\nUse --replace to override, or --merge to add new parts to existing secret");510 }511512 let mut out = if merge && !replace {513 config514 .host_secret(&machine, &name)515 .context("failed to read existing secret for --merge")?516 } else {517 FleetSecret {518 created_at: Utc::now(),519 expires_at: None,520 parts: BTreeMap::new(),521 }522 };523524 if let Some(secret) = parse_secret().await? {525 let recipient = config.recipient(&machine).await?;526 let encrypted =527 encrypt_secret_data(vec![recipient], secret).expect("recipient provided");528 if out529 .parts530 .insert(part_name.clone(), FleetSecretPart { raw: encrypted })531 .is_some() && !replace532 {533 bail!("part {part_name:?} is already defined");534 }535 }536537 if let Some(public) = parse_public(public, public_file).await? {538 if out539 .parts540 .insert(public_name.clone(), FleetSecretPart { raw: public })541 .is_some() && !replace542 {543 bail!("part {public_name:?} is already defined");544 }545 };546547 config.insert_secret(&machine, name, out);548 }549 #[allow(clippy::await_holding_refcell_ref)]550 Secret::Read {551 name,552 machine,553 part: part_name,554 } => {555 let secret = config.host_secret(&machine, &name)?;556 let Some(secret) = secret.parts.get(&part_name) else {557 bail!("no part {part_name} in secret {name}");558 };559 let data = if secret.raw.encrypted {560 let host = config.host(&machine).await?;561 host.decrypt(secret.raw.clone()).await?562 } else {563 secret.raw.data.clone()564 };565566 stdout().write_all(&data)?;567 }568 Secret::UpdateShared {569 name,570 machine,571 add_machine,572 remove_machine,573 prefer_identities,574 } => {575 // TODO: Forbid updating secrets with set expectedOwners (= not user-managed).576577 let secret = config.shared_secret(&name)?;578 if secret.secret.parts.values().all(|v| !v.raw.encrypted) {579 bail!("no secret");580 }581582 let initial_machines = secret.owners.clone();583 let target_machines = parse_machines(584 initial_machines.clone(),585 machine,586 add_machine,587 remove_machine,588 )?;589590 if target_machines.is_empty() {591 info!("no machines left for secret, removing it");592 config.remove_shared(&name);593 return Ok(());594 }595596 let config_field = &config.config_field;597 let field = nix_go!(config_field.sharedSecrets[{ name }]);598599 let updated = update_owner_set(600 &name,601 config,602 secret,603 field,604 &target_machines,605 &prefer_identities,606 )607 .await?;608 config.replace_shared(name, updated);609 }610 Secret::Regenerate { prefer_identities } => {611 info!("checking for secrets to regenerate");612 {613 let _span = info_span!("shared").entered();614 let expected_shared_set = config615 .list_configured_shared()616 .await?617 .into_iter()618 .collect::<HashSet<_>>();619 let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();620 for missing in expected_shared_set.difference(&shared_set) {621 let config_field = &config.config_field;622 let secret = nix_go!(config_field.sharedSecrets[{ missing }]);623 let expected_owners: Option<Vec<String>> =624 nix_go_json!(secret.expectedOwners);625 let Some(expected_owners) = expected_owners else {626 // TODO: Might still need to regenerate627 continue;628 };629 info!("generating secret: {missing}");630 let shared = generate_shared(config, missing, secret, expected_owners)631 .in_current_span()632 .await?;633 config.replace_shared(missing.to_string(), shared)634 }635 }636 for host in config.list_hosts().await? {637 if opts.should_skip(&host).await? {638 continue;639 }640641 let _span = info_span!("host", host = host.name).entered();642 let expected_set = host643 .list_configured_secrets()644 .in_current_span()645 .await?646 .into_iter()647 .collect::<HashSet<_>>();648 let stored_set = config649 .list_secrets(&host.name)650 .into_iter()651 .collect::<HashSet<_>>();652 for missing in expected_set.difference(&stored_set) {653 info!("generating secret: {missing}");654 let secret = host.secret_field(missing).in_current_span().await?;655 let generated =656 match generate(config, missing, secret, &[host.name.clone()])657 .in_current_span()658 .await659 {660 Ok(v) => v,661 Err(e) => {662 error!("{e:?}");663 continue;664 }665 };666 config.insert_secret(&host.name, missing.to_string(), generated)667 }668 }669 let mut to_remove = Vec::new();670 for name in &config.list_shared() {671 info!("updating secret: {name}");672 let data = config.shared_secret(name)?;673 let config_field = &config.config_field;674 let expected_owners: Vec<String> =675 nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);676 if expected_owners.is_empty() {677 warn!("secret was removed from fleet config: {name}, removing from data");678 to_remove.push(name.to_string());679 continue;680 }681682 let secret = nix_go!(config_field.sharedSecrets[{ name }]);683 config.replace_shared(684 name.to_owned(),685 update_owner_set(686 name,687 config,688 data,689 secret,690 &expected_owners,691 &prefer_identities,692 )693 .await?,694 );695 }696 for k in to_remove {697 config.remove_shared(&k);698 }699 }700 Secret::List {} => {701 let _span = info_span!("loading secrets").entered();702 let configured = config.list_configured_shared().await?;703 #[derive(Tabled)]704 struct SecretDisplay {705 #[tabled(rename = "Name")]706 name: String,707 #[tabled(rename = "Owners")]708 owners: String,709 }710 let mut table = vec![];711 for name in configured.iter().cloned() {712 let config = config.clone();713 let expected_owners = config.shared_secret_expected_owners(&name).await?;714 let data = config.shared_secret(&name)?;715 let owners = data716 .owners717 .iter()718 .map(|o| {719 if expected_owners.contains(o) {720 o.green().to_string()721 } else {722 o.red().to_string()723 }724 })725 .collect::<Vec<_>>();726 table.push(SecretDisplay {727 owners: owners.join(", "),728 name,729 })730 }731 info!("loaded\n{}", Table::new(table).to_string())732 }733 Secret::Edit {734 name,735 machine,736 part,737 add,738 } => {739 let secret = config.host_secret(&machine, &name)?;740 if let Some(data) = secret.parts.get(&part) {741 let host = config.host(&machine).await?;742 let secret = host.decrypt(data.raw.clone()).await?;743 String::from_utf8(secret).context("secret is not utf8")?744 } else if add {745 String::new()746 } else {747 bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");748 };749 }750 }751 Ok(())752 }753}754755/*756async fn edit_temp_file(757 builder: tempfile::Builder<'_, '_>,758 r: Vec<u8>,759 header: &str,760 comment: &str,761) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {762 if !stdin().is_tty() {763 // TODO: Also try to open /dev/tty directly?764 bail!("stdin is not tty, can't open editor");765 }766767 use std::fmt::Write;768 let mut file = builder.tempfile()?;769770 let mut full_header = String::new();771 let mut had = false;772 for line in header.trim_end().lines() {773 had = true;774 writeln!(&mut full_header, "{comment}{line}")?;775 }776 if had {777 writeln!(&mut full_header, "{}", comment.trim_end())?;778 }779 writeln!(780 &mut full_header,781 "{comment}Do not touch this header! It will be removed automatically"782 )?;783784 file.write_all(full_header.as_bytes())?;785 file.write_all(&r)?;786787 let abs_path = file.into_temp_path();788 let editor = std::env::var_os("VISUAL")789 .or_else(|| std::env::var_os("EDITOR"))790 .unwrap_or_else(|| "vi".into());791 let editor_args = shlex::bytes::split(editor.as_encoded_bytes())792 .ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;793 let editor_args = editor_args794 .into_iter()795 .map(|v| {796 // Only ASCII subsequences are replaced797 unsafe { OsString::from_encoded_bytes_unchecked(v) }798 })799 .collect_vec();800 let Some((editor, args)) = editor_args.split_first() else {801 bail!("EDITOR env var has no command");802 };803 let mut command = Command::new(editor);804 command.args(args);805806 let path_arg = abs_path.canonicalize()?;807808 // TODO: Save full state, using tcget/_getmode/_setmode809 let was_raw = terminal::is_raw_mode_enabled()?;810 terminal::enable_raw_mode()?;811812 let status = command.arg(path_arg).status().await;813814 if !was_raw {815 terminal::disable_raw_mode()?;816 }817818 let success = match status {819 Ok(s) => s.success(),820 Err(e) if e.kind() == io::ErrorKind::NotFound => {821 bail!("editor not found")822 }823 Err(e) => bail!("editor spawn error: {e}"),824 };825826 let mut file = std::fs::read(&abs_path).context("read editor output")?;827 let Some(v) = file.strip_prefix(full_header.as_bytes()) else {828 todo!();829 };830 todo!();831832 // Ok((success, abs_path))833}834*/