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 owo_colors::OwoColorize;15use serde::Deserialize;16use tabled::{Table, Tabled};17use tokio::{fs::read, process::Command};18use tracing::{error, info, info_span, warn, Instrument};1920use crate::{21 better_nix_eval::Field,22 fleetdata::{encrypt_secret_data, FleetSecret, FleetSecretPart, FleetSharedSecret},23 host::Config,24 nix_go, nix_go_json,25};2627#[derive(Parser)]28pub enum Secret {29 30 ForceKeys,31 32 AddShared {33 34 name: String,35 36 machines: Vec<String>,37 38 #[clap(long)]39 force: bool,40 41 #[clap(long)]42 public: Option<String>,43 44 #[clap(long, default_value = "public")]45 public_name: String,46 47 #[clap(long)]48 public_file: Option<PathBuf>,4950 51 #[clap(long)]52 expires_at: Option<DateTime<Utc>>,5354 55 #[clap(long)]56 re_add: bool,5758 #[clap(default_value = "secret")]59 part_name: String,60 },61 62 Add {63 64 name: String,65 66 machine: String,67 68 #[clap(long)]69 force: bool,70 71 #[clap(long)]72 public: Option<String>,73 74 #[clap(long, default_value = "public")]75 public_name: String,76 77 #[clap(long)]78 public_file: Option<PathBuf>,7980 #[clap(default_value = "secret")]81 part_name: String,82 },83 84 Read {85 name: String,86 machine: String,8788 #[clap(default_value = "secret")]89 part_name: String,90 },91 UpdateShared {92 name: String,9394 #[clap(long)]95 machines: Option<Vec<String>>,9697 #[clap(long)]98 add_machines: Vec<String>,99 #[clap(long)]100 remove_machines: Vec<String>,101102 103 #[clap(long)]104 prefer_identities: Vec<String>,105106 #[clap(default_value = "secret")]107 part_name: String,108 },109 Regenerate {110 111 112 #[clap(long)]113 prefer_identities: Vec<String>,114 },115 List {},116 Edit {117 name: String,118 machine: String,119120 #[clap(default_value = "secret")]121 part: String,122123 #[clap(long)]124 add: bool,125 },126}127128#[tracing::instrument(skip(config, secret, field, prefer_identities))]129async fn update_owner_set(130 secret_name: &str,131 config: &Config,132 mut secret: FleetSharedSecret,133 field: Field,134 updated_set: &[String],135 prefer_identities: &[String],136) -> Result<FleetSharedSecret> {137 let original_set = secret.owners.clone();138139 let set = original_set.iter().collect::<BTreeSet<_>>();140 let expected_set = updated_set.iter().collect::<BTreeSet<_>>();141142 if set == expected_set {143 info!("no need to update owner list, it is already correct");144 return Ok(secret);145 }146147 let should_regenerate = if set.difference(&expected_set).next().is_some() {148 149 warn!("host was removed from secret owners, but until this host rebuild, the secret will still be stored on it.");150 nix_go_json!(field.regenerateOnOwnerRemoved)151 } else if expected_set.difference(&set).next().is_some() {152 nix_go_json!(field.regenerateOnOwnerAdded)153 } else {154 false155 };156157 if should_regenerate {158 info!("secret is owner-dependent, will regenerate");159 let generated = generate_shared(config, secret_name, field, updated_set.to_vec()).await?;160 Ok(generated)161 } else {162 let identity_holder = if !prefer_identities.is_empty() {163 prefer_identities164 .iter()165 .find(|i| original_set.iter().any(|s| s == *i))166 } else {167 secret.owners.first()168 };169 let Some(identity_holder) = identity_holder else {170 bail!("no available holder found");171 };172173 for (part_name, part) in secret.secret.parts.iter_mut() {174 let _span = info_span!("part reencryption", part_name);175 if !part.raw.encrypted {176 continue;177 }178 let host = config.host(identity_holder).await?;179 let encrypted = host180 .reencrypt(part.raw.clone(), updated_set.to_vec())181 .await?;182 part.raw = encrypted;183 }184185 secret.owners = updated_set.to_vec();186 Ok(secret)187 }188}189190#[derive(Deserialize)]191#[serde(rename_all = "camelCase")]192enum GeneratorKind {193 Impure,194 Pure,195}196197async fn generate_pure(198 _config: &Config,199 _display_name: &str,200 _secret: Field,201 _default_generator: Field,202 _owners: &[String],203) -> Result<FleetSecret> {204 bail!("pure generators are broken for now")205}206async fn generate_impure(207 config: &Config,208 _display_name: &str,209 secret: Field,210 default_generator: Field,211 owners: &[String],212) -> Result<FleetSecret> {213 let generator = nix_go!(secret.generator);214 let on: Option<String> = nix_go_json!(default_generator.impureOn);215216 let host = if let Some(on) = &on {217 config.host(on).await?218 } else {219 config.local_host()220 };221 let on_pkgs = host.pkgs().await?;222 let call_package = nix_go!(on_pkgs.callPackage);223 let mk_encrypt_secret = nix_go!(on_pkgs.mkEncryptSecret);224225 let mut recipients = Vec::new();226 for owner in owners {227 let key = config.key(owner).await?;228 recipients.push(key);229 }230 let encrypt = nix_go!(mk_encrypt_secret(Obj {231 recipients: { recipients },232 }));233234 let generator = nix_go!(call_package(generator)(Obj {235 encrypt,236 237 }));238239 let generator = generator.build().await?;240 let generator = generator241 .get("out")242 .ok_or_else(|| anyhow!("missing generateImpure out"))?;243 let generator = host.remote_derivation(generator).await?;244245 let out_parent = host.mktemp_dir().await?;246 let out = format!("{out_parent}/out");247248 let mut gen = host.cmd(generator).await?;249 gen.env("out", &out);250 if on.is_none() {251 252 let project_path: String = config253 .directory254 .clone()255 .into_os_string()256 .into_string()257 .map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;258 gen.env("FLEET_PROJECT", project_path);259 }260 gen.run().await.context("impure generator")?;261262 {263 let marker = host.read_file_text(format!("{out}/marker")).await?;264 ensure!(marker == "SUCCESS", "generation not succeeded");265 }266267 let mut parts = BTreeMap::new();268 for part in host.read_dir(&out).await? {269 if part == "created_at" || part == "expired_at" || part == "marker" {270 continue;271 }272 let contents: SecretData = host273 .read_file_text(format!("{out}/{part}"))274 .await?275 .parse()276 .map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;277 parts.insert(part.to_owned(), FleetSecretPart { raw: contents });278 }279280 let created_at = host.read_file_value(format!("{out}/created_at")).await?;281 let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();282283 Ok(FleetSecret {284 created_at,285 expires_at,286 parts,287 })288}289async fn generate(290 config: &Config,291 display_name: &str,292 secret: Field,293 owners: &[String],294) -> Result<FleetSecret> {295 let generator = nix_go!(secret.generator);296 297 {298 let gen_ty = generator.type_of().await?;299 if gen_ty == "null" {300 bail!("secret has no generator defined, can't automatically generate it.");301 }302 if gen_ty != "lambda" {303 bail!("generator should be lambda, got {gen_ty}");304 }305 }306 let default_pkgs = &config.default_pkgs;307 let default_call_package = nix_go!(default_pkgs.callPackage);308 309 310 311 312 313 314 315 316 let default_generator = nix_go!(default_call_package(generator)(Obj {317 encrypt: { "exit 1" },318 319 }));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: Field,336 expected_owners: Vec<String>,337) -> Result<FleetSharedSecret> {338 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 io::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 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 424 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) -> Result<()> {431 match self {432 Secret::ForceKeys => {433 for host in config.list_hosts().await? {434 if config.should_skip(&host.name) {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_name,446 public_file,447 expires_at,448 re_add,449 part_name,450 } => {451 452453 let exists = config.has_shared(&name);454 if exists && !force && !re_add {455 bail!("secret already defined");456 }457 if re_add {458 459 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 force,502 public,503 public_name,504 public_file,505 part_name,506 } => {507 if config.has_secret(&machine, &name) && !force {508 bail!("secret already defined");509 }510511 let mut parts = BTreeMap::new();512513 if let Some(secret) = parse_secret().await? {514 let recipient = config.recipient(&machine).await?;515 let encrypted =516 encrypt_secret_data(vec![recipient], secret).expect("recipient provided");517 parts.insert(part_name, FleetSecretPart { raw: encrypted });518 }519520 if let Some(public) = parse_public(public, public_file).await? {521 parts.insert(public_name, FleetSecretPart { raw: public });522 };523524 config.insert_secret(525 &machine,526 name,527 FleetSecret {528 created_at: Utc::now(),529 expires_at: None,530 parts,531 },532 );533 }534 #[allow(clippy::await_holding_refcell_ref)]535 Secret::Read {536 name,537 machine,538 part_name,539 } => {540 let secret = config.host_secret(&machine, &name)?;541 let Some(secret) = secret.parts.get(&part_name) else {542 bail!("no part {part_name} in secret {name}");543 };544 let data = if secret.raw.encrypted {545 let host = config.host(&machine).await?;546 host.decrypt(secret.raw.clone()).await?547 } else {548 secret.raw.data.clone()549 };550551 stdout().write_all(&data)?;552 }553 Secret::UpdateShared {554 name,555 machines,556 add_machines,557 remove_machines,558 prefer_identities,559 part_name,560 } => {561 562563 let secret = config.shared_secret(&name)?;564 if secret.secret.parts.get(&part_name).is_none() {565 bail!("no secret");566 }567568 let initial_machines = secret.owners.clone();569 let target_machines = parse_machines(570 initial_machines.clone(),571 machines,572 add_machines,573 remove_machines,574 )?;575576 if target_machines.is_empty() {577 info!("no machines left for secret, removing it");578 config.remove_shared(&name);579 return Ok(());580 }581582 let config_field = &config.config_unchecked_field;583 let field = nix_go!(config_field.sharedSecrets[{ name }]);584585 let updated = update_owner_set(586 &name,587 config,588 secret,589 field,590 &target_machines,591 &prefer_identities,592 )593 .await?;594 config.replace_shared(name, updated);595 }596 Secret::Regenerate { prefer_identities } => {597 info!("checking for secrets to regenerate");598 {599 let _span = info_span!("shared").entered();600 let expected_shared_set = config601 .list_configured_shared()602 .await?603 .into_iter()604 .collect::<HashSet<_>>();605 let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();606 for missing in expected_shared_set.difference(&shared_set) {607 let config_field = &config.config_unchecked_field;608 let secret = nix_go!(config_field.sharedSecrets[{ missing }]);609 let expected_owners: Option<Vec<String>> =610 nix_go_json!(secret.expectedOwners);611 let Some(expected_owners) = expected_owners else {612 613 continue;614 };615 info!("generating secret: {missing}");616 let shared = generate_shared(config, missing, secret, expected_owners)617 .in_current_span()618 .await?;619 config.replace_shared(missing.to_string(), shared)620 }621 }622 for host in config.list_hosts().await? {623 if config.should_skip(&host.name) {624 continue;625 }626627 let _span = info_span!("host", host = host.name).entered();628 let expected_set = host629 .list_configured_secrets()630 .in_current_span()631 .await?632 .into_iter()633 .collect::<HashSet<_>>();634 let stored_set = config635 .list_secrets(&host.name)636 .into_iter()637 .collect::<HashSet<_>>();638 for missing in expected_set.difference(&stored_set) {639 info!("generating secret: {missing}");640 let secret = host.secret_field(missing).in_current_span().await?;641 let generated =642 match generate(config, missing, secret, &[host.name.clone()])643 .in_current_span()644 .await645 {646 Ok(v) => v,647 Err(e) => {648 error!("{e:?}");649 continue;650 }651 };652 config.insert_secret(&host.name, missing.to_string(), generated)653 }654 }655 let mut to_remove = Vec::new();656 for name in &config.list_shared() {657 info!("updating secret: {name}");658 let data = config.shared_secret(name)?;659 let config_field = &config.config_unchecked_field;660 let expected_owners: Vec<String> =661 nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);662 if expected_owners.is_empty() {663 warn!("secret was removed from fleet config: {name}, removing from data");664 to_remove.push(name.to_string());665 continue;666 }667668 let secret = nix_go!(config_field.sharedSecrets[{ name }]);669 config.replace_shared(670 name.to_owned(),671 update_owner_set(672 name,673 config,674 data,675 secret,676 &expected_owners,677 &prefer_identities,678 )679 .await?,680 );681 }682 for k in to_remove {683 config.remove_shared(&k);684 }685 }686 Secret::List {} => {687 let _span = info_span!("loading secrets").entered();688 let configured = config.list_configured_shared().await?;689 #[derive(Tabled)]690 struct SecretDisplay {691 #[tabled(rename = "Name")]692 name: String,693 #[tabled(rename = "Owners")]694 owners: String,695 }696 let mut table = vec![];697 for name in configured.iter().cloned() {698 let config = config.clone();699 let expected_owners = config.shared_secret_expected_owners(&name).await?;700 let data = config.shared_secret(&name)?;701 let owners = data702 .owners703 .iter()704 .map(|o| {705 if expected_owners.contains(o) {706 o.green().to_string()707 } else {708 o.red().to_string()709 }710 })711 .collect::<Vec<_>>();712 table.push(SecretDisplay {713 owners: owners.join(", "),714 name,715 })716 }717 info!("loaded\n{}", Table::new(table).to_string())718 }719 Secret::Edit {720 name,721 machine,722 part,723 add,724 } => {725 let secret = config.host_secret(&machine, &name)?;726 if let Some(data) = secret.parts.get(&part) {727 let host = config.host(&machine).await?;728 let secret = host.decrypt(data.raw.clone()).await?;729 String::from_utf8(secret).context("secret is not utf8")?730 } else if add {731 String::new()732 } else {733 bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");734 };735 }736 }737 Ok(())738 }739}740741async fn edit_temp_file(742 builder: tempfile::Builder<'_, '_>,743 r: Vec<u8>,744 header: &str,745 comment: &str,746) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {747 if !stdin().is_tty() {748 749 bail!("stdin is not tty, can't open editor");750 }751752 use std::fmt::Write;753 let mut file = builder.tempfile()?;754755 let mut full_header = String::new();756 let mut had = false;757 for line in header.trim_end().lines() {758 had = true;759 writeln!(&mut full_header, "{comment}{line}")?;760 }761 if had {762 writeln!(&mut full_header, "{}", comment.trim_end())?;763 }764 writeln!(765 &mut full_header,766 "{comment}Do not touch this header! It will be removed automatically"767 )?;768769 file.write_all(full_header.as_bytes())?;770 file.write_all(&r)?;771772 let abs_path = file.into_temp_path();773 let editor = std::env::var_os("VISUAL")774 .or_else(|| std::env::var_os("EDITOR"))775 .unwrap_or_else(|| "vi".into());776 let editor_args = shlex::bytes::split(editor.as_encoded_bytes())777 .ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;778 let editor_args = editor_args779 .into_iter()780 .map(|v| {781 782 unsafe { OsString::from_encoded_bytes_unchecked(v) }783 })784 .collect_vec();785 let Some((editor, args)) = editor_args.split_first() else {786 bail!("EDITOR env var has no command");787 };788 let mut command = Command::new(editor);789 command.args(args);790791 let path_arg = abs_path.canonicalize()?;792793 794 let was_raw = terminal::is_raw_mode_enabled()?;795 terminal::enable_raw_mode()?;796797 let status = command.arg(path_arg).status().await;798799 if !was_raw {800 terminal::disable_raw_mode()?;801 }802803 let success = match status {804 Ok(s) => s.success(),805 Err(e) if e.kind() == io::ErrorKind::NotFound => {806 bail!("editor not found")807 }808 Err(e) => bail!("editor spawn error: {e}"),809 };810811 let mut file = std::fs::read(&abs_path).context("read editor output")?;812 let Some(v) = file.strip_prefix(full_header.as_bytes()) else {813 todo!();814 };815 todo!();816817 818}