1use std::{2 collections::{BTreeMap, BTreeSet, HashSet},3 ffi::OsString,4 io::{self, stdin, stdout, Read, Write},5 path::PathBuf,6};78use anyhow::{anyhow, bail, ensure, Context, Result};9use chrono::{DateTime, Utc};10use clap::Parser;11use crossterm::{terminal, tty::IsTty};12use fleet_shared::SecretData;13use itertools::Itertools;14use nix_eval::{nix_go, nix_go_json, Value};15use owo_colors::OwoColorize;16use serde::Deserialize;17use tabled::{Table, Tabled};18use tokio::{fs::read, process::Command};19use tracing::{error, info, info_span, warn, Instrument};2021use crate::{22 fleetdata::{encrypt_secret_data, FleetSecret, FleetSecretPart, FleetSharedSecret},23 host::Config,24};2526#[derive(Parser)]27pub enum Secret {28 29 ForceKeys,30 31 AddShared {32 33 name: String,34 35 machines: Vec<String>,36 37 #[clap(long)]38 force: bool,39 40 #[clap(long)]41 public: Option<String>,42 43 #[clap(long, default_value = "public")]44 public_name: String,45 46 #[clap(long)]47 public_file: Option<PathBuf>,4849 50 #[clap(long)]51 expires_at: Option<DateTime<Utc>>,5253 54 #[clap(long)]55 re_add: bool,5657 #[clap(default_value = "secret")]58 part_name: String,59 },60 61 Add {62 63 name: String,64 65 machine: String,66 67 #[clap(long)]68 force: bool,69 70 #[clap(long)]71 public: Option<String>,72 73 #[clap(long, default_value = "public")]74 public_name: String,75 76 #[clap(long)]77 public_file: Option<PathBuf>,7879 #[clap(default_value = "secret")]80 part_name: String,81 },82 83 Read {84 name: String,85 machine: String,8687 #[clap(default_value = "secret")]88 part_name: String,89 },90 UpdateShared {91 name: String,9293 #[clap(long)]94 machines: Option<Vec<String>>,9596 #[clap(long)]97 add_machines: Vec<String>,98 #[clap(long)]99 remove_machines: Vec<String>,100101 102 #[clap(long)]103 prefer_identities: Vec<String>,104105 #[clap(default_value = "secret")]106 part_name: String,107 },108 Regenerate {109 110 111 #[clap(long)]112 prefer_identities: Vec<String>,113 },114 List {},115 Edit {116 name: String,117 machine: String,118119 #[clap(default_value = "secret")]120 part: String,121122 #[clap(long)]123 add: bool,124 },125}126127#[tracing::instrument(skip(config, secret, field, prefer_identities))]128async fn update_owner_set(129 secret_name: &str,130 config: &Config,131 mut secret: FleetSharedSecret,132 field: Value,133 updated_set: &[String],134 prefer_identities: &[String],135) -> Result<FleetSharedSecret> {136 let original_set = secret.owners.clone();137138 let set = original_set.iter().collect::<BTreeSet<_>>();139 let expected_set = updated_set.iter().collect::<BTreeSet<_>>();140141 if set == expected_set {142 info!("no need to update owner list, it is already correct");143 return Ok(secret);144 }145146 let should_regenerate = if set.difference(&expected_set).next().is_some() {147 148 warn!("host was removed from secret owners, but until this host rebuild, the secret will still be stored on it.");149 nix_go_json!(field.regenerateOnOwnerRemoved)150 } else if expected_set.difference(&set).next().is_some() {151 nix_go_json!(field.regenerateOnOwnerAdded)152 } else {153 false154 };155156 if should_regenerate {157 info!("secret is owner-dependent, will regenerate");158 let generated = generate_shared(config, secret_name, field, updated_set.to_vec()).await?;159 Ok(generated)160 } else {161 let identity_holder = if !prefer_identities.is_empty() {162 prefer_identities163 .iter()164 .find(|i| original_set.iter().any(|s| s == *i))165 } else {166 secret.owners.first()167 };168 let Some(identity_holder) = identity_holder else {169 bail!("no available holder found");170 };171172 for (part_name, part) in secret.secret.parts.iter_mut() {173 let _span = info_span!("part reencryption", part_name);174 if !part.raw.encrypted {175 continue;176 }177 let host = config.host(identity_holder).await?;178 let encrypted = host179 .reencrypt(part.raw.clone(), updated_set.to_vec())180 .await?;181 part.raw = encrypted;182 }183184 secret.owners = updated_set.to_vec();185 Ok(secret)186 }187}188189#[derive(Deserialize)]190#[serde(rename_all = "camelCase")]191enum GeneratorKind {192 Impure,193 Pure,194}195196async fn generate_pure(197 _config: &Config,198 _display_name: &str,199 _secret: Value,200 _default_generator: Value,201 _owners: &[String],202) -> Result<FleetSecret> {203 bail!("pure generators are broken for now")204}205async fn generate_impure(206 config: &Config,207 _display_name: &str,208 secret: Value,209 default_generator: Value,210 owners: &[String],211) -> Result<FleetSecret> {212 let generator = nix_go!(secret.generator);213 let on: Option<String> = nix_go_json!(default_generator.impureOn);214215 let host = if let Some(on) = &on {216 config.host(on).await?217 } else {218 config.local_host()219 };220 let on_pkgs = host.pkgs().await?;221 let call_package = nix_go!(on_pkgs.callPackage);222 let mk_encrypt_secret = nix_go!(on_pkgs.mkEncryptSecret);223224 let mut recipients = Vec::new();225 for owner in owners {226 let key = config.key(owner).await?;227 recipients.push(key);228 }229 let encrypt = nix_go!(mk_encrypt_secret(Obj {230 recipients: { recipients },231 }));232233 let generator = nix_go!(call_package(generator)(Obj {234 encrypt,235 236 }));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 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 296 {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 308 309 310 311 312 313 314 315 let default_generator = nix_go!(default_call_package(generator)(Obj {316 encrypt: { "exit 1" },317 318 }));319320 let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);321322 match kind {323 GeneratorKind::Impure => {324 generate_impure(config, display_name, secret, default_generator, owners).await325 }326 GeneratorKind::Pure => {327 generate_pure(config, display_name, secret, default_generator, owners).await328 }329 }330}331async fn generate_shared(332 config: &Config,333 display_name: &str,334 secret: Value,335 expected_owners: Vec<String>,336) -> Result<FleetSharedSecret> {337 338 Ok(FleetSharedSecret {339 secret: generate(config, display_name, secret, &expected_owners).await?,340 owners: expected_owners,341 })342}343344async fn parse_public(345 public: Option<String>,346 public_file: Option<PathBuf>,347) -> Result<Option<SecretData>> {348 Ok(match (public, public_file) {349 (Some(v), None) => Some(SecretData {350 data: v.into(),351 encrypted: false,352 }),353 (None, Some(v)) => Some(SecretData {354 data: read(v).await?,355 encrypted: false,356 }),357 (Some(_), Some(_)) => {358 bail!("only public or public_file should be set")359 }360 (None, None) => None,361 })362}363364async fn parse_secret() -> Result<Option<Vec<u8>>> {365 let mut input = vec![];366 io::stdin().read_to_end(&mut input)?;367 if input.is_empty() {368 Ok(None)369 } else {370 Ok(Some(input))371 }372}373374fn parse_machines(375 initial: Vec<String>,376 machines: Option<Vec<String>>,377 mut add_machines: Vec<String>,378 mut remove_machines: Vec<String>,379) -> Result<Vec<String>> {380 if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {381 bail!("no operation");382 }383384 let initial_machines = initial.clone();385 let mut target_machines = initial;386 info!("Currently encrypted for {initial_machines:?}");387388 389 if let Some(machines) = machines {390 ensure!(391 add_machines.is_empty() && remove_machines.is_empty(),392 "can't combine --machines and --add-machines/--remove-machines"393 );394 let target = initial_machines.iter().collect::<HashSet<_>>();395 let source = machines.iter().collect::<HashSet<_>>();396 for removed in target.difference(&source) {397 remove_machines.push((*removed).clone());398 }399 for added in source.difference(&target) {400 add_machines.push((*added).clone());401 }402 }403404 for machine in &remove_machines {405 let mut removed = false;406 while let Some(pos) = target_machines.iter().position(|m| m == machine) {407 target_machines.swap_remove(pos);408 removed = true;409 }410 if !removed {411 warn!("secret is not enabled for {machine}");412 }413 }414 for machine in &add_machines {415 if target_machines.iter().any(|m| m == machine) {416 warn!("secret is already added to {machine}");417 } else {418 target_machines.push(machine.to_owned());419 }420 }421 if !remove_machines.is_empty() {422 423 424 warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");425 }426 Ok(target_machines)427}428impl Secret {429 pub async fn run(self, config: &Config) -> Result<()> {430 match self {431 Secret::ForceKeys => {432 for host in config.list_hosts().await? {433 if config.should_skip(&host.name) {434 continue;435 }436 config.key(&host.name).await?;437 }438 }439 Secret::AddShared {440 mut machines,441 name,442 force,443 public,444 public_name,445 public_file,446 expires_at,447 re_add,448 part_name,449 } => {450 451452 let exists = config.has_shared(&name);453 if exists && !force && !re_add {454 bail!("secret already defined");455 }456 if re_add {457 458 ensure!(!force, "--force and --readd are not compatible");459 ensure!(exists, "secret doesn't exists");460 ensure!(461 machines.is_empty(),462 "you can't use machines argument for --readd"463 );464 let shared = config.shared_secret(&name)?;465 machines = shared.owners;466 }467468 let recipients = config.recipients(machines.clone()).await?;469470 let mut parts = BTreeMap::new();471472 let mut input = vec![];473 io::stdin().read_to_end(&mut input)?;474475 if !input.is_empty() {476 let encrypted = encrypt_secret_data(recipients, input)477 .ok_or_else(|| anyhow!("no recipients provided"))?;478 parts.insert(part_name, FleetSecretPart { raw: encrypted });479 }480481 if let Some(public) = parse_public(public, public_file).await? {482 parts.insert(public_name, FleetSecretPart { raw: public });483 }484485 config.replace_shared(486 name,487 FleetSharedSecret {488 owners: machines,489 secret: FleetSecret {490 created_at: Utc::now(),491 expires_at,492 parts,493 },494 },495 );496 }497 Secret::Add {498 machine,499 name,500 force,501 public,502 public_name,503 public_file,504 part_name,505 } => {506 if config.has_secret(&machine, &name) && !force {507 bail!("secret already defined");508 }509510 let mut parts = BTreeMap::new();511512 if let Some(secret) = parse_secret().await? {513 let recipient = config.recipient(&machine).await?;514 let encrypted =515 encrypt_secret_data(vec![recipient], secret).expect("recipient provided");516 parts.insert(part_name, FleetSecretPart { raw: encrypted });517 }518519 if let Some(public) = parse_public(public, public_file).await? {520 parts.insert(public_name, FleetSecretPart { raw: public });521 };522523 config.insert_secret(524 &machine,525 name,526 FleetSecret {527 created_at: Utc::now(),528 expires_at: None,529 parts,530 },531 );532 }533 #[allow(clippy::await_holding_refcell_ref)]534 Secret::Read {535 name,536 machine,537 part_name,538 } => {539 let secret = config.host_secret(&machine, &name)?;540 let Some(secret) = secret.parts.get(&part_name) else {541 bail!("no part {part_name} in secret {name}");542 };543 let data = if secret.raw.encrypted {544 let host = config.host(&machine).await?;545 host.decrypt(secret.raw.clone()).await?546 } else {547 secret.raw.data.clone()548 };549550 stdout().write_all(&data)?;551 }552 Secret::UpdateShared {553 name,554 machines,555 add_machines,556 remove_machines,557 prefer_identities,558 part_name,559 } => {560 561562 let secret = config.shared_secret(&name)?;563 if secret.secret.parts.get(&part_name).is_none() {564 bail!("no secret");565 }566567 let initial_machines = secret.owners.clone();568 let target_machines = parse_machines(569 initial_machines.clone(),570 machines,571 add_machines,572 remove_machines,573 )?;574575 if target_machines.is_empty() {576 info!("no machines left for secret, removing it");577 config.remove_shared(&name);578 return Ok(());579 }580581 let config_field = &config.config_unchecked_field;582 let field = nix_go!(config_field.sharedSecrets[{ name }]);583584 let updated = update_owner_set(585 &name,586 config,587 secret,588 field,589 &target_machines,590 &prefer_identities,591 )592 .await?;593 config.replace_shared(name, updated);594 }595 Secret::Regenerate { prefer_identities } => {596 info!("checking for secrets to regenerate");597 {598 let _span = info_span!("shared").entered();599 let expected_shared_set = config600 .list_configured_shared()601 .await?602 .into_iter()603 .collect::<HashSet<_>>();604 let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();605 for missing in expected_shared_set.difference(&shared_set) {606 let config_field = &config.config_unchecked_field;607 let secret = nix_go!(config_field.sharedSecrets[{ missing }]);608 let expected_owners: Option<Vec<String>> =609 nix_go_json!(secret.expectedOwners);610 let Some(expected_owners) = expected_owners else {611 612 continue;613 };614 info!("generating secret: {missing}");615 let shared = generate_shared(config, missing, secret, expected_owners)616 .in_current_span()617 .await?;618 config.replace_shared(missing.to_string(), shared)619 }620 }621 for host in config.list_hosts().await? {622 if config.should_skip(&host.name) {623 continue;624 }625626 let _span = info_span!("host", host = host.name).entered();627 let expected_set = host628 .list_configured_secrets()629 .in_current_span()630 .await?631 .into_iter()632 .collect::<HashSet<_>>();633 let stored_set = config634 .list_secrets(&host.name)635 .into_iter()636 .collect::<HashSet<_>>();637 for missing in expected_set.difference(&stored_set) {638 info!("generating secret: {missing}");639 let secret = host.secret_field(missing).in_current_span().await?;640 let generated =641 match generate(config, missing, secret, &[host.name.clone()])642 .in_current_span()643 .await644 {645 Ok(v) => v,646 Err(e) => {647 error!("{e:?}");648 continue;649 }650 };651 config.insert_secret(&host.name, missing.to_string(), generated)652 }653 }654 let mut to_remove = Vec::new();655 for name in &config.list_shared() {656 info!("updating secret: {name}");657 let data = config.shared_secret(name)?;658 let config_field = &config.config_unchecked_field;659 let expected_owners: Vec<String> =660 nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);661 if expected_owners.is_empty() {662 warn!("secret was removed from fleet config: {name}, removing from data");663 to_remove.push(name.to_string());664 continue;665 }666667 let secret = nix_go!(config_field.sharedSecrets[{ name }]);668 config.replace_shared(669 name.to_owned(),670 update_owner_set(671 name,672 config,673 data,674 secret,675 &expected_owners,676 &prefer_identities,677 )678 .await?,679 );680 }681 for k in to_remove {682 config.remove_shared(&k);683 }684 }685 Secret::List {} => {686 let _span = info_span!("loading secrets").entered();687 let configured = config.list_configured_shared().await?;688 #[derive(Tabled)]689 struct SecretDisplay {690 #[tabled(rename = "Name")]691 name: String,692 #[tabled(rename = "Owners")]693 owners: String,694 }695 let mut table = vec![];696 for name in configured.iter().cloned() {697 let config = config.clone();698 let expected_owners = config.shared_secret_expected_owners(&name).await?;699 let data = config.shared_secret(&name)?;700 let owners = data701 .owners702 .iter()703 .map(|o| {704 if expected_owners.contains(o) {705 o.green().to_string()706 } else {707 o.red().to_string()708 }709 })710 .collect::<Vec<_>>();711 table.push(SecretDisplay {712 owners: owners.join(", "),713 name,714 })715 }716 info!("loaded\n{}", Table::new(table).to_string())717 }718 Secret::Edit {719 name,720 machine,721 part,722 add,723 } => {724 let secret = config.host_secret(&machine, &name)?;725 if let Some(data) = secret.parts.get(&part) {726 let host = config.host(&machine).await?;727 let secret = host.decrypt(data.raw.clone()).await?;728 String::from_utf8(secret).context("secret is not utf8")?729 } else if add {730 String::new()731 } else {732 bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");733 };734 }735 }736 Ok(())737 }738}739740async fn edit_temp_file(741 builder: tempfile::Builder<'_, '_>,742 r: Vec<u8>,743 header: &str,744 comment: &str,745) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {746 if !stdin().is_tty() {747 748 bail!("stdin is not tty, can't open editor");749 }750751 use std::fmt::Write;752 let mut file = builder.tempfile()?;753754 let mut full_header = String::new();755 let mut had = false;756 for line in header.trim_end().lines() {757 had = true;758 writeln!(&mut full_header, "{comment}{line}")?;759 }760 if had {761 writeln!(&mut full_header, "{}", comment.trim_end())?;762 }763 writeln!(764 &mut full_header,765 "{comment}Do not touch this header! It will be removed automatically"766 )?;767768 file.write_all(full_header.as_bytes())?;769 file.write_all(&r)?;770771 let abs_path = file.into_temp_path();772 let editor = std::env::var_os("VISUAL")773 .or_else(|| std::env::var_os("EDITOR"))774 .unwrap_or_else(|| "vi".into());775 let editor_args = shlex::bytes::split(editor.as_encoded_bytes())776 .ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;777 let editor_args = editor_args778 .into_iter()779 .map(|v| {780 781 unsafe { OsString::from_encoded_bytes_unchecked(v) }782 })783 .collect_vec();784 let Some((editor, args)) = editor_args.split_first() else {785 bail!("EDITOR env var has no command");786 };787 let mut command = Command::new(editor);788 command.args(args);789790 let path_arg = abs_path.canonicalize()?;791792 793 let was_raw = terminal::is_raw_mode_enabled()?;794 terminal::enable_raw_mode()?;795796 let status = command.arg(path_arg).status().await;797798 if !was_raw {799 terminal::disable_raw_mode()?;800 }801802 let success = match status {803 Ok(s) => s.success(),804 Err(e) if e.kind() == io::ErrorKind::NotFound => {805 bail!("editor not found")806 }807 Err(e) => bail!("editor spawn error: {e}"),808 };809810 let mut file = std::fs::read(&abs_path).context("read editor output")?;811 let Some(v) = file.strip_prefix(full_header.as_bytes()) else {812 todo!();813 };814 todo!();815816 817}