1use std::collections::{BTreeMap, BTreeSet, HashMap};2use std::sync::{Arc, OnceLock};34use anyhow::{Context, bail, ensure};5use fleet_shared::SecretData;6use itertools::Itertools;7use nix_eval::{NativeFn, Value, await_in_nix, nix_go, nix_go_json};8use remowt_endpoints::fs::FsClient;9use remowt_link_shared::BifConfig;10use serde::Deserialize;11use tracing::{info, warn};1213use crate::fleetdata::{14 Expectations, FleetSecretData, FleetSecretDistribution, FleetSecretPart, GeneratorPart,15 RegenerationConstraints, SecretOwner,16};17use crate::host::{Config, ConfigHost};18use anyhow::{Result, anyhow};1920pub static PRIMOPS_DATA: OnceLock<Config> = OnceLock::new();2122#[derive(Deserialize)]23#[serde(rename_all = "camelCase")]24enum GeneratorKind {25 Impure,26 Pure,27}2829pub fn get_pkgs_and_generators(host_on: &ConfigHost, recipients: Vec<String>) -> Result<Value> {30 let pkgs = host_on.pkgs()?;31 let default_mk_secret_generators = nix_go!(pkgs.mkSecretGenerators);32 let generators = nix_go!(default_mk_secret_generators(Obj { recipients }));33 pkgs.clone().attrs_update(generators)34}35pub fn get_default_pkgs_and_generators(config: &Config) -> Result<Value> {36 let host_on = config.local_host();37 get_pkgs_and_generators(&host_on, vec![])38}39pub fn call_package(config: &Config, pkgs: &Value, package: &Value) -> Result<Value> {40 ensure!(41 package.is_function(),42 "package should be a function to be called with callPackage"43 );44 45 let nixpkgs = &config.nixpkgs;46 let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs));47 Ok(nix_go!(call_package(package)(Obj {})))48}4950pub fn get_default_generator_drv(config: &Config, generator: &Value) -> Result<Value> {51 let default_pkgs_and_generators = get_default_pkgs_and_generators(config)?;52 let default_generator_drv = call_package(config, &default_pkgs_and_generators, generator)53 .context("failed to initialize generator to get metadata")?;5455 Ok(default_generator_drv)56}5758fn secret_to_parts(59 secret_name: &str,60 secret: &BTreeMap<String, FleetSecretPart>,61 expected: &BTreeMap<String, GeneratorPart>,62) -> Value {63 let mut out = HashMap::new();64 for (part_name, part) in secret {65 if !expected.contains_key(part_name) {66 warn!(67 "secret {secret_name} part {part_name} is stored, but not defined in nixos config, it will not be passed to nix"68 );69 continue;70 };71 out.insert(72 part_name.as_str(),73 Value::new_attrs(HashMap::from_iter([(74 "raw",75 Value::new_str(&part.raw.to_string()),76 )])),77 );78 }7980 Value::new_attrs(out)81}8283pub async fn generate(84 config: &Config,85 expectations: Expectations,86 generator: &Value,87 default_generator_drv: &Value,88) -> Result<FleetSecretDistribution> {89 let kind: GeneratorKind = nix_go_json!(default_generator_drv.generatorKind);9091 match kind {92 GeneratorKind::Impure => {93 let impure_on: Option<String> = nix_go_json!(default_generator_drv.impureOn);9495 let host_on = if let Some(on) = &impure_on {96 Arc::new(97 config98 .host(on)99 .context("failed to get secret generation target host")?,100 )101 } else {102 config.local_host()103 };104105 let remowt = host_on.remowt().await?;106 let fs = remowt.endpoints::<FsClient<BifConfig>>();107108 let mut recipients = Vec::new();109 for owner in &expectations.owners {110 recipients.push(config.key(owner).await?);111 }112 let pkgs_and_generators = get_pkgs_and_generators(&host_on, recipients)113 .context("failed to get pkgs for target host")?;114 let generator = call_package(config, &pkgs_and_generators, generator)115 .context("failed to evaluate generator for target host")?;116117 let generator = generator118 .build("out")119 .context("failed to build generator for target host")?;120121 let generator = host_on122 .remote_derivation(&generator, None)123 .await124 .context("failed to copy generator to target host")?;125126 127 let out_parent = fs128 .mktemp_dir()129 .await130 .map_err(|e| anyhow!("{e:?}"))131 .context("failed to prepare generator output dir on target host")?;132 let out = out_parent.join("out");133 let mut generator_cmd = remowt.cmd(generator);134 generator_cmd.env("out", &out);135 if impure_on.is_none() {136 let project_path: String = config137 .directory138 .clone()139 .into_os_string()140 .into_string()141 .map_err(|e| anyhow!("fleet project path is not utf-8: {e:?}"))?;142 generator_cmd.env("FLEET_PROJECT", project_path);143 };144 generator_cmd145 .run()146 .await147 .context("failed to run impure generator")?;148149 {150 let marker = fs.read_file_text(out.join("marker")).await?;151 ensure!(152 marker == "SUCCESS",153 "impure generator ended prematurely, secret generation failed"154 );155 }156157 let mut missing_parts = expectations.parts.clone();158 let mut parts = BTreeMap::new();159 for part in fs.read_dir(&out).await? {160 let part = part.into_string();161 if part == "created_at" || part == "expires_at" || part == "marker" {162 continue;163 }164 let Some(part_def) = missing_parts.remove(&part) else {165 bail!("secret generator has produced an unexpected part: {part}");166 };167 let contents: SecretData = fs168 .read_file_text(out.join("part"))169 .await?170 .parse()171 .map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;172173 ensure!(174 contents.encrypted == part_def.encrypted,175 "part {part} produced by generator is supposed to be {}, but it was not",176 if part_def.encrypted {177 "encrypted"178 } else {179 "plaintext"180 }181 );182183 parts.insert(part.to_owned(), FleetSecretPart { raw: contents });184 }185 if !missing_parts.is_empty() {186 bail!(187 "the secret generator has not produced the following expected parts: {missing_parts:?}"188 );189 }190191 let created_at = fs.read_file_value(out.join("created_at")).await??;192 let expires_at = match fs.read_file_value(out.join("expires_at")).await {193 Ok(v) => Some(v?),194 Err(remowt_endpoints::fs::Error::NotFound) => None,195 Err(e) => return Err(e.into()),196 };197198 let new_data = FleetSecretData {199 created_at,200 expires_at,201 parts,202 generation_data: expectations.generation_data.clone(),203 };204205 let new_data =206 FleetSecretDistribution::new(expectations.owners.clone(), new_data, config.now);207208 Ok(new_data)209 }210 GeneratorKind::Pure => {211 bail!("pure generators are disabled for now")212 }213 }214}215216pub fn init_primops() {217 NativeFn::new(218 c"__fleetEnsureHostSecrets",219 c"Ensure no extra secrets are stored for the host, pruning unknown",220 [c"host", c"expectedNonshared", c"expectedShared", c"rest"],221 |_es, [host, expected_nonshared, expected_shared, rest]| {222 let host = SecretOwner::host(host.to_string()?);223 let expected_nonshared: BTreeSet<String> = expected_nonshared.as_json()?;224 let expected_shared: BTreeSet<String> = expected_shared.as_json()?;225226 let mut expected = expected_nonshared;227 expected.extend(expected_shared);228229 let config = PRIMOPS_DATA230 .get()231 .expect("primops data should be set on init");232233 config234 .data235 .secrets236 .write()237 .expect("no poisoning")238 .prune_host(&host, expected);239240 Ok(rest.clone())241 },242 )243 .register();244 NativeFn::new(245 c"__fleetEnsureHostSecret",246 c"Ensure secret existence for a host, regenerating it in case of some mismatch",247 [c"host", c"secret", c"generator"],248 |es, [host, secret, generator]| {249 let host = SecretOwner::host(&host.to_string()?);250 let secret = secret.to_string()?;251252 let config = PRIMOPS_DATA253 .get()254 .expect("primops data should be set on init");255256 let shared_def = config.secret_definition(&secret).context("failed to get shared secret definition")?;257258 let (shared, generator, expected_owners) = if generator.is_string() {259 assert_eq!(generator.to_string()?, "shared", "asserted by nixos type system");260 let Some(shared_def) = shared_def else {261 bail!("secret {secret} is defined on host {host} as shared, but there is no shared secret with same name defined at fleetConfiguration.secrets.{secret}.generator")262 };263 let expected_owners = shared_def.expected_owners()?;264265 ensure!(expected_owners.contains(&host), "secret {secret} does not define {host} as expected owner");266267 (Some(shared_def.clone()), shared_def.generator()?, expected_owners)268 } else {269 if shared_def.is_some() {270 bail!("hosts can only have their own generators for non-shared secrets, either set host secret generator to \"shared\", or remove shared secret generator at fleetConfiguration.secrets.{secret}.generator")271 }272273 (None, generator.clone(), BTreeSet::from_iter([host.clone()]))274 };275276 let default_generator_drv = get_default_generator_drv(config, &generator)?;277 let mut expectations = Expectations {278 parts: nix_go_json!(default_generator_drv.parts),279 generation_data: nix_go_json!(default_generator_drv.generationData),280 owners: expected_owners.clone(),281 };282 let constraints = if let Some(shared) = &shared{283 RegenerationConstraints {284 allow_different: nix_go_json!(default_generator_drv.allowDifferent) && shared.allow_different()?,285 regenerate_on_owner_added: shared.regenerate_on_owner_added()?,286 regenerate_on_owner_removed: shared.regenerate_on_owner_added()?,287 }288 } else {289 RegenerationConstraints::host_personal()290 };291292 let mut secrets = config.data.secrets.write().expect("no poisoning");293 let dists = secrets.get_or_create(&secret);294295 if shared.is_some() {296 dists.prune_shared(&expected_owners, !constraints.allow_different, &expectations.parts, &expectations.generation_data, constraints.regenerate_on_owner_removed, constraints.regenerate_on_owner_added, &config.prefer_identities, config.now);297 } else {298 dists.prune_host(host.clone(), &expectations.parts, &expectations.generation_data, config.now);299 };300301 if let Some(dist) = dists.get(&host) {302 return Ok(secret_to_parts(&secret, &dist.secret.parts, &expectations.parts));303 };304305 let mut reencrypt_targets = expectations.owners.clone();306 for dist in dists.distributions() {307 for own in dist.owners() {308 reencrypt_targets.remove(own);309 }310 }311 if !constraints.regenerate_on_owner_added {312 if let Some(unpruned) = dists.try_unprune(host.clone()) {313 return Ok(secret_to_parts(&secret, &unpruned.secret.parts, &expectations.parts));314 } else if let Some(best) = dists.best_distribution_for_reencryption(&config.prefer_identities) {315 let new_owners = reencrypt_targets.clone();316 let mut reencrypt_targets = reencrypt_targets;317 reencrypt_targets.extend(best.owners().cloned());318319 let mut preferred = best.owners().collect_vec();320 preferred.sort_by_key(|v| !config.prefer_identities.contains(*v));321322 warn!("reencrypting secret {secret} as it is missing for host {host}");323324 for owner in preferred {325 if let Some(hostname) = owner.as_host() && let Ok(host) = config.host(hostname) {326 let best = best.clone();327 let reencrypt_targets = reencrypt_targets.clone();328 let reencrypted = match await_in_nix(async move {329 host.reencrypt_distribution(&best, reencrypt_targets.clone(), config.now).await330 }) {331 Ok(r) => r,332 Err(e) => {333 warn!("reencryption failed on {hostname}: {e:?}");334 continue;335 }336 };337 dists.extend(reencrypted.clone(), format!("secret was reencrypted to extend with new owners: {new_owners:?}"));338 return Ok(secret_to_parts(&secret, &reencrypted.secret.parts, &expectations.parts));339 };340 }341 warn!("failed to reencrypt using any host")342 };343 };344345 if constraints.allow_different {346 for dist in dists.distributions() {347 for own in dist.owners() {348 expectations.owners.remove(own);349 }350 }351 }352 info!("secret {secret} is being generated for {:?}", expectations.owners);353354 let expectations_ = expectations.clone();355 let generated = await_in_nix(async move {356 generate(config, expectations_, &generator, &default_generator_drv).await357 })?;358359 dists.extend(generated.clone(), "secret was generated".to_string());360361 Ok(secret_to_parts(&secret, &generated.secret.parts, &expectations.parts))362 },363 )364 .register();365}
1use std::collections::{BTreeMap, BTreeSet, HashMap};2use std::sync::{Arc, OnceLock};34use anyhow::{Context, bail, ensure};5use fleet_shared::SecretData;6use itertools::Itertools;7use nix_eval::{NativeFn, Value, await_in_nix, nix_go, nix_go_json};8use remowt_endpoints::fs::FsClient;9use remowt_link_shared::BifConfig;10use serde::Deserialize;11use tracing::{info, warn};1213use crate::fleetdata::{14 Expectations, FleetSecretData, FleetSecretDistribution, FleetSecretPart, GeneratorPart,15 RegenerationConstraints, SecretOwner,16};17use crate::host::{Config, ConfigHost};18use anyhow::{Result, anyhow};1920pub static PRIMOPS_DATA: OnceLock<Config> = OnceLock::new();2122#[derive(Deserialize)]23#[serde(rename_all = "camelCase")]24enum GeneratorKind {25 Impure,26 Pure,27}2829pub fn get_pkgs_and_generators(host_on: &ConfigHost, recipients: Vec<String>) -> Result<Value> {30 let pkgs = host_on.pkgs()?;31 let default_mk_secret_generators = nix_go!(pkgs.mkSecretGenerators);32 let generators = nix_go!(default_mk_secret_generators(Obj { recipients }));33 pkgs.clone().attrs_update(generators)34}35pub fn get_default_pkgs_and_generators(config: &Config) -> Result<Value> {36 let host_on = config.local_host();37 get_pkgs_and_generators(&host_on, vec![])38}39pub fn call_package(config: &Config, pkgs: &Value, package: &Value) -> Result<Value> {40 ensure!(41 package.is_function(),42 "package should be a function to be called with callPackage"43 );44 45 let nixpkgs = &config.nixpkgs;46 let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs));47 Ok(nix_go!(call_package(package)(Obj {})))48}4950pub fn get_default_generator_drv(config: &Config, generator: &Value) -> Result<Value> {51 let default_pkgs_and_generators = get_default_pkgs_and_generators(config)?;52 let default_generator_drv = call_package(config, &default_pkgs_and_generators, generator)53 .context("failed to initialize generator to get metadata")?;5455 Ok(default_generator_drv)56}5758fn secret_to_parts(59 secret_name: &str,60 secret: &BTreeMap<String, FleetSecretPart>,61 expected: &BTreeMap<String, GeneratorPart>,62) -> Value {63 let mut out = HashMap::new();64 for (part_name, part) in secret {65 if !expected.contains_key(part_name) {66 warn!(67 "secret {secret_name} part {part_name} is stored, but not defined in nixos config, it will not be passed to nix"68 );69 continue;70 };71 out.insert(72 part_name.as_str(),73 Value::new_attrs(HashMap::from_iter([(74 "raw",75 Value::new_str(&part.raw.to_string()),76 )])),77 );78 }7980 Value::new_attrs(out)81}8283pub async fn generate(84 config: &Config,85 expectations: Expectations,86 generator: &Value,87 default_generator_drv: &Value,88) -> Result<FleetSecretDistribution> {89 let kind: GeneratorKind = nix_go_json!(default_generator_drv.generatorKind);9091 match kind {92 GeneratorKind::Impure => {93 let impure_on: Option<String> = nix_go_json!(default_generator_drv.impureOn);9495 let host_on = if let Some(on) = &impure_on {96 Arc::new(97 config98 .host(on)99 .context("failed to get secret generation target host")?,100 )101 } else {102 config.local_host()103 };104105 let remowt = host_on.remowt().await?;106 let fs = remowt.endpoints::<FsClient<BifConfig>>();107108 let mut recipients = Vec::new();109 for owner in &expectations.owners {110 recipients.push(config.key(owner).await?);111 }112 let pkgs_and_generators = get_pkgs_and_generators(&host_on, recipients)113 .context("failed to get pkgs for target host")?;114 let generator = call_package(config, &pkgs_and_generators, generator)115 .context("failed to evaluate generator for target host")?;116117 let generator = generator118 .build("out")119 .context("failed to build generator for target host")?;120121 let generator = host_on122 .remote_derivation(&generator, None)123 .await124 .context("failed to copy generator to target host")?;125126 127 let out_parent = fs128 .mktemp_dir()129 .await130 .map_err(|e| anyhow!("{e:?}"))131 .context("failed to prepare generator output dir on target host")?;132 let out = out_parent.join("out");133 let mut generator_cmd = remowt.cmd(generator);134 generator_cmd.env("out", &out);135 if impure_on.is_none() {136 let project_path: String = config137 .directory138 .clone()139 .into_os_string()140 .into_string()141 .map_err(|e| anyhow!("fleet project path is not utf-8: {e:?}"))?;142 generator_cmd.env("FLEET_PROJECT", project_path);143 };144 generator_cmd145 .run()146 .await147 .context("failed to run impure generator")?;148149 {150 let marker = fs.read_file_text(out.join("marker")).await?;151 ensure!(152 marker == "SUCCESS",153 "impure generator ended prematurely, secret generation failed"154 );155 }156157 let mut missing_parts = expectations.parts.clone();158 let mut parts = BTreeMap::new();159 for part in fs.read_dir(&out).await? {160 let part = part.into_string();161 if part == "created_at" || part == "expires_at" || part == "marker" {162 continue;163 }164 let Some(part_def) = missing_parts.remove(&part) else {165 bail!("secret generator has produced an unexpected part: {part}");166 };167 let contents: SecretData = fs168 .read_file_text(out.join(&part))169 .await?170 .parse()171 .map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;172173 ensure!(174 contents.encrypted == part_def.encrypted,175 "part {part} produced by generator is supposed to be {}, but it was not",176 if part_def.encrypted {177 "encrypted"178 } else {179 "plaintext"180 }181 );182183 parts.insert(part.to_owned(), FleetSecretPart { raw: contents });184 }185 if !missing_parts.is_empty() {186 bail!(187 "the secret generator has not produced the following expected parts: {missing_parts:?}"188 );189 }190191 let created_at = fs.read_file_value(out.join("created_at")).await??;192 let expires_at = match fs.read_file_value(out.join("expires_at")).await {193 Ok(v) => Some(v?),194 Err(remowt_endpoints::fs::Error::NotFound) => None,195 Err(e) => return Err(e.into()),196 };197198 let new_data = FleetSecretData {199 created_at,200 expires_at,201 parts,202 generation_data: expectations.generation_data.clone(),203 };204205 let new_data =206 FleetSecretDistribution::new(expectations.owners.clone(), new_data, config.now);207208 Ok(new_data)209 }210 GeneratorKind::Pure => {211 bail!("pure generators are disabled for now")212 }213 }214}215216pub fn init_primops() {217 NativeFn::new(218 c"__fleetEnsureHostSecrets",219 c"Ensure no extra secrets are stored for the host, pruning unknown",220 [c"host", c"expectedNonshared", c"expectedShared", c"rest"],221 |_es, [host, expected_nonshared, expected_shared, rest]| {222 let host = SecretOwner::host(host.to_string()?);223 let expected_nonshared: BTreeSet<String> = expected_nonshared.as_json()?;224 let expected_shared: BTreeSet<String> = expected_shared.as_json()?;225226 let mut expected = expected_nonshared;227 expected.extend(expected_shared);228229 let config = PRIMOPS_DATA230 .get()231 .expect("primops data should be set on init");232233 config234 .data235 .secrets236 .write()237 .expect("no poisoning")238 .prune_host(&host, expected);239240 Ok(rest.clone())241 },242 )243 .register();244 NativeFn::new(245 c"__fleetEnsureHostSecret",246 c"Ensure secret existence for a host, regenerating it in case of some mismatch",247 [c"host", c"secret", c"generator"],248 |es, [host, secret, generator]| {249 let host = SecretOwner::host(&host.to_string()?);250 let secret = secret.to_string()?;251252 let config = PRIMOPS_DATA253 .get()254 .expect("primops data should be set on init");255256 let shared_def = config.secret_definition(&secret).context("failed to get shared secret definition")?;257258 let (shared, generator, expected_owners) = if generator.is_string() {259 assert_eq!(generator.to_string()?, "shared", "asserted by nixos type system");260 let Some(shared_def) = shared_def else {261 bail!("secret {secret} is defined on host {host} as shared, but there is no shared secret with same name defined at fleetConfiguration.secrets.{secret}.generator")262 };263 let expected_owners = shared_def.expected_owners()?;264265 ensure!(expected_owners.contains(&host), "secret {secret} does not define {host} as expected owner");266267 (Some(shared_def.clone()), shared_def.generator()?, expected_owners)268 } else {269 if shared_def.is_some() {270 bail!("hosts can only have their own generators for non-shared secrets, either set host secret generator to \"shared\", or remove shared secret generator at fleetConfiguration.secrets.{secret}.generator")271 }272273 (None, generator.clone(), BTreeSet::from_iter([host.clone()]))274 };275276 let default_generator_drv = get_default_generator_drv(config, &generator)?;277 let mut expectations = Expectations {278 parts: nix_go_json!(default_generator_drv.parts),279 generation_data: nix_go_json!(default_generator_drv.generationData),280 owners: expected_owners.clone(),281 };282 let constraints = if let Some(shared) = &shared{283 RegenerationConstraints {284 allow_different: nix_go_json!(default_generator_drv.allowDifferent) && shared.allow_different()?,285 regenerate_on_owner_added: shared.regenerate_on_owner_added()?,286 regenerate_on_owner_removed: shared.regenerate_on_owner_added()?,287 }288 } else {289 RegenerationConstraints::host_personal()290 };291292 let mut secrets = config.data.secrets.write().expect("no poisoning");293 let dists = secrets.get_or_create(&secret);294295 if shared.is_some() {296 dists.prune_shared(&expected_owners, !constraints.allow_different, &expectations.parts, &expectations.generation_data, constraints.regenerate_on_owner_removed, constraints.regenerate_on_owner_added, &config.prefer_identities, config.now);297 } else {298 dists.prune_host(host.clone(), &expectations.parts, &expectations.generation_data, config.now);299 };300301 if let Some(dist) = dists.get(&host) {302 return Ok(secret_to_parts(&secret, &dist.secret.parts, &expectations.parts));303 };304305 let mut reencrypt_targets = expectations.owners.clone();306 for dist in dists.distributions() {307 for own in dist.owners() {308 reencrypt_targets.remove(own);309 }310 }311 if !constraints.regenerate_on_owner_added {312 if let Some(unpruned) = dists.try_unprune(host.clone()) {313 return Ok(secret_to_parts(&secret, &unpruned.secret.parts, &expectations.parts));314 } else if let Some(best) = dists.best_distribution_for_reencryption(&config.prefer_identities) {315 let new_owners = reencrypt_targets.clone();316 let mut reencrypt_targets = reencrypt_targets;317 reencrypt_targets.extend(best.owners().cloned());318319 let mut preferred = best.owners().collect_vec();320 preferred.sort_by_key(|v| !config.prefer_identities.contains(*v));321322 warn!("reencrypting secret {secret} as it is missing for host {host}");323324 for owner in preferred {325 if let Some(hostname) = owner.as_host() && let Ok(host) = config.host(hostname) {326 let best = best.clone();327 let reencrypt_targets = reencrypt_targets.clone();328 let reencrypted = match await_in_nix(async move {329 host.reencrypt_distribution(&best, reencrypt_targets.clone(), config.now).await330 }) {331 Ok(r) => r,332 Err(e) => {333 warn!("reencryption failed on {hostname}: {e:?}");334 continue;335 }336 };337 dists.extend(reencrypted.clone(), format!("secret was reencrypted to extend with new owners: {new_owners:?}"));338 return Ok(secret_to_parts(&secret, &reencrypted.secret.parts, &expectations.parts));339 };340 }341 warn!("failed to reencrypt using any host")342 };343 };344345 if constraints.allow_different {346 for dist in dists.distributions() {347 for own in dist.owners() {348 expectations.owners.remove(own);349 }350 }351 }352 info!("secret {secret} is being generated for {:?}", expectations.owners);353354 let expectations_ = expectations.clone();355 let generated = await_in_nix(async move {356 generate(config, expectations_, &generator, &default_generator_drv).await357 })?;358359 dists.extend(generated.clone(), "secret was generated".to_string());360361 Ok(secret_to_parts(&secret, &generated.secret.parts, &expectations.parts))362 },363 )364 .register();365}