difftreelog
feat use builtin for getting secret
in: trunk
12 files changed
Cargo.lockdiffbeforeafterboth--- a/Cargo.lock
+++ b/Cargo.lock
@@ -723,15 +723,6 @@
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
-name = "convert_case"
-version = "0.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7"
-dependencies = [
- "unicode-segmentation",
-]
-
-[[package]]
name = "cookie-factory"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -765,34 +756,6 @@
]
[[package]]
-name = "crossterm"
-version = "0.29.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
-dependencies = [
- "bitflags",
- "crossterm_winapi",
- "derive_more",
- "document-features",
- "filedescriptor",
- "mio",
- "parking_lot",
- "rustix 1.1.2",
- "signal-hook",
- "signal-hook-mio",
- "winapi",
-]
-
-[[package]]
-name = "crossterm_winapi"
-version = "0.9.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
-dependencies = [
- "winapi",
-]
-
-[[package]]
name = "crypto-common"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -932,27 +895,6 @@
dependencies = [
"powerfmt",
"serde_core",
-]
-
-[[package]]
-name = "derive_more"
-version = "2.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
-dependencies = [
- "derive_more-impl",
-]
-
-[[package]]
-name = "derive_more-impl"
-version = "2.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
-dependencies = [
- "convert_case",
- "proc-macro2",
- "quote",
- "syn",
]
[[package]]
@@ -976,15 +918,6 @@
"proc-macro2",
"quote",
"syn",
-]
-
-[[package]]
-name = "document-features"
-version = "0.2.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d"
-dependencies = [
- "litrs",
]
[[package]]
@@ -1073,17 +1006,6 @@
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
-name = "filedescriptor"
-version = "0.8.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
-dependencies = [
- "libc",
- "thiserror 1.0.69",
- "winapi",
-]
-
-[[package]]
name = "find-crate"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1128,7 +1050,6 @@
"chrono",
"clap",
"clap_complete",
- "crossterm",
"fleet-base",
"fleet-shared",
"futures",
@@ -1142,7 +1063,6 @@
"openssh",
"opentelemetry",
"opentelemetry_sdk",
- "owo-colors",
"peg",
"regex",
"serde",
@@ -1503,12 +1423,6 @@
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
-name = "hermit-abi"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
-
-[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1961,24 +1875,7 @@
dependencies = [
"memchr",
"serde",
-]
-
-[[package]]
-name = "is-terminal"
-version = "0.4.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
-dependencies = [
- "hermit-abi",
- "libc",
- "windows-sys 0.59.0",
]
-
-[[package]]
-name = "is_ci"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
[[package]]
name = "is_terminal_polyfill"
@@ -2083,12 +1980,6 @@
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
-
-[[package]]
-name = "litrs"
-version = "0.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed"
[[package]]
name = "lock_api"
@@ -2161,7 +2052,6 @@
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
dependencies = [
"libc",
- "log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.59.0",
]
@@ -2428,16 +2318,6 @@
]
[[package]]
-name = "owo-colors"
-version = "4.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52"
-dependencies = [
- "supports-color 2.1.0",
- "supports-color 3.0.2",
-]
-
-[[package]]
name = "papergrid"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3335,27 +3215,6 @@
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
-
-[[package]]
-name = "signal-hook"
-version = "0.3.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
-dependencies = [
- "libc",
- "signal-hook-registry",
-]
-
-[[package]]
-name = "signal-hook-mio"
-version = "0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd"
-dependencies = [
- "libc",
- "mio",
- "signal-hook",
-]
[[package]]
name = "signal-hook-registry"
@@ -3449,25 +3308,6 @@
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
-name = "supports-color"
-version = "2.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89"
-dependencies = [
- "is-terminal",
- "is_ci",
-]
-
-[[package]]
-name = "supports-color"
-version = "3.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6"
-dependencies = [
- "is_ci",
-]
-
-[[package]]
name = "syn"
version = "2.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4170,12 +4010,6 @@
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
[[package]]
-name = "unicode-segmentation"
-version = "1.12.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
-
-[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4448,25 +4282,9 @@
"home",
"once_cell",
"rustix 0.38.44",
-]
-
-[[package]]
-name = "winapi"
-version = "0.3.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
-dependencies = [
- "winapi-i686-pc-windows-gnu",
- "winapi-x86_64-pc-windows-gnu",
]
[[package]]
-name = "winapi-i686-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
-
-[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4474,12 +4292,6 @@
dependencies = [
"windows-sys 0.61.2",
]
-
-[[package]]
-name = "winapi-x86_64-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
README.adocdiffbeforeafterboth--- a/README.adoc
+++ b/README.adoc
@@ -211,7 +211,7 @@
];
# And finally, I have secrets, which are shared between machines.
# Note that this example is somewhat wrong, as this goes not into the machine configuration, but to fleet configuration.
- sharedSecrets = {
+ secrets = {
"ca.pem" = {
# This is just the public key, no need to regenerate it to change owner list
regenerateOnOwnerAdded = false;
cmds/fleet/Cargo.tomldiffbeforeafterboth--- a/cmds/fleet/Cargo.toml
+++ b/cmds/fleet/Cargo.toml
@@ -28,12 +28,10 @@
async-trait = "0.1"
base64 = "0.22.1"
chrono = { version = "0.4", features = ["serde"] }
-crossterm = { version = "0.29.0", features = ["use-dev-tty"] }
futures = "0.3"
hostname = "0.4.1"
itertools = "0.14"
openssh = "0.11"
-owo-colors = { version = "4.2", features = ["supports-color", "supports-colors"] }
peg = "0.8"
regex = "1.11"
shlex = "1.3"
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 anyhow::{Context, Result, anyhow, bail, ensure};8use chrono::{DateTime, Utc};9use clap::Parser;10use fleet_base::{11 fleetdata::{FleetSecretData, FleetSecretDistribution, FleetSecretPart, encrypt_secret_data},12 host::Config,13 opts::FleetOpts,14 secret::{Expectations, RegenerationReason, SharedSecretDefinition, secret_needs_regeneration},15};16use fleet_shared::SecretData;17use nix_eval::{NixType, Value, nix_go, nix_go_json};18use owo_colors::OwoColorize;19use serde::Deserialize;20use tabled::{Table, Tabled};21use tokio::{fs::read, task::spawn_blocking};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 /// Read secret from remote host, requires sudo on one of the owning hosts30 Read {31 /// Secret name to read32 name: String,3334 /// Distribution with what machine to read35 /// If not shared between multiple - defaults to single owner36 #[clap(short = 'm', long)]37 machine: Option<String>,3839 /// Which private secret part to read40 #[clap(short = 'p', long, default_value = "secret")]41 part: String,4243 /// Which host should we use to decrypt, in case if reencryption is required, without44 /// regeneration45 #[clap(long)]46 prefer_identities: Vec<String>,47 },48 Regenerate {49 /// Which host should we use to decrypt, in case if reencryption is required, without50 /// regeneration51 #[clap(long)]52 prefer_identities: Vec<String>,53 /// Only regenerate shared secrets54 #[clap(long)]55 skip_hosts: bool,56 },57 List {},58 Edit {59 name: String,60 #[clap(short = 'm', long)]61 machine: String,6263 #[clap(long)]64 add: bool,6566 /// Which private secret part to read67 #[clap(short = 'p', long, default_value = "secret")]68 part: String,69 },70}7172#[allow(clippy::too_many_arguments)]73#[tracing::instrument(skip(config, secret, definition, prefer_identities))]74async fn maybe_regenerate_shared_secret(75 secret_name: &str,76 config: &Config,77 mut secret: FleetSecretDistribution,78 definition: SharedSecretDefinition,79 prefer_identities: &[String],80 expectations: &Expectations,81) -> Result<FleetSecretDistribution> {82 let reason = secret_needs_regeneration(&secret.secret, &secret.owners, expectations);83 let value = definition.definition_value();8485 let (should_reencrypt, reason) = match reason {86 Some(RegenerationReason::OwnersAdded(_)) => {87 // Secret always needs to be reencrypted for new owners to be able to read it88 (89 true,90 if nix_go_json!(value.regenerateOnOwnerAdded) {91 reason92 } else {93 None94 },95 )96 }97 Some(RegenerationReason::OwnersRemoved(_)) => {98 // No need to reencrypt, we can just leave stanzas in place.99 if nix_go_json!(value.regenerateOnOwnerRemoved) {100 (true, reason)101 } else {102 (false, None)103 }104 }105 Some(_) => (true, reason),106 None => (false, None),107 };108109 if let Some(reason) = reason {110 info!("secret needs to be regenerated: {reason}");111 let generated = generate_shared(config, secret_name, definition, expectations).await?;112 Ok(generated)113 } else if should_reencrypt {114 info!("secret needs to be reencrypted");115 let identity_holder = if !prefer_identities.is_empty() {116 prefer_identities117 .iter()118 .find(|i| secret.owners.iter().any(|s| s == *i))119 } else {120 secret.owners.first()121 };122 let Some(identity_holder) = identity_holder else {123 bail!("no available holder found");124 };125126 for (part_name, part) in secret.secret.parts.iter_mut() {127 let _span = info_span!("part reencryption", part_name);128 if !part.raw.encrypted {129 continue;130 }131 let host = config.host(identity_holder).await?;132 let encrypted = host133 .reencrypt(134 part.raw.clone(),135 expectations.owners.iter().cloned().collect(),136 )137 .await?;138 part.raw = encrypted;139 }140 secret.owners = expectations.owners.clone();141 Ok(secret)142 } else {143 Ok(secret)144 }145}146147#[derive(Deserialize)]148#[serde(rename_all = "camelCase")]149enum GeneratorKind {150 Impure,151 Pure,152}153154async fn generate_pure(155 _config: &Config,156 _display_name: &str,157 _secret: Value,158 _default_generator: Value,159 _expectations: &Expectations,160) -> Result<FleetSecretData> {161 bail!("pure generators are broken for now")162}163async fn generate_impure(164 config: &Config,165 _display_name: &str,166 secret: Value,167 default_generator: Value,168 expectations: &Expectations,169) -> Result<FleetSecretData> {170 let generator = nix_go!(secret.generator);171 let on: Option<String> = nix_go_json!(default_generator.impureOn);172173 let nixpkgs = &config.nixpkgs;174175 let host = if let Some(on) = &on {176 config.host(on).await?177 } else {178 config.local_host()179 };180 let on_pkgs = host.pkgs().await?;181 let mk_secret_generators = nix_go!(on_pkgs.mkSecretGenerators);182183 let mut recipients = Vec::new();184 for owner in &expectations.owners {185 let key = config.key(owner).await?;186 recipients.push(key);187 }188 let generators = nix_go!(mk_secret_generators(Obj { recipients }));189 let pkgs_and_generators = on_pkgs.attrs_update(generators)?;190191 let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));192193 let generator = nix_go!(call_package(generator)(Obj {}));194195 let generator = spawn_blocking(move || generator.build("out"))196 .await197 .expect("nix build shouldn't fail")?;198 let generator = host.remote_derivation(&generator).await?;199200 let out_parent = host.mktemp_dir().await?;201 let out = format!("{out_parent}/out");202203 let mut r#gen = host.cmd(generator).await?;204 r#gen.env("out", &out);205 if on.is_none() {206 // This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.207 let project_path: String = config208 .directory209 .clone()210 .into_os_string()211 .into_string()212 .map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;213 r#gen.env("FLEET_PROJECT", project_path);214 }215 r#gen.run().await.context("impure generator")?;216217 {218 let marker = host.read_file_text(format!("{out}/marker")).await?;219 ensure!(marker == "SUCCESS", "generation not succeeded");220 }221222 let mut parts = BTreeMap::new();223 for part in host.read_dir(&out).await? {224 if part == "created_at" || part == "expires_at" || part == "marker" {225 continue;226 }227 let contents: SecretData = host228 .read_file_text(format!("{out}/{part}"))229 .await?230 .parse()231 .map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;232 parts.insert(part.to_owned(), FleetSecretPart { raw: contents });233 }234235 let created_at = host.read_file_value(format!("{out}/created_at")).await?;236 let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();237238 let new_data = FleetSecretData {239 created_at,240 expires_at,241 parts,242 generation_data: expectations.generation_data.clone(),243 };244245 if let Some(reason) = secret_needs_regeneration(&new_data, &expectations.owners, expectations) {246 bail!("newly generated secret needs to be regenerated: {reason}")247 }248249 Ok(new_data)250}251252async fn generate(253 config: &Config,254 display_name: &str,255 secret: Value,256 expectations: &Expectations,257) -> Result<FleetSecretData> {258 let generator = nix_go!(secret.generator);259 // Can't properly check on nix module system level260 {261 let gen_ty = generator.type_of();262 if matches!(gen_ty, NixType::Null) {263 bail!("secret has no generator defined, can't automatically generate it.");264 }265 if matches!(gen_ty, NixType::Attrs) {266 if !generator.has_field("__functor")? {267 bail!("generator should be functor, got {gen_ty:?}");268 }269 } else if matches!(gen_ty, NixType::Function) {270 bail!("generator should be functor, got {gen_ty:?}");271 }272 }273 let nixpkgs = &config.nixpkgs;274 let default_pkgs = &config.default_pkgs;275 let default_mk_secret_generators = nix_go!(default_pkgs.mkSecretGenerators);276 // Generators provide additional information in passthru, to access277 // passthru we should call generator, but information about where this generator is supposed to build278 // is located in passthru... Thus evaluating generator on host.279 //280 // Maybe it is also possible to do some magic with __functor?281 //282 // I don't want to make modules always responsible for additional secret data anyway,283 // so it should be in derivation, and not in the secret data itself.284 let generators = nix_go!(default_mk_secret_generators(Obj {285 recipients: <Vec<String>>::new(),286 }));287 let pkgs_and_generators = default_pkgs.clone().attrs_update(generators)?;288289 let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));290 let default_generator = nix_go!(call_package(generator)(Obj {}));291292 let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);293294 match kind {295 GeneratorKind::Impure => {296 generate_impure(297 config,298 display_name,299 secret,300 default_generator,301 expectations,302 )303 .await304 }305 GeneratorKind::Pure => {306 generate_pure(307 config,308 display_name,309 secret,310 default_generator,311 expectations,312 )313 .await314 }315 }316}317async fn generate_shared(318 config: &Config,319 display_name: &str,320 secret: SharedSecretDefinition,321 expectations: &Expectations,322) -> Result<FleetSecretDistribution> {323 // let owners: Vec<String> = nix_go_json!(secret.expectedOwners);324 Ok(FleetSecretDistribution {325 managed: Some(true),326 secret: generate(327 config,328 display_name,329 secret.definition_value(),330 expectations,331 )332 .await?,333 owners: expectations.owners.clone(),334 })335}336337async fn parse_public(338 public: Option<String>,339 public_file: Option<PathBuf>,340) -> Result<Option<SecretData>> {341 Ok(match (public, public_file) {342 (Some(v), None) => Some(SecretData {343 data: v.into(),344 encrypted: false,345 }),346 (None, Some(v)) => Some(SecretData {347 data: read(v).await?,348 encrypted: false,349 }),350 (Some(_), Some(_)) => {351 bail!("only public or public_file should be set")352 }353 (None, None) => None,354 })355}356357async fn parse_secret() -> Result<Option<Vec<u8>>> {358 let mut input = vec![];359 stdin().read_to_end(&mut input)?;360 if input.is_empty() {361 Ok(None)362 } else {363 Ok(Some(input))364 }365}366367fn parse_machines(368 initial: BTreeSet<String>,369 machines: Option<Vec<String>>,370 mut add_machines: Vec<String>,371 mut remove_machines: Vec<String>,372) -> Result<BTreeSet<String>> {373 if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {374 bail!("no operation");375 }376377 let initial_machines = initial.clone();378 let mut target_machines = initial;379 info!("Currently encrypted for {initial_machines:?}");380381 if let Some(machines) = machines {382 ensure!(383 add_machines.is_empty() && remove_machines.is_empty(),384 "can't combine --machines and --add-machines/--remove-machines"385 );386 let target = initial_machines.iter().collect::<HashSet<_>>();387 let source = machines.iter().collect::<HashSet<_>>();388 for removed in target.difference(&source) {389 remove_machines.push((*removed).clone());390 }391 for added in source.difference(&target) {392 add_machines.push((*added).clone());393 }394 }395396 for machine in &remove_machines {397 if !target_machines.remove(machine) {398 warn!("secret is not enabled for {machine}");399 }400 }401 for machine in &add_machines {402 if !target_machines.insert(machine.to_owned()) {403 warn!("secret is already added to {machine}");404 }405 }406 if !remove_machines.is_empty() {407 // TODO: maybe force secret regeneration?408 // Not that useful without revokation.409 warn!(410 "secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret"411 );412 }413 Ok(target_machines)414}415impl Secret {416 pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {417 match self {418 Secret::AddManager => {419 todo!("part of fleet-pusher")420 }421 Secret::ForceKeys => {422 for host in config.list_hosts().await? {423 if opts.should_skip(&host).await? {424 continue;425 }426 config.key(&host.name).await?;427 }428 }429 Secret::Read {430 name,431 machine,432 part: part_name,433 mut prefer_identities,434 } => {435 let Some(secret) = config.shared_secret(&name) else {436 bail!("secret doesn't exists");437 };438439 let dist = if secret.len() == 1 {440 &secret[0]441 } else if let Some(machine) = machine {442 let dist = secret.get(&machine);443 let Some(dist) = dist else {444 bail!("machine {machine} has no distribution of secret {name}");445 };446 prefer_identities.push(machine);447 dist448 } else {449 bail!(450 "secret {name} has shares, but no --machine specified for specifing which do you need"451 )452 };453454 let Some(part) = dist.secret.parts.get(&part_name) else {455 bail!("no part {part_name} in secret {name}");456 };457 let data = if part.raw.encrypted {458 let identity_holder = if !prefer_identities.is_empty() {459 prefer_identities460 .iter()461 .find(|i| dist.owners.iter().any(|s| s == *i))462 } else {463 dist.owners.first()464 };465 let Some(identity_holder) = identity_holder else {466 bail!("no available holder found");467 };468 let host = config.host(identity_holder).await?;469 host.decrypt(part.raw.clone()).await?470 } else {471 part.raw.data.clone()472 };473 stdout().write_all(&data)?;474 }475 Secret::Regenerate {476 prefer_identities,477 skip_hosts,478 } => {479 /*480 info!("checking for secrets to regenerate");481 let expected_shared_set = config482 .list_configured_shared()483 .await?484 .into_iter()485 .collect::<HashSet<_>>();486 let stored_shared_set = config.list_secrets().into_iter().collect::<HashSet<_>>();487 {488 // Generate missing shared489 let _span = info_span!("shared").entered();490 for missing in expected_shared_set.difference(&stored_shared_set) {491 let definition = config.shared_secret_definition(missing)?;492 if !definition.is_managed()? {493 info!("skipping unmanaged secret: {missing}");494 continue;495 }496 let expectations = definition497 .expectations()498 .with_context(|| format!("expectations for shared {missing:?}"))?;499 info!("generating secret: {missing}");500 let shared = generate_shared(config, missing, definition, &expectations)501 .in_current_span()502 .await?;503 config.replace_shared(missing.to_string(), shared)504 }505 }506 if !skip_hosts {507 for host in config.list_hosts().await? {508 if opts.should_skip(&host).await? {509 continue;510 }511512 let _span = info_span!("host", host = host.name).entered();513 let expected_set = host514 .list_defined_secrets()?515 .into_iter()516 .collect::<HashSet<_>>();517 let stored_set = config518 .list_secrets_for_owner(&host.name)519 .into_iter()520 .collect::<HashSet<_>>();521 for missing_secret in expected_set.difference(&stored_set) {522 let secret = host.secret_definition(missing_secret)?;523 if secret.is_shared()? {524 continue;525 }526 info!("generating missing secret: {missing_secret}");527 let expectations = secret.expectations().with_context(|| {528 format!("expectations for {missing_secret:?} of {:?}", host.name)529 })?;530 let generated = match generate(531 config,532 missing_secret,533 secret.definition_value()?,534 &expectations,535 )536 .in_current_span()537 .await538 {539 Ok(v) => v,540 Err(e) => {541 error!("{e:?}");542 continue;543 }544 };545 config.insert_secret(host.name, missing_secret.to_string(), generated)546 }547 for known_secret in stored_set.intersection(&expected_set) {548 let secret = host.secret_definition(known_secret)?;549 if secret.is_shared()? {550 continue;551 }552 info!("updating secret: {known_secret}");553 let data = config.host_secret(&host.name, known_secret)?;554 let expectations = secret.expectations()?;555 if let Some(regen_reason) = data.needs_regeneration(&expectations) {556 info!("needs regeneration: {regen_reason}");557 let generated = match generate(558 config,559 known_secret,560 secret.definition_value()?,561 &expectations,562 )563 .in_current_span()564 .await565 {566 Ok(v) => v,567 Err(e) => {568 error!("{e:?}");569 continue;570 }571 };572 config.insert_secret(573 &host.name,574 known_secret.to_string(),575 FleetLegacyHostSecret {576 managed: Some(true),577 secret: generated,578 },579 )580 }581 }582 for removed_secret in stored_set.difference(&expected_set) {583 let definition = host.secret_definition(removed_secret)?;584 if definition.is_shared()? {585 continue;586 }587 info!("removing secret: {removed_secret}");588 config.remove_secret(&host.name, removed_secret);589 }590 }591 }592 for known_secret in stored_shared_set.intersection(&expected_shared_set) {593 info!("updating shared secret: {known_secret}");594 let data = config.shared_secret(known_secret)?.expect("exists");595596 let definition = config.shared_secret_definition(known_secret)?;597 let expectations = definition.expectations()?;598 config.replace_shared(599 known_secret.to_owned(),600 maybe_regenerate_shared_secret(601 known_secret,602 config,603 data,604 definition,605 &prefer_identities,606 &expectations,607 )608 .await?,609 );610 }611 for removed_secret in stored_shared_set.difference(&expected_shared_set) {612 info!("removing shared secret: {removed_secret}");613 config.remove_shared(removed_secret);614 }615 */616 todo!()617 }618 Secret::List {} => {619 let _span = info_span!("loading secrets").entered();620 let configured = config.list_configured_shared().await?;621 #[derive(Tabled)]622 struct SecretDisplay {623 #[tabled(rename = "Name")]624 name: String,625 #[tabled(rename = "Owners")]626 owners: String,627 }628 let mut table = vec![];629 for name in configured.iter().cloned() {630 let config = config.clone();631 let data = config.shared_secret(&name).expect("exists");632 let definition = config.shared_secret_definition(&name)?;633 let expectations = definition.expectations()?;634 let owners = data635 .owners()636 .map(|o| {637 if expectations.owners.contains(o) {638 o.green().to_string()639 } else {640 o.red().to_string()641 }642 })643 .collect::<Vec<_>>();644 table.push(SecretDisplay {645 owners: owners.join(", "),646 name,647 })648 }649 info!("loaded\n{}", Table::new(table).to_string())650 }651 Secret::Edit {652 name,653 machine,654 part,655 add,656 } => {657 let secret = config658 .host_secret(&machine, &name)659 .context("secret not found")?;660 if let Some(data) = secret.secret.parts.get(&part) {661 let host = config.host(&machine).await?;662 let secret = host.decrypt(data.raw.clone()).await?;663 String::from_utf8(secret).context("secret is not utf8")?664 } else if add {665 String::new()666 } else {667 bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");668 };669 }670 }671 Ok(())672 }673}674675/*676async fn edit_temp_file(677 builder: tempfile::Builder<'_, '_>,678 r: Vec<u8>,679 header: &str,680 comment: &str,681) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {682 if !stdin().is_tty() {683 // TODO: Also try to open /dev/tty directly?684 bail!("stdin is not tty, can't open editor");685 }686687 use std::fmt::Write;688 let mut file = builder.tempfile()?;689690 let mut full_header = String::new();691 let mut had = false;692 for line in header.trim_end().lines() {693 had = true;694 writeln!(&mut full_header, "{comment}{line}")?;695 }696 if had {697 writeln!(&mut full_header, "{}", comment.trim_end())?;698 }699 writeln!(700 &mut full_header,701 "{comment}Do not touch this header! It will be removed automatically"702 )?;703704 file.write_all(full_header.as_bytes())?;705 file.write_all(&r)?;706707 let abs_path = file.into_temp_path();708 let editor = std::env::var_os("VISUAL")709 .or_else(|| std::env::var_os("EDITOR"))710 .unwrap_or_else(|| "vi".into());711 let editor_args = shlex::bytes::split(editor.as_encoded_bytes())712 .ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;713 let editor_args = editor_args714 .into_iter()715 .map(|v| {716 // Only ASCII subsequences are replaced717 unsafe { OsString::from_encoded_bytes_unchecked(v) }718 })719 .collect_vec();720 let Some((editor, args)) = editor_args.split_first() else {721 bail!("EDITOR env var has no command");722 };723 let mut command = Command::new(editor);724 command.args(args);725726 let path_arg = abs_path.canonicalize()?;727728 // TODO: Save full state, using tcget/_getmode/_setmode729 let was_raw = terminal::is_raw_mode_enabled()?;730 terminal::enable_raw_mode()?;731732 let status = command.arg(path_arg).status().await;733734 if !was_raw {735 terminal::disable_raw_mode()?;736 }737738 let success = match status {739 Ok(s) => s.success(),740 Err(e) if e.kind() == io::ErrorKind::NotFound => {741 bail!("editor not found")742 }743 Err(e) => bail!("editor spawn error: {e}"),744 };745746 let mut file = std::fs::read(&abs_path).context("read editor output")?;747 let Some(v) = file.strip_prefix(full_header.as_bytes()) else {748 todo!();749 };750 todo!();751752 // Ok((success, abs_path))753}754*/1use std::{2 collections::{BTreeMap, BTreeSet, HashSet},3 io::{self, Read, Write, stdin, stdout},4 path::PathBuf,5};67use anyhow::{Context, Result, anyhow, bail, ensure};8use chrono::{DateTime, Utc};9use clap::Parser;10use fleet_base::{11 fleetdata::{FleetSecretData, FleetSecretDistribution, FleetSecretPart, encrypt_secret_data},12 host::Config,13 opts::FleetOpts,14 secret::{Expectations, RegenerationReason, secret_needs_regeneration},15};16use fleet_shared::SecretData;17use nix_eval::{NixType, Value, nix_go, nix_go_json};18use serde::Deserialize;19use tabled::{Table, Tabled};20use tokio::{fs::read, task::spawn_blocking};21use tracing::{Instrument, error, info, info_span, warn};2223#[derive(Parser)]24pub enum Secret {25 AddManager,26 /// Force load host keys for all defined hosts27 ForceKeys,28 /// Read secret from remote host, requires sudo on one of the owning hosts29 Read {30 /// Secret name to read31 name: String,3233 /// Distribution with what machine to read34 /// If not shared between multiple - defaults to single owner35 #[clap(short = 'm', long)]36 machine: Option<String>,3738 /// Which private secret part to read39 #[clap(short = 'p', long, default_value = "secret")]40 part: String,4142 /// Which host should we use to decrypt, in case if reencryption is required, without43 /// regeneration44 #[clap(long)]45 prefer_identities: Vec<String>,46 },47 Regenerate {48 /// Which host should we use to decrypt, in case if reencryption is required, without49 /// regeneration50 #[clap(long)]51 prefer_identities: Vec<String>,52 /// Only regenerate shared secrets53 #[clap(long)]54 skip_hosts: bool,55 },56 List {},57 Edit {58 name: String,59 #[clap(short = 'm', long)]60 machine: String,6162 #[clap(long)]63 add: bool,6465 /// Which private secret part to read66 #[clap(short = 'p', long, default_value = "secret")]67 part: String,68 },69}7071/*72#[allow(clippy::too_many_arguments)]73#[tracing::instrument(skip(config, secret, definition, prefer_identities))]74async fn maybe_regenerate_shared_secret(75 secret_name: &str,76 config: &Config,77 mut secret: FleetSecretDistribution,78 definition: SharedSecretDefinition,79 prefer_identities: &[String],80 expectations: &Expectations,81) -> Result<FleetSecretDistribution> {82 let reason = secret_needs_regeneration(&secret.secret, &secret.owners, expectations);83 let value = definition.definition_value();8485 let (should_reencrypt, reason) = match reason {86 Some(RegenerationReason::OwnersAdded(_)) => {87 // Secret always needs to be reencrypted for new owners to be able to read it88 (89 true,90 if nix_go_json!(value.regenerateOnOwnerAdded) {91 reason92 } else {93 None94 },95 )96 }97 Some(RegenerationReason::OwnersRemoved(_)) => {98 // No need to reencrypt, we can just leave stanzas in place.99 if nix_go_json!(value.regenerateOnOwnerRemoved) {100 (true, reason)101 } else {102 (false, None)103 }104 }105 Some(_) => (true, reason),106 None => (false, None),107 };108109 if let Some(reason) = reason {110 info!("secret needs to be regenerated: {reason}");111 let generated = generate_shared(config, secret_name, definition, expectations).await?;112 Ok(generated)113 } else if should_reencrypt {114 info!("secret needs to be reencrypted");115 let identity_holder = if !prefer_identities.is_empty() {116 prefer_identities117 .iter()118 .find(|i| secret.owners.iter().any(|s| s == *i))119 } else {120 secret.owners.first()121 };122 let Some(identity_holder) = identity_holder else {123 bail!("no available holder found");124 };125126 for (part_name, part) in secret.secret.parts.iter_mut() {127 let _span = info_span!("part reencryption", part_name);128 if !part.raw.encrypted {129 continue;130 }131 let host = config.host(identity_holder).await?;132 let encrypted = host133 .reencrypt(134 part.raw.clone(),135 expectations.owners.iter().cloned().collect(),136 )137 .await?;138 part.raw = encrypted;139 }140 secret.owners = expectations.owners.clone();141 Ok(secret)142 } else {143 Ok(secret)144 }145}146*/147148#[derive(Deserialize)]149#[serde(rename_all = "camelCase")]150enum GeneratorKind {151 Impure,152 Pure,153}154155async fn generate_pure(156 _config: &Config,157 _display_name: &str,158 _secret: Value,159 _default_generator: Value,160 _expectations: &Expectations,161) -> Result<FleetSecretData> {162 bail!("pure generators are broken for now")163}164async fn generate_impure(165 config: &Config,166 _display_name: &str,167 secret: Value,168 default_generator: Value,169 expectations: &Expectations,170) -> Result<FleetSecretData> {171 let generator = nix_go!(secret.generator);172 let on: Option<String> = nix_go_json!(default_generator.impureOn);173174 let nixpkgs = &config.nixpkgs;175176 let host = if let Some(on) = &on {177 config.host(on).await?178 } else {179 config.local_host()180 };181 let on_pkgs = host.pkgs().await?;182 let mk_secret_generators = nix_go!(on_pkgs.mkSecretGenerators);183184 let mut recipients = Vec::new();185 for owner in &expectations.owners {186 let key = config.key(owner).await?;187 recipients.push(key);188 }189 let generators = nix_go!(mk_secret_generators(Obj { recipients }));190 let pkgs_and_generators = on_pkgs.attrs_update(generators)?;191192 let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));193194 let generator = nix_go!(call_package(generator)(Obj {}));195196 let generator = spawn_blocking(move || generator.build("out"))197 .await198 .expect("nix build shouldn't fail")?;199 let generator = host.remote_derivation(&generator).await?;200201 let out_parent = host.mktemp_dir().await?;202 let out = format!("{out_parent}/out");203204 let mut r#gen = host.cmd(generator).await?;205 r#gen.env("out", &out);206 if on.is_none() {207 // This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.208 let project_path: String = config209 .directory210 .clone()211 .into_os_string()212 .into_string()213 .map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;214 r#gen.env("FLEET_PROJECT", project_path);215 }216 r#gen.run().await.context("impure generator")?;217218 {219 let marker = host.read_file_text(format!("{out}/marker")).await?;220 ensure!(marker == "SUCCESS", "generation not succeeded");221 }222223 let mut parts = BTreeMap::new();224 for part in host.read_dir(&out).await? {225 if part == "created_at" || part == "expires_at" || part == "marker" {226 continue;227 }228 let contents: SecretData = host229 .read_file_text(format!("{out}/{part}"))230 .await?231 .parse()232 .map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;233 parts.insert(part.to_owned(), FleetSecretPart { raw: contents });234 }235236 let created_at = host.read_file_value(format!("{out}/created_at")).await?;237 let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();238239 let new_data = FleetSecretData {240 created_at,241 expires_at,242 parts,243 generation_data: expectations.generation_data.clone(),244 };245246 if let Some(reason) = secret_needs_regeneration(&new_data, &expectations.owners, expectations) {247 bail!("newly generated secret needs to be regenerated: {reason}")248 }249250 Ok(new_data)251}252253async fn generate(254 config: &Config,255 display_name: &str,256 secret: Value,257 expectations: &Expectations,258) -> Result<FleetSecretData> {259 let generator = nix_go!(secret.generator);260 // Can't properly check on nix module system level261 {262 let gen_ty = generator.type_of();263 if matches!(gen_ty, NixType::Null) {264 bail!("secret has no generator defined, can't automatically generate it.");265 }266 if matches!(gen_ty, NixType::Attrs) {267 if !generator.has_field("__functor")? {268 bail!("generator should be functor, got {gen_ty:?}");269 }270 } else if matches!(gen_ty, NixType::Function) {271 bail!("generator should be functor, got {gen_ty:?}");272 }273 }274 let nixpkgs = &config.nixpkgs;275 let default_pkgs = &config.default_pkgs;276 let default_mk_secret_generators = nix_go!(default_pkgs.mkSecretGenerators);277 // Generators provide additional information in passthru, to access278 // passthru we should call generator, but information about where this generator is supposed to build279 // is located in passthru... Thus evaluating generator on host.280 //281 // Maybe it is also possible to do some magic with __functor?282 //283 // I don't want to make modules always responsible for additional secret data anyway,284 // so it should be in derivation, and not in the secret data itself.285 let generators = nix_go!(default_mk_secret_generators(Obj {286 recipients: <Vec<String>>::new(),287 }));288 let pkgs_and_generators = default_pkgs.clone().attrs_update(generators)?;289290 let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));291 let default_generator = nix_go!(call_package(generator)(Obj {}));292293 let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);294295 match kind {296 GeneratorKind::Impure => {297 generate_impure(298 config,299 display_name,300 secret,301 default_generator,302 expectations,303 )304 .await305 }306 GeneratorKind::Pure => {307 generate_pure(308 config,309 display_name,310 secret,311 default_generator,312 expectations,313 )314 .await315 }316 }317}318/*319async fn generate_shared(320 config: &Config,321 display_name: &str,322 secret: SharedSecretDefinition,323 expectations: &Expectations,324) -> Result<FleetSecretDistribution> {325 // let owners: Vec<String> = nix_go_json!(secret.expectedOwners);326 Ok(FleetSecretDistribution {327 managed: Some(true),328 secret: generate(329 config,330 display_name,331 secret.definition_value(),332 expectations,333 )334 .await?,335 owners: expectations.owners.clone(),336 })337}*/338339async fn parse_public(340 public: Option<String>,341 public_file: Option<PathBuf>,342) -> Result<Option<SecretData>> {343 Ok(match (public, public_file) {344 (Some(v), None) => Some(SecretData {345 data: v.into(),346 encrypted: false,347 }),348 (None, Some(v)) => Some(SecretData {349 data: read(v).await?,350 encrypted: false,351 }),352 (Some(_), Some(_)) => {353 bail!("only public or public_file should be set")354 }355 (None, None) => None,356 })357}358359async fn parse_secret() -> Result<Option<Vec<u8>>> {360 let mut input = vec![];361 stdin().read_to_end(&mut input)?;362 if input.is_empty() {363 Ok(None)364 } else {365 Ok(Some(input))366 }367}368369fn parse_machines(370 initial: BTreeSet<String>,371 machines: Option<Vec<String>>,372 mut add_machines: Vec<String>,373 mut remove_machines: Vec<String>,374) -> Result<BTreeSet<String>> {375 if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {376 bail!("no operation");377 }378379 let initial_machines = initial.clone();380 let mut target_machines = initial;381 info!("Currently encrypted for {initial_machines:?}");382383 if let Some(machines) = machines {384 ensure!(385 add_machines.is_empty() && remove_machines.is_empty(),386 "can't combine --machines and --add-machines/--remove-machines"387 );388 let target = initial_machines.iter().collect::<HashSet<_>>();389 let source = machines.iter().collect::<HashSet<_>>();390 for removed in target.difference(&source) {391 remove_machines.push((*removed).clone());392 }393 for added in source.difference(&target) {394 add_machines.push((*added).clone());395 }396 }397398 for machine in &remove_machines {399 if !target_machines.remove(machine) {400 warn!("secret is not enabled for {machine}");401 }402 }403 for machine in &add_machines {404 if !target_machines.insert(machine.to_owned()) {405 warn!("secret is already added to {machine}");406 }407 }408 if !remove_machines.is_empty() {409 // TODO: maybe force secret regeneration?410 // Not that useful without revokation.411 warn!(412 "secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret"413 );414 }415 Ok(target_machines)416}417impl Secret {418 pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {419 match self {420 Secret::AddManager => {421 todo!("part of fleet-pusher")422 }423 Secret::ForceKeys => {424 for host in config.list_hosts().await? {425 if opts.should_skip(&host).await? {426 continue;427 }428 config.key(&host.name).await?;429 }430 }431 Secret::Read {432 name,433 machine,434 part: part_name,435 mut prefer_identities,436 } => {437 let Some(secret) = config.shared_secret(&name) else {438 bail!("secret doesn't exists");439 };440441 let dist = if secret.len() == 1 {442 &secret[0]443 } else if let Some(machine) = machine {444 let dist = secret.get(&machine);445 let Some(dist) = dist else {446 bail!("machine {machine} has no distribution of secret {name}");447 };448 prefer_identities.push(machine);449 dist450 } else {451 bail!(452 "secret {name} has shares, but no --machine specified for specifing which do you need"453 )454 };455456 let Some(part) = dist.secret.parts.get(&part_name) else {457 bail!("no part {part_name} in secret {name}");458 };459 let data = if part.raw.encrypted {460 let identity_holder = if !prefer_identities.is_empty() {461 prefer_identities462 .iter()463 .find(|i| dist.owners.iter().any(|s| s == *i))464 } else {465 dist.owners.first()466 };467 let Some(identity_holder) = identity_holder else {468 bail!("no available holder found");469 };470 let host = config.host(identity_holder).await?;471 host.decrypt(part.raw.clone()).await?472 } else {473 part.raw.data.clone()474 };475 stdout().write_all(&data)?;476 }477 Secret::Regenerate {478 prefer_identities,479 skip_hosts,480 } => {481 /*482 info!("checking for secrets to regenerate");483 let expected_shared_set = config484 .list_configured_shared()485 .await?486 .into_iter()487 .collect::<HashSet<_>>();488 let stored_shared_set = config.list_secrets().into_iter().collect::<HashSet<_>>();489 {490 // Generate missing shared491 let _span = info_span!("shared").entered();492 for missing in expected_shared_set.difference(&stored_shared_set) {493 let definition = config.shared_secret_definition(missing)?;494 if !definition.is_managed()? {495 info!("skipping unmanaged secret: {missing}");496 continue;497 }498 let expectations = definition499 .expectations()500 .with_context(|| format!("expectations for shared {missing:?}"))?;501 info!("generating secret: {missing}");502 let shared = generate_shared(config, missing, definition, &expectations)503 .in_current_span()504 .await?;505 config.replace_shared(missing.to_string(), shared)506 }507 }508 if !skip_hosts {509 for host in config.list_hosts().await? {510 if opts.should_skip(&host).await? {511 continue;512 }513514 let _span = info_span!("host", host = host.name).entered();515 let expected_set = host516 .list_defined_secrets()?517 .into_iter()518 .collect::<HashSet<_>>();519 let stored_set = config520 .list_secrets_for_owner(&host.name)521 .into_iter()522 .collect::<HashSet<_>>();523 for missing_secret in expected_set.difference(&stored_set) {524 let secret = host.secret_definition(missing_secret)?;525 if secret.is_shared()? {526 continue;527 }528 info!("generating missing secret: {missing_secret}");529 let expectations = secret.expectations().with_context(|| {530 format!("expectations for {missing_secret:?} of {:?}", host.name)531 })?;532 let generated = match generate(533 config,534 missing_secret,535 secret.definition_value()?,536 &expectations,537 )538 .in_current_span()539 .await540 {541 Ok(v) => v,542 Err(e) => {543 error!("{e:?}");544 continue;545 }546 };547 config.insert_secret(host.name, missing_secret.to_string(), generated)548 }549 for known_secret in stored_set.intersection(&expected_set) {550 let secret = host.secret_definition(known_secret)?;551 if secret.is_shared()? {552 continue;553 }554 info!("updating secret: {known_secret}");555 let data = config.host_secret(&host.name, known_secret)?;556 let expectations = secret.expectations()?;557 if let Some(regen_reason) = data.needs_regeneration(&expectations) {558 info!("needs regeneration: {regen_reason}");559 let generated = match generate(560 config,561 known_secret,562 secret.definition_value()?,563 &expectations,564 )565 .in_current_span()566 .await567 {568 Ok(v) => v,569 Err(e) => {570 error!("{e:?}");571 continue;572 }573 };574 config.insert_secret(575 &host.name,576 known_secret.to_string(),577 FleetLegacyHostSecret {578 managed: Some(true),579 secret: generated,580 },581 )582 }583 }584 for removed_secret in stored_set.difference(&expected_set) {585 let definition = host.secret_definition(removed_secret)?;586 if definition.is_shared()? {587 continue;588 }589 info!("removing secret: {removed_secret}");590 config.remove_secret(&host.name, removed_secret);591 }592 }593 }594 for known_secret in stored_shared_set.intersection(&expected_shared_set) {595 info!("updating shared secret: {known_secret}");596 let data = config.shared_secret(known_secret)?.expect("exists");597598 let definition = config.shared_secret_definition(known_secret)?;599 let expectations = definition.expectations()?;600 config.replace_shared(601 known_secret.to_owned(),602 maybe_regenerate_shared_secret(603 known_secret,604 config,605 data,606 definition,607 &prefer_identities,608 &expectations,609 )610 .await?,611 );612 }613 for removed_secret in stored_shared_set.difference(&expected_shared_set) {614 info!("removing shared secret: {removed_secret}");615 config.remove_shared(removed_secret);616 }617 */618 todo!()619 }620 Secret::List {} => {621 let _span = info_span!("loading secrets").entered();622 let configured = config.list_configured_shared().await?;623 #[derive(Tabled)]624 struct SecretDisplay {625 #[tabled(rename = "Name")]626 name: String,627 #[tabled(rename = "Owners")]628 owners: String,629 }630 // let mut table = vec![];631 for name in configured.iter().cloned() {632 let config = config.clone();633 let data = config.shared_secret(&name).expect("exists");634 /*635 let definition = config.shared_secret_definition(&name)?;636 let expectations = definition.expectations()?;637 let owners = data638 .owners()639 .map(|o| {640 if expectations.owners.contains(o) {641 o.green().to_string()642 } else {643 o.red().to_string()644 }645 })646 .collect::<Vec<_>>();647 table.push(SecretDisplay {648 owners: owners.join(", "),649 name,650 })651*/652 }653 // info!("loaded\n{}", Table::new(table).to_string())654 }655 Secret::Edit {656 name,657 machine,658 part,659 add,660 } => {661 let secret = config662 .host_secret(&machine, &name)663 .context("secret not found")?;664 if let Some(data) = secret.secret.parts.get(&part) {665 let host = config.host(&machine).await?;666 let secret = host.decrypt(data.raw.clone()).await?;667 String::from_utf8(secret).context("secret is not utf8")?668 } else if add {669 String::new()670 } else {671 bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");672 };673 }674 }675 Ok(())676 }677}678679/*680async fn edit_temp_file(681 builder: tempfile::Builder<'_, '_>,682 r: Vec<u8>,683 header: &str,684 comment: &str,685) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {686 if !stdin().is_tty() {687 // TODO: Also try to open /dev/tty directly?688 bail!("stdin is not tty, can't open editor");689 }690691 use std::fmt::Write;692 let mut file = builder.tempfile()?;693694 let mut full_header = String::new();695 let mut had = false;696 for line in header.trim_end().lines() {697 had = true;698 writeln!(&mut full_header, "{comment}{line}")?;699 }700 if had {701 writeln!(&mut full_header, "{}", comment.trim_end())?;702 }703 writeln!(704 &mut full_header,705 "{comment}Do not touch this header! It will be removed automatically"706 )?;707708 file.write_all(full_header.as_bytes())?;709 file.write_all(&r)?;710711 let abs_path = file.into_temp_path();712 let editor = std::env::var_os("VISUAL")713 .or_else(|| std::env::var_os("EDITOR"))714 .unwrap_or_else(|| "vi".into());715 let editor_args = shlex::bytes::split(editor.as_encoded_bytes())716 .ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;717 let editor_args = editor_args718 .into_iter()719 .map(|v| {720 // Only ASCII subsequences are replaced721 unsafe { OsString::from_encoded_bytes_unchecked(v) }722 })723 .collect_vec();724 let Some((editor, args)) = editor_args.split_first() else {725 bail!("EDITOR env var has no command");726 };727 let mut command = Command::new(editor);728 command.args(args);729730 let path_arg = abs_path.canonicalize()?;731732 // TODO: Save full state, using tcget/_getmode/_setmode733 let was_raw = terminal::is_raw_mode_enabled()?;734 terminal::enable_raw_mode()?;735736 let status = command.arg(path_arg).status().await;737738 if !was_raw {739 terminal::disable_raw_mode()?;740 }741742 let success = match status {743 Ok(s) => s.success(),744 Err(e) if e.kind() == io::ErrorKind::NotFound => {745 bail!("editor not found")746 }747 Err(e) => bail!("editor spawn error: {e}"),748 };749750 let mut file = std::fs::read(&abs_path).context("read editor output")?;751 let Some(v) = file.strip_prefix(full_header.as_bytes()) else {752 todo!();753 };754 todo!();755756 // Ok((success, abs_path))757}758*/crates/fleet-base/src/host.rsdiffbeforeafterboth--- a/crates/fleet-base/src/host.rs
+++ b/crates/fleet-base/src/host.rs
@@ -23,7 +23,6 @@
use crate::{
command::MyCommand,
fleetdata::{FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretDistributions},
- secret::{HostSecretDefinition, SharedSecretDefinition},
};
pub struct FleetConfigInternals {
@@ -31,7 +30,7 @@
pub directory: PathBuf,
/// builtins.currentSystem
pub local_system: String,
- pub data: Mutex<FleetData>,
+ pub data: Arc<Mutex<FleetData>>,
pub nix_args: Vec<OsString>,
/// fleet_config.config
pub config_field: Value,
@@ -520,13 +519,6 @@
let nixos = self.nixos_unchecked_config()?;
let secrets = nix_go!(nixos.secrets);
secrets.list_fields()
- }
- pub fn secret_definition(&self, name: &str) -> Result<HostSecretDefinition> {
- let nixos = self.nixos_unchecked_config()?;
- Ok(HostSecretDefinition(
- self.name.clone(),
- nix_go!(nixos.secrets[{ name }]),
- ))
}
/// Packages for this host, resolved with nixpkgs overlays
@@ -665,12 +657,6 @@
pub fn shared_secret(&self, secret: &str) -> Option<FleetSecretDistributions> {
let data = self.data();
data.secrets.get(secret).cloned()
- }
- pub fn shared_secret_definition(&self, secret: &str) -> Result<SharedSecretDefinition> {
- let config_field = &self.config_field;
- Ok(SharedSecretDefinition(nix_go!(
- config_field.sharedSecrets[{ secret }]
- )))
}
// TODO: Should this be something modifiable from other processes?
crates/fleet-base/src/opts.rsdiffbeforeafterboth--- a/crates/fleet-base/src/opts.rs
+++ b/crates/fleet-base/src/opts.rs
@@ -211,7 +211,7 @@
}
let bytes =
std::fs::read_to_string(&fleet_data_path).context("reading fleet state (fleet.nix)")?;
- let data = Mutex::new(FleetData::from_str(&bytes)?);
+ let data = Arc::new(Mutex::new(FleetData::from_str(&bytes)?));
let mut fetch_settings = FetchSettings::new();
fetch_settings.set(c"warn-dirty", c"false");
@@ -239,8 +239,7 @@
let builtins_field = Value::eval("builtins")?;
let fleet_root = flake.get_field("fleetConfigurations")?;
- let data_val = Value::serialized(&data)?;
- let fleet_field = nix_go!(fleet_root.default(data_val));
+ let fleet_field = nix_go!(fleet_root.default(Obj {}));
let config_field = nix_go!(fleet_field.config);
crates/fleet-base/src/primops.rsdiffbeforeafterboth--- a/crates/fleet-base/src/primops.rs
+++ b/crates/fleet-base/src/primops.rs
@@ -1,4 +1,9 @@
-use nix_eval::NativeFn;
+use std::collections::HashMap;
+use std::sync::{Arc, Mutex};
+
+use nix_eval::{NativeFn, Value};
+
+use crate::fleetdata::{FleetData, FleetSecrets};
#[derive(thiserror::Error, Debug)]
enum Error {}
@@ -15,30 +20,32 @@
fn host_parts(&self, host: &str, name: &str) -> Parts;
}
-struct FsSecretsBackend {
+struct FsSecretsBackend {}
-}
-
-pub fn init_primops() {
+pub fn init_primops(secrets: Arc<Mutex<FleetData>>) {
NativeFn::new(
- c"fleet_ensure_secret",
+ c"fleet_ensure_host_secret",
c"Ensure secret existence for a host, regenerating it in case of some mismatch",
- [
- c"host",
- c"secret",
- c"expected_parts",
- c"expected_encrypted_parts",
- c"generator",
- ],
- |[
- host,
- secret,
- expected_parts,
- expected_encrypted_parts,
- generator,
- ]| {
-
- todo!()
+ [c"host", c"secret", c"generator"],
+ |[host, secret, generator]| {
+ todo!("ensure secret");
+ Ok(Value::new_attrs(HashMap::from_iter([(
+ "raw",
+ Value::new_str("rawData"),
+ )])))
+ },
+ )
+ .register();
+ NativeFn::new(
+ c"fleet_ensure_host_secret",
+ c"Ensure secret existence for a host, regenerating it in case of some mismatch",
+ [c"host", c"secret", c"generator"],
+ |[host, secret, generator]| {
+ todo!("ensure secret");
+ Ok(Value::new_attrs(HashMap::from_iter([(
+ "raw",
+ Value::new_str("rawData"),
+ )])))
},
)
.register();
crates/fleet-base/src/secret.rsdiffbeforeafterboth--- a/crates/fleet-base/src/secret.rs
+++ b/crates/fleet-base/src/secret.rs
@@ -1,8 +1,6 @@
use std::collections::BTreeSet;
-use anyhow::Result;
use chrono::{DateTime, Utc};
-use nix_eval::{Value, nix_go, nix_go_json};
use crate::fleetdata::FleetSecretData;
@@ -12,63 +10,6 @@
pub generation_data: serde_json::Value,
pub public_parts: BTreeSet<String>,
pub private_parts: BTreeSet<String>,
-}
-
-pub struct HostSecretDefinition(pub(crate) String, pub(crate) Value);
-impl HostSecretDefinition {
- pub fn is_managed(&self) -> Result<bool> {
- let def = self.definition_value()?;
- Ok(!nix_go!(def.generator).is_null())
- }
- pub fn is_shared(&self) -> Result<bool> {
- let def = self.definition_value()?;
- Ok(nix_go_json!(def.shared))
- }
- pub fn expectations(&self) -> Result<Expectations> {
- let def = self.definition_value()?;
- let parts = nix_go!(def.parts);
-
- let mut public_parts = BTreeSet::new();
- let mut private_parts = BTreeSet::new();
- for part in parts.list_fields()? {
- if nix_go_json!(parts[&part].encrypted) {
- private_parts.insert(part.clone());
- } else {
- public_parts.insert(part.clone());
- }
- }
-
- Ok(Expectations {
- owners: BTreeSet::from([self.0.clone()]),
- generation_data: nix_go_json!(def.expectedGenerationData),
- public_parts,
- private_parts,
- })
- }
- pub fn definition_value(&self) -> Result<Value> {
- let value = &self.1;
- Ok(nix_go!(value.definition))
- }
-}
-
-pub struct SharedSecretDefinition(pub(crate) Value);
-impl SharedSecretDefinition {
- pub fn is_managed(&self) -> Result<bool> {
- let value = &self.0;
- Ok(!nix_go!(value.generator).is_null())
- }
- pub fn expectations(&self) -> Result<Expectations> {
- let value = &self.0;
- Ok(Expectations {
- owners: nix_go_json!(value.expectedOwners),
- generation_data: nix_go_json!(value.expectedGenerationData),
- public_parts: nix_go_json!(value.expectedPublicParts),
- private_parts: nix_go_json!(value.expectedPrivateParts),
- })
- }
- pub fn definition_value(&self) -> Value {
- self.0.clone()
- }
}
#[derive(thiserror::Error, Debug)]
modules/module-list.nixdiffbeforeafterboth--- a/modules/module-list.nix
+++ b/modules/module-list.nix
@@ -6,5 +6,4 @@
./nixos.nix
./nixpkgs.nix
./secrets.nix
- ./secrets-data.nix
]
modules/nixos/secrets.nixdiffbeforeafterboth--- a/modules/nixos/secrets.nix
+++ b/modules/nixos/secrets.nix
@@ -8,50 +8,26 @@
let
inherit (builtins)
hashString
- elemAt
- length
toJSON
- filter
;
inherit (lib.stringsWithDeps) stringAfter;
inherit (lib.options) mkOption literalExpression;
inherit (lib.lists) optional;
- inherit (lib.attrsets) mapAttrs mapAttrsToList;
- inherit (lib.modules) mkIf mkMerge;
+ inherit (lib.attrsets) mapAttrs;
+ inherit (lib.modules) mkIf;
inherit (lib.types)
submodule
str
attrsOf
nullOr
unspecified
- lazyAttrsOf
uniq
functionTo
package
- listOf
- bool
;
inherit (fleetLib.strings) decodeRawSecret;
sysConfig = config;
- secretPartDataType = submodule {
- options = {
- raw = mkOption {
- type = str;
- internal = true;
- description = "Encoded & Encrypted secret part data, passed from fleet.nix";
- };
- };
- };
- secretDataType = submodule {
- freeformType = lazyAttrsOf secretPartDataType;
- options = {
- shared = mkOption {
- description = "Is this secret owned by this machine, or propagated from shared secrets";
- default = false;
- };
- };
- };
secretPartType =
secretName:
submodule (
@@ -61,11 +37,6 @@
in
{
options = {
- encrypted = mkOption {
- type = bool;
- description = "Is this secret part supposed to be encrypted?";
- };
-
hash = mkOption {
type = str;
description = "Hash of secret in encoded format";
@@ -82,17 +53,17 @@
type = str;
description = "Secret public data (only available for plaintext)";
};
+ raw = mkOption {
+ type = str;
+ description = "Raw (encoded/encrypted secret part data)";
+ };
+ };
+ config = {
+ hash = hashString "sha1" config.raw;
+ data = decodeRawSecret config.raw;
+ path = "/run/secrets/${secretName}/${config.hash}-${partName}";
+ stablePath = "/run/secrets/${secretName}/${partName}";
};
- config =
- let
- raw = sysConfig.data.secrets.${secretName}.${partName}.raw;
- in
- {
- hash = hashString "sha1" raw;
- data = decodeRawSecret raw;
- path = "/run/secrets/${secretName}/${config.hash}-${partName}";
- stablePath = "/run/secrets/${secretName}/${partName}";
- };
}
);
secretType = submodule (
@@ -105,14 +76,9 @@
in
{
options = {
- shared = mkOption {
- type = bool;
- description = "Was this secret propagated from a shared secret?";
- };
parts = mkOption {
- type = lazyAttrsOf (secretPartType secretName);
+ type = attrsOf (secretPartType secretName);
description = "Definition of secret parts";
- default = { };
};
generator = mkOption {
type = uniq (nullOr (functionTo package));
@@ -134,47 +100,14 @@
description = "Group of the secret";
default = sysConfig.users.users.${config.owner}.group;
defaultText = literalExpression "config.users.users.$${owner}.group";
- };
- expectedGenerationData = mkOption {
- type = unspecified;
- description = "Data that gets embedded into secret part";
- default = null;
};
};
config = {
- shared = (sysConfig.data.secrets.${secretName} or { shared = false; }).shared;
- parts = mkMerge [
- (mkIf (config.generator != null)
- (
- # Get fake derivation body, in future it should be implemented the same way as in Rust.
- lib.callPackageWith (
- pkgs
- // {
- mkSecretGenerator = pkgs.stdenv.mkDerivation;
- mkImpureSecretGenerator = pkgs.stdenv.mkDerivation;
- }
- ) config.generator { }
- ).parts
- )
- (mapAttrs (_: _: { }) (
- removeAttrs (sysConfig.data.secrets.${secretName} or { }) [
- "shared"
- "managed"
- ]
- ))
- ];
+ parts = builtins.fleet_ensure_host_secret sysConfig.networking.hostName secretName config.generator;
};
}
);
- processPart = secretName: partName: part: {
- inherit (part) path stablePath;
- raw = config.data.secrets.${secretName}.${partName}.raw;
- };
- processSecret = secretName: secret: {
- inherit (secret.definition) group mode owner;
- parts = (mapAttrs (processPart secretName) (secret.definition.parts));
- };
- secretsData = (mapAttrs (processSecret) config.secrets);
+ secretsData = (mapAttrs (_: s: s.definition) config.secrets);
secretsFile = pkgs.writeTextFile {
name = "secrets.json";
text = toJSON secretsData;
@@ -185,11 +118,6 @@
in
{
options = {
- data.secrets = mkOption {
- type = attrsOf secretDataType;
- default = { };
- description = "Host-local secret data";
- };
secrets = mkOption {
type = attrsOf secretType;
default = { };
modules/secrets-data.nixdiffbeforeafterboth--- a/modules/secrets-data.nix
+++ /dev/null
@@ -1,95 +0,0 @@
-{
- lib,
- fleetLib,
- ...
-}:
-let
- inherit (fleetLib.options) mkDataOption;
- inherit (lib.options) mkOption;
- inherit (lib.types)
- nullOr
- listOf
- str
- attrsOf
- submodule
- bool
- unspecified
- ;
-
- secretDataValue = {
- options = {
- raw = mkOption {
- type = nullOr str;
- description = "Raw secret data in unspecified encoded and optionally encrypted format.";
- default = null;
- };
- };
- };
-
- sharedSecretData = {
- freeformType = attrsOf (submodule secretDataValue);
- options = {
- managed = mkOption {
- type = nullOr bool;
- description = "Is current fleet data value is generated by generator";
- default = null;
- };
-
- createdAt = mkOption {
- type = str;
- description = "Timestamp of secret generation/last rotation.";
- default = null;
- };
- expiresAt = mkOption {
- type = nullOr str;
- description = "Expiration timestamp triggering mandatory secret rotation.";
- default = null;
- };
-
- owners = mkOption {
- type = listOf str;
- description = ''
- List of hosts currently authorized to decrypt this shared secret.
-
- If owners differ from expected owners, the secret is considered outdated
- and requires regeneration or re-encryption.
- '';
- default = [ ];
- };
- generationData = mkOption {
- type = unspecified;
- description = "Contextual metadata associated with secret part.";
- default = null;
- };
- };
- };
-
- 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);
- };
- secrets = mkOption {
- type = attrsOf (listOf submodule sharedSecretData);
- default = { };
- description = "Shared secret data.";
- };
- };
- });
-}
modules/secrets.nixdiffbeforeafterboth--- a/modules/secrets.nix
+++ b/modules/secrets.nix
@@ -5,7 +5,6 @@
let
inherit (lib.options) mkOption literalExpression;
inherit (lib.types)
- unspecified
nullOr
listOf
str
@@ -66,22 +65,7 @@
An input to this function - `pkgs` of a generator host with implementation-defined representation of extra encryption data,
use `mkSecretGenerator` helpers to implement own generators.
'';
- default = null;
- };
- expectedGenerationData = mkOption {
- type = unspecified;
- description = "Contextual metadata embedded within the secret part value";
default = null;
- };
- expectedPrivateParts = mkOption {
- type = listOf str;
- default = [ ];
- description = "List of parts that are expected to be encrypted";
- };
- expectedPublicParts = mkOption {
- type = listOf str;
- default = [ ];
- description = "List of parts that are expected to be public";
};
};
};