difftreelog
feat manager identities
in: trunk
7 files changed
Cargo.lockdiffbeforeafterboth--- a/Cargo.lock
+++ b/Cargo.lock
@@ -92,6 +92,8 @@
"scrypt",
"sha2",
"subtle",
+ "which",
+ "wsl",
"x25519-dalek",
"zeroize",
]
@@ -111,6 +113,7 @@
"rand 0.8.5",
"secrecy",
"sha2",
+ "tempfile",
]
[[package]]
@@ -1286,6 +1289,15 @@
]
[[package]]
+name = "home"
+version = "0.5.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
+[[package]]
name = "hostname"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3639,6 +3651,18 @@
]
[[package]]
+name = "which"
+version = "4.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7"
+dependencies = [
+ "either",
+ "home",
+ "once_cell",
+ "rustix 0.38.40",
+]
+
+[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3776,6 +3800,12 @@
]
[[package]]
+name = "wsl"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dab7ac864710bdea6594becbea5b5050333cf34fefb0dc319567eb347950d4"
+
+[[package]]
name = "x25519-dalek"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.tomldiffbeforeafterboth--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,7 +11,7 @@
nix-eval = { path = "./crates/nix-eval" }
nixlike = { path = "./crates/nixlike" }
-age = { version = "0.11", features = ["ssh"] }
+age = { version = "0.11", features = ["ssh", "plugin"] }
anyhow = "1.0"
clap = { version = "4.5", features = ["derive", "env", "unicode", "wrap_help"] }
clap_complete = "4.5"
cmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth1use 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 /// Force load host keys for all defined hosts27 ForceKeys,28 /// Add secret, data should be provided in stdin29 AddShared {30 /// Secret name31 name: String,32 /// Secret owners33 #[clap(long, short)]34 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,5253 /// How to name public secret part54 #[clap(long, short = 'p', default_value = "public")]55 public_part: String,56 /// How to name private secret part57 #[clap(short = 's', long, default_value = "secret")]58 part: String,59 },60 /// Add secret, data should be provided in stdin61 Add {62 /// Secret name63 name: String,64 /// Secret owner65 #[clap(short = 'm', long)]66 machine: String,67 /// Replace secret if already present68 #[clap(long)]69 replace: bool,70 /// Add new parts to existing secret71 #[clap(long)]72 merge: bool,73 /// Secret public part74 #[clap(long)]75 public: Option<String>,76 /// Load public part from specified file77 #[clap(long)]78 public_file: Option<PathBuf>,7980 /// How to name public secret part81 #[clap(short = 'p', long, default_value = "public")]82 public_part: String,83 /// How to name private secret part84 #[clap(short = 's', long, default_value = "secret")]85 part: String,86 },87 /// Read secret from remote host, requires sudo on said host88 Read {89 name: String,90 #[clap(short = 'm', long)]91 machine: String,9293 /// Which private secret part to read94 #[clap(short = 'p', long, default_value = "secret")]95 part: String,96 },97 /// Read secret from remote host, requires sudo on said host98 ReadShared {99 name: String,100 /// Which private secret part to read101 #[clap(short = 'p', long, default_value = "secret")]102 part: String,103 /// Which host should we use to decrypt, in case if reencryption is required, without104 /// regeneration105 #[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 /// Which host should we use to decrypt120 #[clap(long)]121 prefer_identities: Vec<String>,122 },123 Regenerate {124 /// Which host should we use to decrypt, in case if reencryption is required, without125 /// regeneration126 #[clap(long)]127 prefer_identities: Vec<String>,128 /// Only regenerate shared secrets129 #[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 /// Which private secret part to read142 #[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 // TODO: Leeway?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 // TODO: Remove this warning for revokable secrets.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 // This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.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 // Can't properly check on nix module system level353 {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 // Generators provide additional information in passthru, to access370 // passthru we should call generator, but information about where this generator is supposed to build371 // is located in passthru... Thus evaluating generator on host.372 //373 // Maybe it is also possible to do some magic with __functor?374 //375 // I don't want to make modules always responsible for additional secret data anyway,376 // so it should be in derivation, and not in the secret data itself.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 // let owners: Vec<String> = nix_go_json!(secret.expectedOwners);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 // ensure!(machines.is_some() || !add_machines.is_empty() || )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 // TODO: maybe force secret regeneration?514 // Not that useful without revokation.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 // TODO: Forbid updating secrets with set expectedOwners (= not user-managed).544545 let exists = config.has_shared(&name);546 if exists && !force && !re_add {547 bail!("secret already defined");548 }549 if re_add {550 // Fixme: use clap to limit this usage551 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 // TODO: Forbid updating secrets with set expectedOwners (= not user-managed).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 // Generate missing shared745 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 // Can't generate this missing secret, as it has no defined owners.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}936937/*938async fn edit_temp_file(939 builder: tempfile::Builder<'_, '_>,940 r: Vec<u8>,941 header: &str,942 comment: &str,943) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {944 if !stdin().is_tty() {945 // TODO: Also try to open /dev/tty directly?946 bail!("stdin is not tty, can't open editor");947 }948949 use std::fmt::Write;950 let mut file = builder.tempfile()?;951952 let mut full_header = String::new();953 let mut had = false;954 for line in header.trim_end().lines() {955 had = true;956 writeln!(&mut full_header, "{comment}{line}")?;957 }958 if had {959 writeln!(&mut full_header, "{}", comment.trim_end())?;960 }961 writeln!(962 &mut full_header,963 "{comment}Do not touch this header! It will be removed automatically"964 )?;965966 file.write_all(full_header.as_bytes())?;967 file.write_all(&r)?;968969 let abs_path = file.into_temp_path();970 let editor = std::env::var_os("VISUAL")971 .or_else(|| std::env::var_os("EDITOR"))972 .unwrap_or_else(|| "vi".into());973 let editor_args = shlex::bytes::split(editor.as_encoded_bytes())974 .ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;975 let editor_args = editor_args976 .into_iter()977 .map(|v| {978 // Only ASCII subsequences are replaced979 unsafe { OsString::from_encoded_bytes_unchecked(v) }980 })981 .collect_vec();982 let Some((editor, args)) = editor_args.split_first() else {983 bail!("EDITOR env var has no command");984 };985 let mut command = Command::new(editor);986 command.args(args);987988 let path_arg = abs_path.canonicalize()?;989990 // TODO: Save full state, using tcget/_getmode/_setmode991 let was_raw = terminal::is_raw_mode_enabled()?;992 terminal::enable_raw_mode()?;993994 let status = command.arg(path_arg).status().await;995996 if !was_raw {997 terminal::disable_raw_mode()?;998 }9991000 let success = match status {1001 Ok(s) => s.success(),1002 Err(e) if e.kind() == io::ErrorKind::NotFound => {1003 bail!("editor not found")1004 }1005 Err(e) => bail!("editor spawn error: {e}"),1006 };10071008 let mut file = std::fs::read(&abs_path).context("read editor output")?;1009 let Some(v) = file.strip_prefix(full_header.as_bytes()) else {1010 todo!();1011 };1012 todo!();10131014 // Ok((success, abs_path))1015}1016*/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 AddManager,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 #[clap(long, short)]35 machines: Vec<String>,36 /// Override secret if already present37 #[clap(long)]38 force: bool,39 /// Secret public part40 #[clap(long)]41 public: Option<String>,42 /// Load public part from specified file43 #[clap(long)]44 public_file: Option<PathBuf>,4546 /// Create a notification on secret expiration47 #[clap(long)]48 expires_at: Option<DateTime<Utc>>,4950 /// Secret with this name already exists, override its value while keeping the same owners.51 #[clap(long)]52 re_add: bool,5354 /// How to name public secret part55 #[clap(long, short = 'p', default_value = "public")]56 public_part: String,57 /// How to name private secret part58 #[clap(short = 's', long, default_value = "secret")]59 part: String,60 },61 /// Add secret, data should be provided in stdin62 Add {63 /// Secret name64 name: String,65 /// Secret owner66 #[clap(short = 'm', long)]67 machine: String,68 /// Replace secret if already present69 #[clap(long)]70 replace: bool,71 /// Add new parts to existing secret72 #[clap(long)]73 merge: bool,74 /// Secret public part75 #[clap(long)]76 public: Option<String>,77 /// Load public part from specified file78 #[clap(long)]79 public_file: Option<PathBuf>,8081 /// How to name public secret part82 #[clap(short = 'p', long, default_value = "public")]83 public_part: String,84 /// How to name private secret part85 #[clap(short = 's', long, default_value = "secret")]86 part: String,87 },88 /// Read secret from remote host, requires sudo on said host89 Read {90 name: String,91 #[clap(short = 'm', long)]92 machine: String,9394 /// Which private secret part to read95 #[clap(short = 'p', long, default_value = "secret")]96 part: String,97 },98 /// Read secret from remote host, requires sudo on said host99 ReadShared {100 name: String,101 /// Which private secret part to read102 #[clap(short = 'p', long, default_value = "secret")]103 part: String,104 /// Which host should we use to decrypt, in case if reencryption is required, without105 /// regeneration106 #[clap(long)]107 prefer_identities: Vec<String>,108 },109 UpdateShared {110 name: String,111112 #[clap(short = 'm', long)]113 machine: Option<Vec<String>>,114115 #[clap(long)]116 add_machine: Vec<String>,117 #[clap(long)]118 remove_machine: Vec<String>,119120 /// Which host should we use to decrypt121 #[clap(long)]122 prefer_identities: Vec<String>,123 },124 Regenerate {125 /// Which host should we use to decrypt, in case if reencryption is required, without126 /// regeneration127 #[clap(long)]128 prefer_identities: Vec<String>,129 /// Only regenerate shared secrets130 #[clap(long)]131 skip_hosts: bool,132 },133 List {},134 Edit {135 name: String,136 #[clap(short = 'm', long)]137 machine: String,138139 #[clap(long)]140 add: bool,141142 /// Which private secret part to read143 #[clap(short = 'p', long, default_value = "secret")]144 part: String,145 },146}147148fn secret_needs_regeneration(149 secret: &FleetSecret,150 expected_generation_data: &serde_json::Value,151) -> bool {152 let data_is_expected = secret.generation_data == *expected_generation_data;153 // TODO: Leeway?154 let expired = secret155 .expires_at156 .map(|expiration| expiration < Utc::now())157 .unwrap_or(false);158 expired || !data_is_expected159}160161#[allow(clippy::too_many_arguments)]162#[tracing::instrument(skip(config, secret, field, prefer_identities, batch))]163async fn maybe_regenerate_shared_secret(164 secret_name: &str,165 config: &Config,166 mut secret: FleetSharedSecret,167 field: Value,168 expected_owners: &[String],169 expected_generation_data: serde_json::Value,170 prefer_identities: &[String],171 batch: Option<NixBuildBatch>,172) -> Result<FleetSharedSecret> {173 let original_set = secret.owners.clone();174175 let set = original_set.iter().collect::<BTreeSet<_>>();176 let expected_set = expected_owners.iter().collect::<BTreeSet<_>>();177178 let regeneration_required =179 secret_needs_regeneration(&secret.secret, &expected_generation_data);180181 if set == expected_set && !regeneration_required {182 info!("no need to update owner list, it is already correct");183 return Ok(secret);184 }185186 let should_regenerate = if regeneration_required {187 info!("secret has its generation data changed, regeneration is required");188 true189 } else if set.difference(&expected_set).next().is_some() {190 // TODO: Remove this warning for revokable secrets.191 warn!(192 "host was removed from secret owners, but until this host rebuild, the secret will still be stored on it."193 );194 nix_go_json!(field.regenerateOnOwnerRemoved)195 } else if expected_set.difference(&set).next().is_some() {196 nix_go_json!(field.regenerateOnOwnerAdded)197 } else {198 false199 };200201 if should_regenerate {202 info!("secret needs to be regenerated");203 let generated = generate_shared(204 config,205 secret_name,206 field,207 expected_owners.to_vec(),208 expected_generation_data,209 batch,210 )211 .await?;212 Ok(generated)213 } else {214 drop(batch);215 let identity_holder = if !prefer_identities.is_empty() {216 prefer_identities217 .iter()218 .find(|i| original_set.iter().any(|s| s == *i))219 } else {220 secret.owners.first()221 };222 let Some(identity_holder) = identity_holder else {223 bail!("no available holder found");224 };225226 for (part_name, part) in secret.secret.parts.iter_mut() {227 let _span = info_span!("part reencryption", part_name);228 if !part.raw.encrypted {229 continue;230 }231 let host = config.host(identity_holder).await?;232 let encrypted = host233 .reencrypt(part.raw.clone(), expected_owners.to_vec())234 .await?;235 part.raw = encrypted;236 }237238 secret.owners = expected_owners.to_vec();239 Ok(secret)240 }241}242243#[derive(Deserialize)]244#[serde(rename_all = "camelCase")]245enum GeneratorKind {246 Impure,247 Pure,248}249250async fn generate_pure(251 _config: &Config,252 _display_name: &str,253 _secret: Value,254 _default_generator: Value,255 _owners: &[String],256) -> Result<FleetSecret> {257 bail!("pure generators are broken for now")258}259async fn generate_impure(260 config: &Config,261 _display_name: &str,262 secret: Value,263 default_generator: Value,264 expected_owners: &[String],265 expected_generation_data: serde_json::Value,266 batch: Option<NixBuildBatch>,267) -> Result<FleetSecret> {268 let generator = nix_go!(secret.generator);269 let on: Option<String> = nix_go_json!(default_generator.impureOn);270271 let nixpkgs = &config.nixpkgs;272273 let host = if let Some(on) = &on {274 config.host(on).await?275 } else {276 config.local_host()277 };278 let on_pkgs = host.pkgs().await?;279 let mk_secret_generators = nix_go!(on_pkgs.mkSecretGenerators);280281 let mut recipients = Vec::new();282 for owner in expected_owners {283 let key = config.key(owner).await?;284 recipients.push(key);285 }286 let generators = nix_go!(mk_secret_generators(Obj { recipients }));287 let pkgs_and_generators = nix_go!(on_pkgs + generators);288289 let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));290291 let generator = nix_go!(call_package(generator)(Obj {}));292293 let generator = generator.build_maybe_batch(batch).await?;294 let generator = generator295 .get("out")296 .ok_or_else(|| anyhow!("missing generateImpure out"))?;297 let generator = host.remote_derivation(generator).await?;298299 let out_parent = host.mktemp_dir().await?;300 let out = format!("{out_parent}/out");301302 let mut r#gen = host.cmd(generator).await?;303 r#gen.env("out", &out);304 if on.is_none() {305 // This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.306 let project_path: String = config307 .directory308 .clone()309 .into_os_string()310 .into_string()311 .map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;312 r#gen.env("FLEET_PROJECT", project_path);313 }314 r#gen.run().await.context("impure generator")?;315316 {317 let marker = host.read_file_text(format!("{out}/marker")).await?;318 ensure!(marker == "SUCCESS", "generation not succeeded");319 }320321 let mut parts = BTreeMap::new();322 for part in host.read_dir(&out).await? {323 if part == "created_at" || part == "expires_at" || part == "marker" {324 continue;325 }326 let contents: SecretData = host327 .read_file_text(format!("{out}/{part}"))328 .await?329 .parse()330 .map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;331 parts.insert(part.to_owned(), FleetSecretPart { raw: contents });332 }333334 let created_at = host.read_file_value(format!("{out}/created_at")).await?;335 let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();336337 Ok(FleetSecret {338 created_at,339 expires_at,340 parts,341 generation_data: expected_generation_data,342 })343}344async fn generate(345 config: &Config,346 display_name: &str,347 secret: Value,348 expected_owners: &[String],349 expected_generation_data: serde_json::Value,350 batch: Option<NixBuildBatch>,351) -> Result<FleetSecret> {352 let generator = nix_go!(secret.generator);353 // Can't properly check on nix module system level354 {355 let gen_ty = generator.type_of().await?;356 if gen_ty == "null" {357 bail!("secret has no generator defined, can't automatically generate it.");358 }359 if gen_ty == "set" {360 if !generator.has_field("__functor").await? {361 bail!("generator should be functor, got {gen_ty}");362 }363 } else if gen_ty != "lambda" {364 bail!("generator should be functor, got {gen_ty}");365 }366 }367 let nixpkgs = &config.nixpkgs;368 let default_pkgs = &config.default_pkgs;369 let default_mk_secret_generators = nix_go!(default_pkgs.mkSecretGenerators);370 // Generators provide additional information in passthru, to access371 // passthru we should call generator, but information about where this generator is supposed to build372 // is located in passthru... Thus evaluating generator on host.373 //374 // Maybe it is also possible to do some magic with __functor?375 //376 // I don't want to make modules always responsible for additional secret data anyway,377 // so it should be in derivation, and not in the secret data itself.378 let generators = nix_go!(default_mk_secret_generators(Obj {379 recipients: <Vec<String>>::new(),380 }));381 let pkgs_and_generators = nix_go!(default_pkgs + generators);382383 let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));384 let default_generator = nix_go!(call_package(generator)(Obj {}));385386 let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);387388 match kind {389 GeneratorKind::Impure => {390 generate_impure(391 config,392 display_name,393 secret,394 default_generator,395 expected_owners,396 expected_generation_data,397 batch,398 )399 .await400 }401 GeneratorKind::Pure => {402 generate_pure(403 config,404 display_name,405 secret,406 default_generator,407 expected_owners,408 )409 .await410 }411 }412}413async fn generate_shared(414 config: &Config,415 display_name: &str,416 secret: Value,417 expected_owners: Vec<String>,418 expected_generation_data: serde_json::Value,419 batch: Option<NixBuildBatch>,420) -> Result<FleetSharedSecret> {421 // let owners: Vec<String> = nix_go_json!(secret.expectedOwners);422 Ok(FleetSharedSecret {423 secret: generate(424 config,425 display_name,426 secret,427 &expected_owners,428 expected_generation_data,429 batch,430 )431 .await?,432 owners: expected_owners,433 })434}435436async fn parse_public(437 public: Option<String>,438 public_file: Option<PathBuf>,439) -> Result<Option<SecretData>> {440 Ok(match (public, public_file) {441 (Some(v), None) => Some(SecretData {442 data: v.into(),443 encrypted: false,444 }),445 (None, Some(v)) => Some(SecretData {446 data: read(v).await?,447 encrypted: false,448 }),449 (Some(_), Some(_)) => {450 bail!("only public or public_file should be set")451 }452 (None, None) => None,453 })454}455456async fn parse_secret() -> Result<Option<Vec<u8>>> {457 let mut input = vec![];458 stdin().read_to_end(&mut input)?;459 if input.is_empty() {460 Ok(None)461 } else {462 Ok(Some(input))463 }464}465466fn parse_machines(467 initial: Vec<String>,468 machines: Option<Vec<String>>,469 mut add_machines: Vec<String>,470 mut remove_machines: Vec<String>,471) -> Result<Vec<String>> {472 if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {473 bail!("no operation");474 }475476 let initial_machines = initial.clone();477 let mut target_machines = initial;478 info!("Currently encrypted for {initial_machines:?}");479480 // ensure!(machines.is_some() || !add_machines.is_empty() || )481 if let Some(machines) = machines {482 ensure!(483 add_machines.is_empty() && remove_machines.is_empty(),484 "can't combine --machines and --add-machines/--remove-machines"485 );486 let target = initial_machines.iter().collect::<HashSet<_>>();487 let source = machines.iter().collect::<HashSet<_>>();488 for removed in target.difference(&source) {489 remove_machines.push((*removed).clone());490 }491 for added in source.difference(&target) {492 add_machines.push((*added).clone());493 }494 }495496 for machine in &remove_machines {497 let mut removed = false;498 while let Some(pos) = target_machines.iter().position(|m| m == machine) {499 target_machines.swap_remove(pos);500 removed = true;501 }502 if !removed {503 warn!("secret is not enabled for {machine}");504 }505 }506 for machine in &add_machines {507 if target_machines.iter().any(|m| m == machine) {508 warn!("secret is already added to {machine}");509 } else {510 target_machines.push(machine.to_owned());511 }512 }513 if !remove_machines.is_empty() {514 // TODO: maybe force secret regeneration?515 // Not that useful without revokation.516 warn!(517 "secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret"518 );519 }520 Ok(target_machines)521}522impl Secret {523 pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {524 match self {525 Secret::AddManager => {526 todo!("part of fleet-pusher")527 }528 Secret::ForceKeys => {529 for host in config.list_hosts().await? {530 if opts.should_skip(&host).await? {531 continue;532 }533 config.key(&host.name).await?;534 }535 }536 Secret::AddShared {537 mut machines,538 name,539 force,540 public,541 public_part: public_name,542 public_file,543 expires_at,544 re_add,545 part: part_name,546 } => {547 // TODO: Forbid updating secrets with set expectedOwners (= not user-managed).548549 let exists = config.has_shared(&name);550 if exists && !force && !re_add {551 bail!("secret already defined");552 }553 if re_add {554 // Fixme: use clap to limit this usage555 ensure!(!force, "--force and --readd are not compatible");556 ensure!(exists, "secret doesn't exists");557 ensure!(558 machines.is_empty(),559 "you can't use machines argument for --readd"560 );561 let shared = config.shared_secret(&name)?;562 machines = shared.owners;563 }564565 let recipients = config.recipients(machines.clone()).await?;566567 let mut parts = BTreeMap::new();568569 let mut input = vec![];570 io::stdin().read_to_end(&mut input)?;571572 if !input.is_empty() {573 let encrypted =574 encrypt_secret_data(recipients.iter().map(|r| r as &dyn Recipient), input)575 .ok_or_else(|| anyhow!("no recipients provided"))?;576 parts.insert(part_name, FleetSecretPart { raw: encrypted });577 }578579 if let Some(public) = parse_public(public, public_file).await? {580 parts.insert(public_name, FleetSecretPart { raw: public });581 }582583 config.replace_shared(584 name,585 FleetSharedSecret {586 owners: machines,587 secret: FleetSecret {588 created_at: Utc::now(),589 expires_at,590 parts,591 generation_data: serde_json::Value::Null,592 },593 },594 );595 }596 Secret::Add {597 machine,598 name,599 replace,600 merge,601 public,602 public_part: public_name,603 public_file,604 part: part_name,605 } => {606 if config.has_secret(&machine, &name) && !replace && !merge {607 bail!(608 "secret already defined.\nUse --replace to override, or --merge to add new parts to existing secret"609 );610 }611612 let mut out = if merge && !replace {613 config614 .host_secret(&machine, &name)615 .context("failed to read existing secret for --merge")?616 } else {617 FleetSecret {618 created_at: Utc::now(),619 expires_at: None,620 parts: BTreeMap::new(),621 generation_data: serde_json::Value::Null,622 }623 };624625 if let Some(secret) = parse_secret().await? {626 let recipient = config.recipient(&machine).await?;627 let encrypted = encrypt_secret_data([&recipient as &dyn Recipient], secret)628 .expect("recipient provided");629 if out630 .parts631 .insert(part_name.clone(), FleetSecretPart { raw: encrypted })632 .is_some() && !replace633 {634 bail!("part {part_name:?} is already defined");635 }636 }637638 if let Some(public) = parse_public(public, public_file).await? {639 if out640 .parts641 .insert(public_name.clone(), FleetSecretPart { raw: public })642 .is_some() && !replace643 {644 bail!("part {public_name:?} is already defined");645 }646 };647648 config.insert_secret(&machine, name, out);649 }650 #[allow(clippy::await_holding_refcell_ref)]651 Secret::Read {652 name,653 machine,654 part: part_name,655 } => {656 let secret = config.host_secret(&machine, &name)?;657 let Some(secret) = secret.parts.get(&part_name) else {658 bail!("no part {part_name} in secret {name}");659 };660 let data = if secret.raw.encrypted {661 let host = config.host(&machine).await?;662 host.decrypt(secret.raw.clone()).await?663 } else {664 secret.raw.data.clone()665 };666667 stdout().write_all(&data)?;668 }669 Secret::ReadShared {670 name,671 part: part_name,672 prefer_identities,673 } => {674 let secret = config.shared_secret(&name)?;675 let Some(part) = secret.secret.parts.get(&part_name) else {676 bail!("no part {part_name} in secret {name}");677 };678 let data = if part.raw.encrypted {679 let identity_holder = if !prefer_identities.is_empty() {680 prefer_identities681 .iter()682 .find(|i| secret.owners.iter().any(|s| s == *i))683 } else {684 secret.owners.first()685 };686 let Some(identity_holder) = identity_holder else {687 bail!("no available holder found");688 };689 let host = config.host(identity_holder).await?;690 host.decrypt(part.raw.clone()).await?691 } else {692 part.raw.data.clone()693 };694 stdout().write_all(&data)?;695 }696 Secret::UpdateShared {697 name,698 machine,699 add_machine,700 remove_machine,701 prefer_identities,702 } => {703 // TODO: Forbid updating secrets with set expectedOwners (= not user-managed).704705 let secret = config.shared_secret(&name)?;706 if secret.secret.parts.values().all(|v| !v.raw.encrypted) {707 bail!("no secret");708 }709710 let initial_machines = secret.owners.clone();711 let target_machines = parse_machines(712 initial_machines.clone(),713 machine,714 add_machine,715 remove_machine,716 )?;717718 if target_machines.is_empty() {719 info!("no machines left for secret, removing it");720 config.remove_shared(&name);721 return Ok(());722 }723724 let config_field = &config.config_field;725 let field = nix_go!(config_field.sharedSecrets[{ name }]);726 let expected_generation_data = nix_go_json!(field.expectedGenerationData);727728 let updated = maybe_regenerate_shared_secret(729 &name,730 config,731 secret,732 field,733 &target_machines,734 expected_generation_data,735 &prefer_identities,736 None,737 )738 .await?;739 config.replace_shared(name, updated);740 }741 Secret::Regenerate {742 prefer_identities,743 skip_hosts,744 } => {745 info!("checking for secrets to regenerate");746 let stored_shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();747 {748 // Generate missing shared749 let shared_batch = None;750 let _span = info_span!("shared").entered();751 let expected_shared_set = config752 .list_configured_shared()753 .await?754 .into_iter()755 .collect::<HashSet<_>>();756 for missing in expected_shared_set.difference(&stored_shared_set) {757 let config_field = &config.config_field;758 let secret = nix_go!(config_field.sharedSecrets[{ missing }]);759 let expected_generation_data: serde_json::Value =760 nix_go_json!(secret.expectedGenerationData);761 let expected_owners: Option<Vec<String>> =762 nix_go_json!(secret.expectedOwners);763 let Some(expected_owners) = expected_owners else {764 // Can't generate this missing secret, as it has no defined owners.765 continue;766 };767 info!("generating secret: {missing}");768 let shared = generate_shared(769 config,770 missing,771 secret,772 expected_owners,773 expected_generation_data,774 shared_batch.clone(),775 )776 .in_current_span()777 .await?;778 config.replace_shared(missing.to_string(), shared)779 }780 }781 if !skip_hosts {782 let hosts_batch = None;783 for host in config.list_hosts().await? {784 if opts.should_skip(&host).await? {785 continue;786 }787788 let _span = info_span!("host", host = host.name).entered();789 let expected_set = host790 .list_configured_secrets()791 .in_current_span()792 .await?793 .into_iter()794 .collect::<HashSet<_>>();795 let stored_set = config796 .list_secrets(&host.name)797 .into_iter()798 .collect::<HashSet<_>>();799 for missing in expected_set.difference(&stored_set) {800 info!("generating secret: {missing}");801 let secret = host.secret_field(missing).in_current_span().await?;802 let expected_generation_data =803 nix_go_json!(secret.expectedGenerationData);804 let generated = match generate(805 config,806 missing,807 secret,808 &[host.name.clone()],809 expected_generation_data,810 hosts_batch.clone(),811 )812 .in_current_span()813 .await814 {815 Ok(v) => v,816 Err(e) => {817 error!("{e:?}");818 continue;819 }820 };821 config.insert_secret(&host.name, missing.to_string(), generated)822 }823 for name in stored_set {824 info!("updating secret: {name}");825 let data = config.host_secret(&host.name, &name)?;826 let secret = host.secret_field(&name).in_current_span().await?;827 let expected_generation_data =828 nix_go_json!(secret.expectedGenerationData);829 if secret_needs_regeneration(&data, &expected_generation_data) {830 let generated = match generate(831 config,832 &name,833 secret,834 &[host.name.clone()],835 expected_generation_data,836 hosts_batch.clone(),837 )838 .in_current_span()839 .await840 {841 Ok(v) => v,842 Err(e) => {843 error!("{e:?}");844 continue;845 }846 };847 config.insert_secret(&host.name, name.to_string(), generated)848 }849 }850 }851 }852 let mut to_remove = Vec::new();853 for name in &stored_shared_set {854 info!("updating secret: {name}");855 let data = config.shared_secret(name)?;856 let config_field = &config.config_field;857 let expected_owners: Option<Vec<String>> =858 nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);859 let Some(expected_owners) = expected_owners else {860 warn!("secret was removed from fleet config: {name}, removing from data");861 to_remove.push(name.to_string());862 continue;863 };864865 let secret = nix_go!(config_field.sharedSecrets[{ name }]);866 let expected_generation_data = nix_go_json!(secret.expectedGenerationData);867 config.replace_shared(868 name.to_owned(),869 maybe_regenerate_shared_secret(870 name,871 config,872 data,873 secret,874 &expected_owners,875 expected_generation_data,876 &prefer_identities,877 None,878 )879 .await?,880 );881 }882 for k in to_remove {883 config.remove_shared(&k);884 }885 }886 Secret::List {} => {887 let _span = info_span!("loading secrets").entered();888 let configured = config.list_configured_shared().await?;889 #[derive(Tabled)]890 struct SecretDisplay {891 #[tabled(rename = "Name")]892 name: String,893 #[tabled(rename = "Owners")]894 owners: String,895 }896 let mut table = vec![];897 for name in configured.iter().cloned() {898 let config = config.clone();899 let expected_owners = config.shared_secret_expected_owners(&name).await?;900 let data = config.shared_secret(&name)?;901 let owners = data902 .owners903 .iter()904 .map(|o| {905 if expected_owners.contains(o) {906 o.green().to_string()907 } else {908 o.red().to_string()909 }910 })911 .collect::<Vec<_>>();912 table.push(SecretDisplay {913 owners: owners.join(", "),914 name,915 })916 }917 info!("loaded\n{}", Table::new(table).to_string())918 }919 Secret::Edit {920 name,921 machine,922 part,923 add,924 } => {925 let secret = config.host_secret(&machine, &name)?;926 if let Some(data) = secret.parts.get(&part) {927 let host = config.host(&machine).await?;928 let secret = host.decrypt(data.raw.clone()).await?;929 String::from_utf8(secret).context("secret is not utf8")?930 } else if add {931 String::new()932 } else {933 bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");934 };935 }936 }937 Ok(())938 }939}940941/*942async fn edit_temp_file(943 builder: tempfile::Builder<'_, '_>,944 r: Vec<u8>,945 header: &str,946 comment: &str,947) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {948 if !stdin().is_tty() {949 // TODO: Also try to open /dev/tty directly?950 bail!("stdin is not tty, can't open editor");951 }952953 use std::fmt::Write;954 let mut file = builder.tempfile()?;955956 let mut full_header = String::new();957 let mut had = false;958 for line in header.trim_end().lines() {959 had = true;960 writeln!(&mut full_header, "{comment}{line}")?;961 }962 if had {963 writeln!(&mut full_header, "{}", comment.trim_end())?;964 }965 writeln!(966 &mut full_header,967 "{comment}Do not touch this header! It will be removed automatically"968 )?;969970 file.write_all(full_header.as_bytes())?;971 file.write_all(&r)?;972973 let abs_path = file.into_temp_path();974 let editor = std::env::var_os("VISUAL")975 .or_else(|| std::env::var_os("EDITOR"))976 .unwrap_or_else(|| "vi".into());977 let editor_args = shlex::bytes::split(editor.as_encoded_bytes())978 .ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;979 let editor_args = editor_args980 .into_iter()981 .map(|v| {982 // Only ASCII subsequences are replaced983 unsafe { OsString::from_encoded_bytes_unchecked(v) }984 })985 .collect_vec();986 let Some((editor, args)) = editor_args.split_first() else {987 bail!("EDITOR env var has no command");988 };989 let mut command = Command::new(editor);990 command.args(args);991992 let path_arg = abs_path.canonicalize()?;993994 // TODO: Save full state, using tcget/_getmode/_setmode995 let was_raw = terminal::is_raw_mode_enabled()?;996 terminal::enable_raw_mode()?;997998 let status = command.arg(path_arg).status().await;9991000 if !was_raw {1001 terminal::disable_raw_mode()?;1002 }10031004 let success = match status {1005 Ok(s) => s.success(),1006 Err(e) if e.kind() == io::ErrorKind::NotFound => {1007 bail!("editor not found")1008 }1009 Err(e) => bail!("editor spawn error: {e}"),1010 };10111012 let mut file = std::fs::read(&abs_path).context("read editor output")?;1013 let Some(v) = file.strip_prefix(full_header.as_bytes()) else {1014 todo!();1015 };1016 todo!();10171018 // Ok((success, abs_path))1019}1020*/crates/fleet-base/src/fleetdata.rsdiffbeforeafterboth--- a/crates/fleet-base/src/fleetdata.rs
+++ b/crates/fleet-base/src/fleetdata.rs
@@ -53,12 +53,22 @@
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
+pub struct ManagerKey {
+ pub name: String,
+ pub key: String,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
pub struct FleetData {
pub version: FleetDataVersion,
#[serde(default = "generate_gc_prefix")]
pub gc_root_prefix: String,
#[serde(default)]
+ pub manager_keys: Vec<ManagerKey>,
+
+ #[serde(default)]
pub hosts: BTreeMap<String, HostData>,
#[serde(default)]
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
flake.lockdiffbeforeafterboth--- a/flake.lock
+++ b/flake.lock
@@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
- "lastModified": 1750266157,
- "narHash": "sha256-tL42YoNg9y30u7zAqtoGDNdTyXTi8EALDeCB13FtbQA=",
+ "lastModified": 1753316655,
+ "narHash": "sha256-tzWa2kmTEN69OEMhxFy+J2oWSvZP5QhEgXp3TROOzl0=",
"owner": "ipetkov",
"repo": "crane",
- "rev": "e37c943371b73ed87faf33f7583860f81f1d5a48",
+ "rev": "f35a3372d070c9e9ccb63ba7ce347f0634ddf3d2",
"type": "github"
},
"original": {
@@ -22,11 +22,11 @@
]
},
"locked": {
- "lastModified": 1749398372,
- "narHash": "sha256-tYBdgS56eXYaWVW3fsnPQ/nFlgWi/Z2Ymhyu21zVM98=",
+ "lastModified": 1753121425,
+ "narHash": "sha256-TVcTNvOeWWk1DXljFxVRp+E0tzG1LhrVjOGGoMHuXio=",
"owner": "hercules-ci",
"repo": "flake-parts",
- "rev": "9305fe4e5c2a6fcf5ba6a3ff155720fbe4076569",
+ "rev": "644e0fc48951a860279da645ba77fe4a6e814c5e",
"type": "github"
},
"original": {
@@ -37,11 +37,11 @@
},
"nixpkgs": {
"locked": {
- "lastModified": 1750895632,
- "narHash": "sha256-EPZWiRmaSTxoBArK5dQyRlSNVLXiBt2hmsYIPgMf3zk=",
+ "lastModified": 1753320130,
+ "narHash": "sha256-KCuv6iYQ0XTVAEJvDLIsk99CJm7fuqIE0/KknyeYPtM=",
"owner": "nixos",
"repo": "nixpkgs",
- "rev": "6ac57ce7fee0d80226095a57ccb7519855ad7c5e",
+ "rev": "788cc7374af486168b8aab6ca49e316c03508a86",
"type": "github"
},
"original": {
@@ -68,11 +68,11 @@
]
},
"locked": {
- "lastModified": 1750819193,
- "narHash": "sha256-XvkupGPZqD54HuKhN/2WhbKjAHeTl1UEnWspzUzRFfA=",
+ "lastModified": 1753238793,
+ "narHash": "sha256-jmQeEpgX+++MEgrcikcwoSiI7vDZWLP0gci7XiWb9uQ=",
"owner": "oxalica",
"repo": "rust-overlay",
- "rev": "1ba3b9c59b68a4b00156827ad46393127b51b808",
+ "rev": "0ad7ab4ca8e83febf147197e65c006dff60623ab",
"type": "github"
},
"original": {
@@ -103,11 +103,11 @@
]
},
"locked": {
- "lastModified": 1749194973,
- "narHash": "sha256-eEy8cuS0mZ2j/r/FE0/LYBSBcIs/MKOIVakwHVuqTfk=",
+ "lastModified": 1753006367,
+ "narHash": "sha256-tzbhc4XttkyEhswByk5R38l+ztN9UDbnj0cTcP6Hp9A=",
"owner": "numtide",
"repo": "treefmt-nix",
- "rev": "a05be418a1af1198ca0f63facb13c985db4cb3c5",
+ "rev": "421b56313c65a0815a52b424777f55acf0b56ddf",
"type": "github"
},
"original": {
modules/secrets-data.nixdiffbeforeafterboth--- a/modules/secrets-data.nix
+++ b/modules/secrets-data.nix
@@ -94,12 +94,28 @@
};
config = { };
};
+ managerKey = {
+ options = {
+ name = mkOption {
+ type = str;
+ description = "Who does this manager key belongs to.";
+ };
+ key = mkOption {
+ type = str;
+ description = "Age-compatible key";
+ };
+ };
+ config = {};
+ };
in
{
options.data = mkDataOption (
{ config, ... }:
{
options = {
+ managerKeys = mkOption {
+ type = listOf (submodule managerKey);
+ };
sharedSecrets = mkOption {
type = attrsOf (submodule sharedSecretData);
default = { };
rust-toolchain.tomldiffbeforeafterboth--- a/rust-toolchain.toml
+++ b/rust-toolchain.toml
@@ -1,3 +1,3 @@
[toolchain]
-channel = "1.86.0"
+channel = "nightly-2025-06-10"
components = ["rustfmt", "clippy", "rust-analyzer", "rust-src"]