1use std::collections::{BTreeMap, BTreeSet, HashMap};2use std::sync::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 serde::Deserialize;9use tracing::{info, warn};1011use crate::fleetdata::{12 Expectations, FleetSecretData, FleetSecretDistribution, FleetSecretPart, GeneratorPart,13 RegenerationConstraints, SecretOwner,14};15use crate::host::{Config, ConfigHost};16use anyhow::{Result, anyhow};1718pub static PRIMOPS_DATA: OnceLock<Config> = OnceLock::new();1920#[derive(Deserialize)]21#[serde(rename_all = "camelCase")]22enum GeneratorKind {23 Impure,24 Pure,25}2627pub fn get_pkgs_and_generators(host_on: &ConfigHost, recipients: Vec<String>) -> Result<Value> {28 let pkgs = host_on.pkgs()?;29 let default_mk_secret_generators = nix_go!(pkgs.mkSecretGenerators);30 let generators = nix_go!(default_mk_secret_generators(Obj { recipients }));31 Ok(pkgs.clone().attrs_update(generators)?)32}33pub fn get_default_pkgs_and_generators(config: &Config) -> Result<Value> {34 let host_on = config.local_host();35 get_pkgs_and_generators(&host_on, vec![])36}37pub fn call_package(config: &Config, pkgs: &Value, package: &Value) -> Result<Value> {38 ensure!(39 package.is_function(),40 "package should be a function to be called with callPackage"41 );42 43 let nixpkgs = &config.nixpkgs;44 let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs));45 Ok(nix_go!(call_package(package)(Obj {})))46}4748pub fn get_default_generator_drv(config: &Config, generator: &Value) -> Result<Value> {49 let default_pkgs_and_generators = get_default_pkgs_and_generators(config)?;50 let default_generator_drv = call_package(config, &default_pkgs_and_generators, generator)51 .context("failed to initialize generator to get metadata")?;5253 Ok(default_generator_drv)54}5556fn secret_to_parts(57 secret_name: &str,58 secret: &BTreeMap<String, FleetSecretPart>,59 expected: &BTreeMap<String, GeneratorPart>,60) -> Value {61 let mut out = HashMap::new();62 for (part_name, part) in secret {63 if !expected.contains_key(part_name) {64 warn!(65 "secret {secret_name} part {part_name} is stored, but not defined in nixos config, it will not be passed to nix"66 );67 continue;68 };69 out.insert(70 part_name.as_str(),71 Value::new_attrs(HashMap::from_iter([(72 "raw",73 Value::new_str(&part.raw.to_string()),74 )])),75 );76 }7778 Value::new_attrs(out)79}8081pub async fn generate(82 config: &Config,83 expectations: Expectations,84 generator: &Value,85 default_generator_drv: &Value,86) -> Result<FleetSecretDistribution> {87 let kind: GeneratorKind = nix_go_json!(default_generator_drv.generatorKind);8889 match kind {90 GeneratorKind::Impure => {91 let impure_on: Option<String> = nix_go_json!(default_generator_drv.impureOn);9293 let host_on = if let Some(on) = &impure_on {94 config95 .host(on)96 .context("failed to get secret generation target host")?97 } else {98 config.local_host()99 };100 let mut recipients = Vec::new();101 for owner in &expectations.owners {102 recipients.push(config.key(owner).await?);103 }104 let pkgs_and_generators = get_pkgs_and_generators(&host_on, recipients)105 .context("failed to get pkgs for target host")?;106 let generator = call_package(config, &pkgs_and_generators, generator)107 .context("failed to evaluate generator for target host")?;108109 let generator = generator110 .build("out")111 .context("failed to build generator for target host")?;112113 let generator = host_on114 .remote_derivation(&generator)115 .await116 .context("failed to copy generator to target host")?;117118 119 let out_parent = host_on120 .mktemp_dir()121 .await122 .context("failed to prepare generator output dir on target host")?;123 let out = format!("{out_parent}/out");124 let mut generator_cmd = host_on.cmd(generator).await?;125 generator_cmd.env("out", &out);126 if impure_on.is_none() {127 let project_path: String = config128 .directory129 .clone()130 .into_os_string()131 .into_string()132 .map_err(|e| anyhow!("fleet project path is not utf-8: {e:?}"))?;133 generator_cmd.env("FLEET_PROJECT", project_path);134 };135 generator_cmd136 .run()137 .await138 .context("failed to run impure generator")?;139140 {141 let marker = host_on.read_file_text(format!("{out}/marker")).await?;142 ensure!(143 marker == "SUCCESS",144 "impure generator ended prematurely, secret generation failed"145 );146 }147148 let mut parts = BTreeMap::new();149 for part in host_on.read_dir(&out).await? {150 if part == "created_at" || part == "expires_at" || part == "marker" {151 continue;152 }153 let contents: SecretData = host_on154 .read_file_text(format!("{out}/{part}"))155 .await?156 .parse()157 .map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;158 parts.insert(part.to_owned(), FleetSecretPart { raw: contents });159 }160161 let created_at = host_on.read_file_value(format!("{out}/created_at")).await?;162 let expires_at = host_on163 .read_file_value(format!("{out}/expires_at"))164 .await165 .ok();166167 let new_data = FleetSecretData {168 created_at,169 expires_at,170 parts,171 generation_data: expectations.generation_data.clone(),172 };173174 let new_data =175 FleetSecretDistribution::new(expectations.owners.clone(), new_data, config.now);176177 Ok(new_data)178 }179 GeneratorKind::Pure => {180 bail!("pure generators are disabled for now")181 }182 }183}184185pub fn init_primops() {186 NativeFn::new(187 c"__fleetEnsureHostSecrets",188 c"Ensure no extra secrets are stored for the host, pruning unknown",189 [c"host", c"expectedNonshared", c"expectedShared", c"rest"],190 |_es, [host, expected_nonshared, expected_shared, rest]| {191 let host = SecretOwner::host(host.to_string()?);192 let expected_nonshared: BTreeSet<String> = expected_nonshared.as_json()?;193 let expected_shared: BTreeSet<String> = expected_shared.as_json()?;194195 let mut expected = expected_nonshared;196 expected.extend(expected_shared);197198 let config = PRIMOPS_DATA199 .get()200 .expect("primops data should be set on init");201202 config203 .data204 .secrets205 .write()206 .expect("no poisoning")207 .prune_host(&host, expected);208209 Ok(rest.clone())210 },211 )212 .register();213 NativeFn::new(214 c"__fleetEnsureHostSecret",215 c"Ensure secret existence for a host, regenerating it in case of some mismatch",216 [c"host", c"secret", c"generator"],217 |es, [host, secret, generator]| {218 let host = SecretOwner::host(&host.to_string()?);219 let secret = secret.to_string()?;220221 let config = PRIMOPS_DATA222 .get()223 .expect("primops data should be set on init");224225 let shared_def = config.secret_definition(&secret).context("failed to get shared secret definition")?;226227 let (shared, generator, expected_owners) = if generator.is_string() {228 assert_eq!(generator.to_string()?, "shared", "asserted by nixos type system");229 let Some(shared_def) = shared_def else {230 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")231 };232 let expected_owners = shared_def.expected_owners()?;233234 ensure!(expected_owners.contains(&host), "secret {secret} does not define {host} as expected owner");235236 (Some(shared_def.clone()), shared_def.generator()?, expected_owners)237 } else {238 if shared_def.is_some() {239 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")240 }241242 (None, generator.clone(), BTreeSet::from_iter([host.clone()]))243 };244245 let default_generator_drv = get_default_generator_drv(config, &generator)?;246 let mut expectations = Expectations {247 parts: nix_go_json!(default_generator_drv.parts),248 generation_data: nix_go_json!(default_generator_drv.generationData),249 owners: expected_owners.clone(),250 };251 let constraints = if let Some(shared) = &shared{252 RegenerationConstraints {253 allow_different: nix_go_json!(default_generator_drv.allowDifferent) && shared.allow_different()?,254 regenerate_on_owner_added: shared.regenerate_on_owner_added()?,255 regenerate_on_owner_removed: shared.regenerate_on_owner_added()?,256 }257 } else {258 RegenerationConstraints::host_personal()259 };260261 let mut secrets = config.data.secrets.write().expect("no poisoning");262 let dists = secrets.get_or_create(&secret);263264 if shared.is_some() {265 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);266 } else {267 dists.prune_host(host.clone(), &expectations.parts, &expectations.generation_data, config.now);268 };269270 if let Some(dist) = dists.get(&host) {271 return Ok(secret_to_parts(&secret, &dist.secret.parts, &expectations.parts));272 };273274 let mut reencrypt_targets = expectations.owners.clone();275 for dist in dists.distributions() {276 for own in dist.owners() {277 reencrypt_targets.remove(own);278 }279 }280 if !constraints.regenerate_on_owner_added {281 if let Some(unpruned) = dists.try_unprune(host.clone()) {282 return Ok(secret_to_parts(&secret, &unpruned.secret.parts, &expectations.parts));283 } else if let Some(best) = dists.best_distribution_for_reencryption(&config.prefer_identities) {284 let new_owners = reencrypt_targets.clone();285 let mut reencrypt_targets = reencrypt_targets;286 reencrypt_targets.extend(best.owners().cloned());287288 let mut preferred = best.owners().collect_vec();289 preferred.sort_by_key(|v| !config.prefer_identities.contains(*v));290291 warn!("reencrypting secret {secret} as it is missing for host {host}");292293 for owner in preferred {294 if let Some(hostname) = owner.as_host() && let Ok(host) = config.host(hostname) {295 let best = best.clone();296 let reencrypt_targets = reencrypt_targets.clone();297 let reencrypted = match await_in_nix(async move {298 host.reencrypt_distribution(&best, reencrypt_targets.clone(), config.now).await299 }) {300 Ok(r) => r,301 Err(e) => {302 warn!("reencryption failed on {hostname}: {e:?}");303 continue;304 }305 };306 dists.extend(reencrypted.clone(), format!("secret was reencrypted to extend with new owners: {new_owners:?}"));307 return Ok(secret_to_parts(&secret, &reencrypted.secret.parts, &expectations.parts));308 };309 }310 warn!("failed to reencrypt using any host")311 };312 };313314 if constraints.allow_different {315 for dist in dists.distributions() {316 for own in dist.owners() {317 expectations.owners.remove(own);318 }319 }320 }321 info!("secret {secret} is being generated for {:?}", expectations.owners);322323 let expectations_ = expectations.clone();324 let generated = await_in_nix(async move {325 generate(config, expectations_, &generator, &default_generator_drv).await326 })?;327328 dists.extend(generated.clone(), "secret was generated".to_string());329330 Ok(secret_to_parts(&secret, &generated.secret.parts, &expectations.parts))331 },332 )333 .register();334}