1use std::{2 collections::{BTreeMap, BTreeSet, HashSet},3 io::{self, Read, Write, stdin, stdout},4 path::PathBuf,5};67use age::Recipient;8use anyhow::{Context, Result, anyhow, bail, ensure};9use chrono::{DateTime, Utc};10use clap::Parser;11use fleet_base::{12 fleetdata::{FleetSecret, FleetSecretPart, FleetSharedSecret, encrypt_secret_data},13 host::Config,14 opts::FleetOpts,15};16use fleet_shared::SecretData;17use nix_eval::{NixBuildBatch, Value, nix_go, nix_go_json};18use owo_colors::OwoColorize;19use serde::Deserialize;20use tabled::{Table, Tabled};21use tokio::fs::read;22use tracing::{Instrument, error, info, info_span, warn};2324#[derive(Parser)]25pub enum Secret {26 27 ForceKeys,28 29 AddShared {30 31 name: String,32 33 #[clap(long, short)]34 machines: Vec<String>,35 36 #[clap(long)]37 force: bool,38 39 #[clap(long)]40 public: Option<String>,41 42 #[clap(long)]43 public_file: Option<PathBuf>,4445 46 #[clap(long)]47 expires_at: Option<DateTime<Utc>>,4849 50 #[clap(long)]51 re_add: bool,5253 54 #[clap(long, short = 'p', default_value = "public")]55 public_part: String,56 57 #[clap(short = 's', long, default_value = "secret")]58 part: String,59 },60 61 Add {62 63 name: String,64 65 #[clap(short = 'm', long)]66 machine: String,67 68 #[clap(long)]69 replace: bool,70 71 #[clap(long)]72 merge: bool,73 74 #[clap(long)]75 public: Option<String>,76 77 #[clap(long)]78 public_file: Option<PathBuf>,7980 81 #[clap(short = 'p', long, default_value = "public")]82 public_part: String,83 84 #[clap(short = 's', long, default_value = "secret")]85 part: String,86 },87 88 Read {89 name: String,90 #[clap(short = 'm', long)]91 machine: String,9293 94 #[clap(short = 'p', long, default_value = "secret")]95 part: String,96 },97 98 ReadShared {99 name: String,100 101 #[clap(short = 'p', long, default_value = "secret")]102 part: String,103 104 105 #[clap(long)]106 prefer_identities: Vec<String>,107 },108 UpdateShared {109 name: String,110111 #[clap(short = 'm', long)]112 machine: Option<Vec<String>>,113114 #[clap(long)]115 add_machine: Vec<String>,116 #[clap(long)]117 remove_machine: Vec<String>,118119 120 #[clap(long)]121 prefer_identities: Vec<String>,122 },123 Regenerate {124 125 126 #[clap(long)]127 prefer_identities: Vec<String>,128 129 #[clap(long)]130 skip_hosts: bool,131 },132 List {},133 Edit {134 name: String,135 #[clap(short = 'm', long)]136 machine: String,137138 #[clap(long)]139 add: bool,140141 142 #[clap(short = 'p', long, default_value = "secret")]143 part: String,144 },145}146147fn secret_needs_regeneration(148 secret: &FleetSecret,149 expected_generation_data: &serde_json::Value,150) -> bool {151 let data_is_expected = secret.generation_data == *expected_generation_data;152 153 let expired = secret154 .expires_at155 .map(|expiration| expiration < Utc::now())156 .unwrap_or(false);157 expired || !data_is_expected158}159160#[allow(clippy::too_many_arguments)]161#[tracing::instrument(skip(config, secret, field, prefer_identities, batch))]162async fn maybe_regenerate_shared_secret(163 secret_name: &str,164 config: &Config,165 mut secret: FleetSharedSecret,166 field: Value,167 expected_owners: &[String],168 expected_generation_data: serde_json::Value,169 prefer_identities: &[String],170 batch: Option<NixBuildBatch>,171) -> Result<FleetSharedSecret> {172 let original_set = secret.owners.clone();173174 let set = original_set.iter().collect::<BTreeSet<_>>();175 let expected_set = expected_owners.iter().collect::<BTreeSet<_>>();176177 let regeneration_required =178 secret_needs_regeneration(&secret.secret, &expected_generation_data);179180 if set == expected_set && !regeneration_required {181 info!("no need to update owner list, it is already correct");182 return Ok(secret);183 }184185 let should_regenerate = if regeneration_required {186 info!("secret has its generation data changed, regeneration is required");187 true188 } else if set.difference(&expected_set).next().is_some() {189 190 warn!(191 "host was removed from secret owners, but until this host rebuild, the secret will still be stored on it."192 );193 nix_go_json!(field.regenerateOnOwnerRemoved)194 } else if expected_set.difference(&set).next().is_some() {195 nix_go_json!(field.regenerateOnOwnerAdded)196 } else {197 false198 };199200 if should_regenerate {201 info!("secret needs to be regenerated");202 let generated = generate_shared(203 config,204 secret_name,205 field,206 expected_owners.to_vec(),207 expected_generation_data,208 batch,209 )210 .await?;211 Ok(generated)212 } else {213 drop(batch);214 let identity_holder = if !prefer_identities.is_empty() {215 prefer_identities216 .iter()217 .find(|i| original_set.iter().any(|s| s == *i))218 } else {219 secret.owners.first()220 };221 let Some(identity_holder) = identity_holder else {222 bail!("no available holder found");223 };224225 for (part_name, part) in secret.secret.parts.iter_mut() {226 let _span = info_span!("part reencryption", part_name);227 if !part.raw.encrypted {228 continue;229 }230 let host = config.host(identity_holder).await?;231 let encrypted = host232 .reencrypt(part.raw.clone(), expected_owners.to_vec())233 .await?;234 part.raw = encrypted;235 }236237 secret.owners = expected_owners.to_vec();238 Ok(secret)239 }240}241242#[derive(Deserialize)]243#[serde(rename_all = "camelCase")]244enum GeneratorKind {245 Impure,246 Pure,247}248249async fn generate_pure(250 _config: &Config,251 _display_name: &str,252 _secret: Value,253 _default_generator: Value,254 _owners: &[String],255) -> Result<FleetSecret> {256 bail!("pure generators are broken for now")257}258async fn generate_impure(259 config: &Config,260 _display_name: &str,261 secret: Value,262 default_generator: Value,263 expected_owners: &[String],264 expected_generation_data: serde_json::Value,265 batch: Option<NixBuildBatch>,266) -> Result<FleetSecret> {267 let generator = nix_go!(secret.generator);268 let on: Option<String> = nix_go_json!(default_generator.impureOn);269270 let nixpkgs = &config.nixpkgs;271272 let host = if let Some(on) = &on {273 config.host(on).await?274 } else {275 config.local_host()276 };277 let on_pkgs = host.pkgs().await?;278 let mk_secret_generators = nix_go!(on_pkgs.mkSecretGenerators);279280 let mut recipients = Vec::new();281 for owner in expected_owners {282 let key = config.key(owner).await?;283 recipients.push(key);284 }285 let generators = nix_go!(mk_secret_generators(Obj { recipients }));286 let pkgs_and_generators = nix_go!(on_pkgs + generators);287288 let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));289290 let generator = nix_go!(call_package(generator)(Obj {}));291292 let generator = generator.build_maybe_batch(batch).await?;293 let generator = generator294 .get("out")295 .ok_or_else(|| anyhow!("missing generateImpure out"))?;296 let generator = host.remote_derivation(generator).await?;297298 let out_parent = host.mktemp_dir().await?;299 let out = format!("{out_parent}/out");300301 let mut r#gen = host.cmd(generator).await?;302 r#gen.env("out", &out);303 if on.is_none() {304 305 let project_path: String = config306 .directory307 .clone()308 .into_os_string()309 .into_string()310 .map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;311 r#gen.env("FLEET_PROJECT", project_path);312 }313 r#gen.run().await.context("impure generator")?;314315 {316 let marker = host.read_file_text(format!("{out}/marker")).await?;317 ensure!(marker == "SUCCESS", "generation not succeeded");318 }319320 let mut parts = BTreeMap::new();321 for part in host.read_dir(&out).await? {322 if part == "created_at" || part == "expires_at" || part == "marker" {323 continue;324 }325 let contents: SecretData = host326 .read_file_text(format!("{out}/{part}"))327 .await?328 .parse()329 .map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;330 parts.insert(part.to_owned(), FleetSecretPart { raw: contents });331 }332333 let created_at = host.read_file_value(format!("{out}/created_at")).await?;334 let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();335336 Ok(FleetSecret {337 created_at,338 expires_at,339 parts,340 generation_data: expected_generation_data,341 })342}343async fn generate(344 config: &Config,345 display_name: &str,346 secret: Value,347 expected_owners: &[String],348 expected_generation_data: serde_json::Value,349 batch: Option<NixBuildBatch>,350) -> Result<FleetSecret> {351 let generator = nix_go!(secret.generator);352 353 {354 let gen_ty = generator.type_of().await?;355 if gen_ty == "null" {356 bail!("secret has no generator defined, can't automatically generate it.");357 }358 if gen_ty == "set" {359 if !generator.has_field("__functor").await? {360 bail!("generator should be functor, got {gen_ty}");361 }362 } else if gen_ty != "lambda" {363 bail!("generator should be functor, got {gen_ty}");364 }365 }366 let nixpkgs = &config.nixpkgs;367 let default_pkgs = &config.default_pkgs;368 let default_mk_secret_generators = nix_go!(default_pkgs.mkSecretGenerators);369 370 371 372 373 374 375 376 377 let generators = nix_go!(default_mk_secret_generators(Obj {378 recipients: <Vec<String>>::new(),379 }));380 let pkgs_and_generators = nix_go!(default_pkgs + generators);381382 let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));383 let default_generator = nix_go!(call_package(generator)(Obj {}));384385 let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);386387 match kind {388 GeneratorKind::Impure => {389 generate_impure(390 config,391 display_name,392 secret,393 default_generator,394 expected_owners,395 expected_generation_data,396 batch,397 )398 .await399 }400 GeneratorKind::Pure => {401 generate_pure(402 config,403 display_name,404 secret,405 default_generator,406 expected_owners,407 )408 .await409 }410 }411}412async fn generate_shared(413 config: &Config,414 display_name: &str,415 secret: Value,416 expected_owners: Vec<String>,417 expected_generation_data: serde_json::Value,418 batch: Option<NixBuildBatch>,419) -> Result<FleetSharedSecret> {420 421 Ok(FleetSharedSecret {422 secret: generate(423 config,424 display_name,425 secret,426 &expected_owners,427 expected_generation_data,428 batch,429 )430 .await?,431 owners: expected_owners,432 })433}434435async fn parse_public(436 public: Option<String>,437 public_file: Option<PathBuf>,438) -> Result<Option<SecretData>> {439 Ok(match (public, public_file) {440 (Some(v), None) => Some(SecretData {441 data: v.into(),442 encrypted: false,443 }),444 (None, Some(v)) => Some(SecretData {445 data: read(v).await?,446 encrypted: false,447 }),448 (Some(_), Some(_)) => {449 bail!("only public or public_file should be set")450 }451 (None, None) => None,452 })453}454455async fn parse_secret() -> Result<Option<Vec<u8>>> {456 let mut input = vec![];457 stdin().read_to_end(&mut input)?;458 if input.is_empty() {459 Ok(None)460 } else {461 Ok(Some(input))462 }463}464465fn parse_machines(466 initial: Vec<String>,467 machines: Option<Vec<String>>,468 mut add_machines: Vec<String>,469 mut remove_machines: Vec<String>,470) -> Result<Vec<String>> {471 if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {472 bail!("no operation");473 }474475 let initial_machines = initial.clone();476 let mut target_machines = initial;477 info!("Currently encrypted for {initial_machines:?}");478479 480 if let Some(machines) = machines {481 ensure!(482 add_machines.is_empty() && remove_machines.is_empty(),483 "can't combine --machines and --add-machines/--remove-machines"484 );485 let target = initial_machines.iter().collect::<HashSet<_>>();486 let source = machines.iter().collect::<HashSet<_>>();487 for removed in target.difference(&source) {488 remove_machines.push((*removed).clone());489 }490 for added in source.difference(&target) {491 add_machines.push((*added).clone());492 }493 }494495 for machine in &remove_machines {496 let mut removed = false;497 while let Some(pos) = target_machines.iter().position(|m| m == machine) {498 target_machines.swap_remove(pos);499 removed = true;500 }501 if !removed {502 warn!("secret is not enabled for {machine}");503 }504 }505 for machine in &add_machines {506 if target_machines.iter().any(|m| m == machine) {507 warn!("secret is already added to {machine}");508 } else {509 target_machines.push(machine.to_owned());510 }511 }512 if !remove_machines.is_empty() {513 514 515 warn!(516 "secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret"517 );518 }519 Ok(target_machines)520}521impl Secret {522 pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {523 match self {524 Secret::ForceKeys => {525 for host in config.list_hosts().await? {526 if opts.should_skip(&host).await? {527 continue;528 }529 config.key(&host.name).await?;530 }531 }532 Secret::AddShared {533 mut machines,534 name,535 force,536 public,537 public_part: public_name,538 public_file,539 expires_at,540 re_add,541 part: part_name,542 } => {543 544545 let exists = config.has_shared(&name);546 if exists && !force && !re_add {547 bail!("secret already defined");548 }549 if re_add {550 551 ensure!(!force, "--force and --readd are not compatible");552 ensure!(exists, "secret doesn't exists");553 ensure!(554 machines.is_empty(),555 "you can't use machines argument for --readd"556 );557 let shared = config.shared_secret(&name)?;558 machines = shared.owners;559 }560561 let recipients = config.recipients(machines.clone()).await?;562563 let mut parts = BTreeMap::new();564565 let mut input = vec![];566 io::stdin().read_to_end(&mut input)?;567568 if !input.is_empty() {569 let encrypted =570 encrypt_secret_data(recipients.iter().map(|r| r as &dyn Recipient), input)571 .ok_or_else(|| anyhow!("no recipients provided"))?;572 parts.insert(part_name, FleetSecretPart { raw: encrypted });573 }574575 if let Some(public) = parse_public(public, public_file).await? {576 parts.insert(public_name, FleetSecretPart { raw: public });577 }578579 config.replace_shared(580 name,581 FleetSharedSecret {582 owners: machines,583 secret: FleetSecret {584 created_at: Utc::now(),585 expires_at,586 parts,587 generation_data: serde_json::Value::Null,588 },589 },590 );591 }592 Secret::Add {593 machine,594 name,595 replace,596 merge,597 public,598 public_part: public_name,599 public_file,600 part: part_name,601 } => {602 if config.has_secret(&machine, &name) && !replace && !merge {603 bail!(604 "secret already defined.\nUse --replace to override, or --merge to add new parts to existing secret"605 );606 }607608 let mut out = if merge && !replace {609 config610 .host_secret(&machine, &name)611 .context("failed to read existing secret for --merge")?612 } else {613 FleetSecret {614 created_at: Utc::now(),615 expires_at: None,616 parts: BTreeMap::new(),617 generation_data: serde_json::Value::Null,618 }619 };620621 if let Some(secret) = parse_secret().await? {622 let recipient = config.recipient(&machine).await?;623 let encrypted = encrypt_secret_data([&recipient as &dyn Recipient], secret)624 .expect("recipient provided");625 if out626 .parts627 .insert(part_name.clone(), FleetSecretPart { raw: encrypted })628 .is_some() && !replace629 {630 bail!("part {part_name:?} is already defined");631 }632 }633634 if let Some(public) = parse_public(public, public_file).await? {635 if out636 .parts637 .insert(public_name.clone(), FleetSecretPart { raw: public })638 .is_some() && !replace639 {640 bail!("part {public_name:?} is already defined");641 }642 };643644 config.insert_secret(&machine, name, out);645 }646 #[allow(clippy::await_holding_refcell_ref)]647 Secret::Read {648 name,649 machine,650 part: part_name,651 } => {652 let secret = config.host_secret(&machine, &name)?;653 let Some(secret) = secret.parts.get(&part_name) else {654 bail!("no part {part_name} in secret {name}");655 };656 let data = if secret.raw.encrypted {657 let host = config.host(&machine).await?;658 host.decrypt(secret.raw.clone()).await?659 } else {660 secret.raw.data.clone()661 };662663 stdout().write_all(&data)?;664 }665 Secret::ReadShared {666 name,667 part: part_name,668 prefer_identities,669 } => {670 let secret = config.shared_secret(&name)?;671 let Some(part) = secret.secret.parts.get(&part_name) else {672 bail!("no part {part_name} in secret {name}");673 };674 let data = if part.raw.encrypted {675 let identity_holder = if !prefer_identities.is_empty() {676 prefer_identities677 .iter()678 .find(|i| secret.owners.iter().any(|s| s == *i))679 } else {680 secret.owners.first()681 };682 let Some(identity_holder) = identity_holder else {683 bail!("no available holder found");684 };685 let host = config.host(identity_holder).await?;686 host.decrypt(part.raw.clone()).await?687 } else {688 part.raw.data.clone()689 };690 stdout().write_all(&data)?;691 }692 Secret::UpdateShared {693 name,694 machine,695 add_machine,696 remove_machine,697 prefer_identities,698 } => {699 700701 let secret = config.shared_secret(&name)?;702 if secret.secret.parts.values().all(|v| !v.raw.encrypted) {703 bail!("no secret");704 }705706 let initial_machines = secret.owners.clone();707 let target_machines = parse_machines(708 initial_machines.clone(),709 machine,710 add_machine,711 remove_machine,712 )?;713714 if target_machines.is_empty() {715 info!("no machines left for secret, removing it");716 config.remove_shared(&name);717 return Ok(());718 }719720 let config_field = &config.config_field;721 let field = nix_go!(config_field.sharedSecrets[{ name }]);722 let expected_generation_data = nix_go_json!(field.expectedGenerationData);723724 let updated = maybe_regenerate_shared_secret(725 &name,726 config,727 secret,728 field,729 &target_machines,730 expected_generation_data,731 &prefer_identities,732 None,733 )734 .await?;735 config.replace_shared(name, updated);736 }737 Secret::Regenerate {738 prefer_identities,739 skip_hosts,740 } => {741 info!("checking for secrets to regenerate");742 let stored_shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();743 {744 745 let shared_batch = None;746 let _span = info_span!("shared").entered();747 let expected_shared_set = config748 .list_configured_shared()749 .await?750 .into_iter()751 .collect::<HashSet<_>>();752 for missing in expected_shared_set.difference(&stored_shared_set) {753 let config_field = &config.config_field;754 let secret = nix_go!(config_field.sharedSecrets[{ missing }]);755 let expected_generation_data: serde_json::Value =756 nix_go_json!(secret.expectedGenerationData);757 let expected_owners: Option<Vec<String>> =758 nix_go_json!(secret.expectedOwners);759 let Some(expected_owners) = expected_owners else {760 761 continue;762 };763 info!("generating secret: {missing}");764 let shared = generate_shared(765 config,766 missing,767 secret,768 expected_owners,769 expected_generation_data,770 shared_batch.clone(),771 )772 .in_current_span()773 .await?;774 config.replace_shared(missing.to_string(), shared)775 }776 }777 if !skip_hosts {778 let hosts_batch = None;779 for host in config.list_hosts().await? {780 if opts.should_skip(&host).await? {781 continue;782 }783784 let _span = info_span!("host", host = host.name).entered();785 let expected_set = host786 .list_configured_secrets()787 .in_current_span()788 .await?789 .into_iter()790 .collect::<HashSet<_>>();791 let stored_set = config792 .list_secrets(&host.name)793 .into_iter()794 .collect::<HashSet<_>>();795 for missing in expected_set.difference(&stored_set) {796 info!("generating secret: {missing}");797 let secret = host.secret_field(missing).in_current_span().await?;798 let expected_generation_data =799 nix_go_json!(secret.expectedGenerationData);800 let generated = match generate(801 config,802 missing,803 secret,804 &[host.name.clone()],805 expected_generation_data,806 hosts_batch.clone(),807 )808 .in_current_span()809 .await810 {811 Ok(v) => v,812 Err(e) => {813 error!("{e:?}");814 continue;815 }816 };817 config.insert_secret(&host.name, missing.to_string(), generated)818 }819 for name in stored_set {820 info!("updating secret: {name}");821 let data = config.host_secret(&host.name, &name)?;822 let secret = host.secret_field(&name).in_current_span().await?;823 let expected_generation_data =824 nix_go_json!(secret.expectedGenerationData);825 if secret_needs_regeneration(&data, &expected_generation_data) {826 let generated = match generate(827 config,828 &name,829 secret,830 &[host.name.clone()],831 expected_generation_data,832 hosts_batch.clone(),833 )834 .in_current_span()835 .await836 {837 Ok(v) => v,838 Err(e) => {839 error!("{e:?}");840 continue;841 }842 };843 config.insert_secret(&host.name, name.to_string(), generated)844 }845 }846 }847 }848 let mut to_remove = Vec::new();849 for name in &stored_shared_set {850 info!("updating secret: {name}");851 let data = config.shared_secret(name)?;852 let config_field = &config.config_field;853 let expected_owners: Option<Vec<String>> =854 nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);855 let Some(expected_owners) = expected_owners else {856 warn!("secret was removed from fleet config: {name}, removing from data");857 to_remove.push(name.to_string());858 continue;859 };860861 let secret = nix_go!(config_field.sharedSecrets[{ name }]);862 let expected_generation_data = nix_go_json!(secret.expectedGenerationData);863 config.replace_shared(864 name.to_owned(),865 maybe_regenerate_shared_secret(866 name,867 config,868 data,869 secret,870 &expected_owners,871 expected_generation_data,872 &prefer_identities,873 None,874 )875 .await?,876 );877 }878 for k in to_remove {879 config.remove_shared(&k);880 }881 }882 Secret::List {} => {883 let _span = info_span!("loading secrets").entered();884 let configured = config.list_configured_shared().await?;885 #[derive(Tabled)]886 struct SecretDisplay {887 #[tabled(rename = "Name")]888 name: String,889 #[tabled(rename = "Owners")]890 owners: String,891 }892 let mut table = vec![];893 for name in configured.iter().cloned() {894 let config = config.clone();895 let expected_owners = config.shared_secret_expected_owners(&name).await?;896 let data = config.shared_secret(&name)?;897 let owners = data898 .owners899 .iter()900 .map(|o| {901 if expected_owners.contains(o) {902 o.green().to_string()903 } else {904 o.red().to_string()905 }906 })907 .collect::<Vec<_>>();908 table.push(SecretDisplay {909 owners: owners.join(", "),910 name,911 })912 }913 info!("loaded\n{}", Table::new(table).to_string())914 }915 Secret::Edit {916 name,917 machine,918 part,919 add,920 } => {921 let secret = config.host_secret(&machine, &name)?;922 if let Some(data) = secret.parts.get(&part) {923 let host = config.host(&machine).await?;924 let secret = host.decrypt(data.raw.clone()).await?;925 String::from_utf8(secret).context("secret is not utf8")?926 } else if add {927 String::new()928 } else {929 bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");930 };931 }932 }933 Ok(())934 }935}93693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016