difftreelog
feat on-demand secret generation
in: trunk
24 files changed
Cargo.lockdiffbeforeafterboth--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1149,7 +1149,6 @@
"base64 0.22.1",
"serde",
"unicode_categories",
- "z85",
]
[[package]]
@@ -2089,6 +2088,7 @@
"serde_json",
"test-log",
"thiserror 2.0.17",
+ "tokio",
"tracing",
"tracing-indicatif",
"vte 0.15.0",
@@ -4638,12 +4638,6 @@
"syn",
"synstructure",
]
-
-[[package]]
-name = "z85"
-version = "3.0.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b3a41ce106832b4da1c065baa4c31cf640cf965fa1483816402b7f6b96f0a64"
[[package]]
name = "zerocopy"
cmds/fleet/src/cmds/build_systems.rsdiffbeforeafterboth--- a/cmds/fleet/src/cmds/build_systems.rs
+++ b/cmds/fleet/src/cmds/build_systems.rs
@@ -7,8 +7,9 @@
host::{Config, DeployKind, GenerationStorage},
opts::FleetOpts,
};
+use futures::{StreamExt as _, stream::FuturesUnordered};
use nix_eval::nix_go;
-use tokio::task::{LocalSet, spawn_blocking};
+use tokio::task::spawn_blocking;
use tracing::{Instrument, error, field, info, info_span, warn};
#[derive(Parser)]
@@ -47,7 +48,7 @@
"--profile",
format!(
"/nix/var/nix/profiles/{}-{hostname}",
- config.data().gc_root_prefix
+ config.data.gc_root_prefix
),
)
.arg(&out_output);
@@ -60,14 +61,14 @@
impl BuildSystems {
pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {
let hosts = opts.filter_skipped(config.list_hosts()?)?;
- let set = LocalSet::new();
+ let mut tasks = FuturesUnordered::new();
let build_attr = self.build_attr.clone();
for host in hosts {
let config = config.clone();
let span = info_span!("build", host = field::display(&host.name));
let hostname = host.name;
let build_attr = build_attr.clone();
- set.spawn_local(
+ tasks.push(
(async move {
let built = match build_task(config, hostname.clone(), &build_attr).await {
Ok(path) => path,
@@ -88,7 +89,7 @@
.instrument(span),
);
}
- set.await;
+ for _task in tasks.next().await {}
Ok(())
}
}
@@ -96,7 +97,7 @@
impl Deploy {
pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {
let hosts = opts.filter_skipped(config.list_hosts()?)?;
- let set = LocalSet::new();
+ let mut tasks = FuturesUnordered::new();
for host in hosts.into_iter() {
let config = config.clone();
let span = info_span!("deploy", host = field::display(&host.name));
@@ -112,7 +113,7 @@
host.set_legacy_ssh_store(legacy);
};
- set.spawn_local(
+ tasks.push(
(async move {
let built = match build_task(config.clone(), hostname.clone(), "toplevel-fleet")
.await
@@ -170,7 +171,7 @@
.instrument(span),
);
}
- set.await;
+ for _task in tasks.next().await {}
Ok(())
}
}
cmds/fleet/src/cmds/rollback.rsdiffbeforeafterboth--- a/cmds/fleet/src/cmds/rollback.rs
+++ b/cmds/fleet/src/cmds/rollback.rs
@@ -56,7 +56,7 @@
.collect::<HashSet<_>>();
let mut stored_locally = config
.local_host()
- .list_generations(&format!("{}-{}", config.data().gc_root_prefix, host.name))
+ .list_generations(&format!("{}-{}", config.data.gc_root_prefix, host.name))
.await
.inspect_err(|e| {
warn!("failed to list generations available locally: {e}");
cmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth--- a/cmds/fleet/src/cmds/secrets/mod.rs
+++ b/cmds/fleet/src/cmds/secrets/mod.rs
@@ -412,7 +412,7 @@
if opts.should_skip(&host)? {
continue;
}
- config.key(&host.name).await?;
+ config.host_key(&host.name).await?;
}
}
Secret::Read {
@@ -421,6 +421,7 @@
part: part_name,
mut prefer_identities,
} => {
+ /*
let Some(secret) = config.shared_secret(&name) else {
bail!("secret doesn't exists");
};
@@ -460,6 +461,8 @@
part.raw.data.clone()
};
stdout().write_all(&data)?;
+ */
+ todo!()
}
Secret::Regenerate {
prefer_identities,
@@ -605,6 +608,7 @@
todo!()
}
Secret::List {} => {
+ /*
let _span = info_span!("loading secrets").entered();
let configured = config.list_configured_shared()?;
#[derive(Tabled)]
@@ -638,6 +642,8 @@
*/
}
// info!("loaded\n{}", Table::new(table).to_string())
+ */
+ todo!()
}
Secret::Edit {
name,
@@ -645,7 +651,7 @@
part,
add,
} => {
- let secret = config
+ /*let secret = config
.host_secret(&machine, &name)
.context("secret not found")?;
if let Some(data) = secret.secret.parts.get(&part) {
@@ -656,7 +662,8 @@
String::new()
} else {
bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");
- };
+ };*/
+ todo!()
}
}
Ok(())
cmds/fleet/src/cmds/tf.rsdiffbeforeafterboth--- a/cmds/fleet/src/cmds/tf.rs
+++ b/cmds/fleet/src/cmds/tf.rs
@@ -72,9 +72,9 @@
let tf_data: TfData = serde_json::from_slice(&data.stdout)
.context("failed to parse terraform fleet output")?;
- let mut data = config.data();
debug!("synchronized done = {tf_data:?}");
- data.extra.insert(
+ let mut extra = config.data.extra.write().expect("no poisoning");
+ extra.insert(
"terraformHosts".to_owned(),
serde_json::to_value(tf_data.hosts).expect("should be valid extra"),
);
cmds/fleet/src/main.rsdiffbeforeafterboth--- a/cmds/fleet/src/main.rs
+++ b/cmds/fleet/src/main.rs
@@ -4,7 +4,7 @@
// pub(crate) mod command;
pub(crate) mod extra_args;
-use std::{env, ffi::OsString, process::ExitCode};
+use std::{env, ffi::OsString, process::ExitCode, sync::Arc};
use anyhow::{Result, bail};
use clap::{CommandFactory, Parser};
@@ -23,7 +23,9 @@
use human_repr::HumanCount;
#[cfg(feature = "indicatif")]
use indicatif::{ProgressState, ProgressStyle};
-use nix_eval::{gc_register_my_thread, gc_unregister_my_thread, init_libraries};
+use nix_eval::{
+ gc_register_my_thread, gc_unregister_my_thread, init_libraries, init_tokio_for_nix,
+};
use tracing::{Instrument, error, info, info_span};
#[cfg(feature = "indicatif")]
use tracing_indicatif::IndicatifLayer;
@@ -39,9 +41,9 @@
info!("nothing to prefetch: no prefetch directory");
return Ok(());
}
- let tasks = <FuturesUnordered<LocalBoxFuture<Result<()>>>>::new();
+ let tasks = FuturesUnordered::new();
for entry in std::fs::read_dir(&prefetch_dir)? {
- tasks.push(Box::pin(async {
+ tasks.push(async {
let entry = entry?;
if !entry.metadata()?.is_file() {
bail!("only files should exist in prefetch directory");
@@ -59,7 +61,7 @@
status.arg("store").arg("prefetch-file").arg(path);
status.run_nix_string().instrument(span).await?;
Ok(())
- }));
+ });
}
tasks.try_collect::<Vec<()>>().await?;
Ok(())
@@ -190,7 +192,7 @@
init_libraries();
- tokio::runtime::Builder::new_multi_thread()
+ let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.on_thread_start(|| {
gc_register_my_thread();
@@ -199,8 +201,13 @@
gc_unregister_my_thread();
})
.build()
- .expect("failed to build runtime")
- .block_on(async {
+ .expect("failed to build runtime");
+ let runtime = Arc::new(runtime);
+
+ init_tokio_for_nix(runtime.clone());
+
+ runtime.block_on(async {
+ tokio::task::spawn(async move {
if let Err(e) = main_real(opts).await {
error!("{e:#}");
ExitCode::FAILURE
@@ -208,6 +215,9 @@
ExitCode::SUCCESS
}
})
+ .await
+ .expect("primary task panicked")
+ })
// async_main(opts)
}
crates/fleet-base/src/command.rsdiffbeforeafterboth--- a/crates/fleet-base/src/command.rs
+++ b/crates/fleet-base/src/command.rs
@@ -334,7 +334,7 @@
let mut stderr = child.stderr.take().unwrap();
let stdout = child.stdout.take().unwrap();
let mut err = FramedRead::new(&mut stderr, LinesCodec::new());
- let mut out: Option<Box<dyn AsyncRead + Unpin>> = Some(Box::new(stdout));
+ let mut out: Option<Box<dyn AsyncRead + Unpin + Send>> = Some(Box::new(stdout));
let mut ob = want_stdout
.then(|| out.take().unwrap())
.unwrap_or_else(|| Box::new(EmptyAsyncRead));
@@ -397,7 +397,7 @@
let mut stderr = child.stderr().take().unwrap();
let stdout = child.stdout().take().unwrap();
let mut err = FramedRead::new(&mut stderr, LinesCodec::new());
- let mut out: Option<Box<dyn AsyncRead + Unpin>> = Some(Box::new(stdout));
+ let mut out: Option<Box<dyn AsyncRead + Unpin + Send>> = Some(Box::new(stdout));
let mut ob = want_stdout
.then(|| out.take().unwrap())
.unwrap_or_else(|| Box::new(EmptyAsyncRead));
crates/fleet-base/src/deploy.rsdiffbeforeafterboth--- a/crates/fleet-base/src/deploy.rs
+++ b/crates/fleet-base/src/deploy.rs
@@ -78,7 +78,7 @@
// unit name conflict in systemd-run
// This code is tied to rollback.nix
if !disable_rollback && action.should_create_rollback_marker() {
- let _span = info_span!("preparing").entered();
+ // let _span = info_span!("preparing").entered();
info!("preparing for rollback");
let generation = get_current_generation(host).await?;
info!(
@@ -179,7 +179,7 @@
// FIXME: Connection might be disconnected after activation run
if action.should_activate() && !failed {
- let _span = info_span!("activating").entered();
+ // let _span = info_span!("activating").entered();
info!("executing activation script");
let specialised = if let Some(specialisation) = specialisation {
let mut specialised = built.join("specialisation");
crates/fleet-base/src/fleetdata.rsdiffbeforeafterboth1use std::{2 collections::{3 BTreeMap, BTreeSet,4 btree_map::{self, Entry},5 },6 io::{self, Cursor},7 ops::Deref,8};910use age::Recipient;11use chrono::{DateTime, Utc};12use fleet_shared::SecretData;13use rand::{14 distr::{Alphanumeric, SampleString as _},15 rng,16};17use serde::{18 Deserialize, Serialize,19 de::{self, Error},20};21use serde_json::Value;22use tracing::info;2324#[derive(Serialize, Deserialize, Default)]25#[serde(rename_all = "camelCase")]26pub struct HostData {27 #[serde(default)]28 #[serde(skip_serializing_if = "String::is_empty")]29 pub encryption_key: String,30}3132const VERSION: &str = "0.1.0";33pub struct FleetDataVersion;34impl Serialize for FleetDataVersion {35 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>36 where37 S: serde::Serializer,38 {39 VERSION.serialize(serializer)40 }41}42impl<'de> Deserialize<'de> for FleetDataVersion {43 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>44 where45 D: serde::Deserializer<'de>,46 {47 let version = String::deserialize(deserializer)?;48 if version != VERSION {49 return Err(D::Error::custom(format!(50 "fleet.nix data version mismatch, expected {VERSION}, got {version}.\nFollow the docs for migration instruction"51 )));52 }53 Ok(Self)54 }55}5657fn generate_gc_prefix() -> String {58 let id = Alphanumeric.sample_string(&mut rng(), 8);59 format!("fleet-gc-{id}")60}6162#[derive(Serialize, Deserialize)]63#[serde(rename_all = "camelCase")]64pub struct ManagerKey {65 pub name: String,66 pub key: String,67}6869#[derive(Serialize, Deserialize)]70#[serde(rename_all = "camelCase")]71pub struct FleetData {72 pub version: FleetDataVersion,73 #[serde(default = "generate_gc_prefix")]74 pub gc_root_prefix: String,7576 #[serde(default, skip_serializing_if = "Vec::is_empty")]77 pub manager_keys: Vec<ManagerKey>,7879 #[serde(default)]80 pub hosts: BTreeMap<String, HostData>,8182 #[serde(default, alias = "shared_secrets")]83 pub secrets: FleetSecrets,8485 // extra_name => anything86 #[serde(default)]87 #[serde(skip_serializing_if = "BTreeMap::is_empty")]88 pub extra: BTreeMap<String, Value>,8990 #[serde(default)]91 #[serde(skip_serializing_if = "BTreeMap::is_empty")]92 host_secrets: BTreeMap<String, BTreeMap<String, FleetSecretDistribution>>,93}94impl FleetData {95 pub fn from_str(s: &str) -> anyhow::Result<Self> {96 let mut data: Self = nixlike::parse_str(s)?;97 if !data.host_secrets.is_empty() {98 info!("migrating host secrets into shared secrets structure");99 data.secrets100 .merge_from_hosts(std::mem::take(&mut data.host_secrets));101 }102 Ok(data)103 }104}105106/// Returns None if recipients.is_empty()107pub fn encrypt_secret_data<'r>(108 recipients: impl IntoIterator<Item = &'r Box<dyn Recipient>>,109 data: Vec<u8>,110) -> Option<SecretData> {111 let mut encrypted = vec![];112 let mut encryptor = age::Encryptor::with_recipients(recipients.into_iter().map(|v| &**v))113 .ok()?114 .wrap_output(&mut encrypted)115 .expect("in memory write");116 io::copy(&mut Cursor::new(data), &mut encryptor).expect("in memory copy");117 encryptor.finish().expect("in memory flush");118 Some(SecretData {119 data: encrypted,120 encrypted: true,121 })122}123124#[derive(Serialize, Deserialize, Clone, Debug)]125pub struct FleetSecretPart {126 pub raw: SecretData,127}128129#[derive(Serialize, Deserialize, Clone, Debug)]130#[serde(rename_all = "camelCase")]131#[must_use]132pub struct FleetSecretData {133 #[serde(default = "Utc::now")]134 pub created_at: DateTime<Utc>,135 #[serde(default)]136 #[serde(skip_serializing_if = "Option::is_none", alias = "expire_at")]137 pub expires_at: Option<DateTime<Utc>>,138139 #[serde(flatten)]140 pub parts: BTreeMap<String, FleetSecretPart>,141142 #[serde(default)]143 #[serde(skip_serializing_if = "Value::is_null")]144 pub generation_data: Value,145}146147#[derive(Serialize, Deserialize, Clone, Debug)]148#[serde(rename_all = "camelCase")]149#[must_use]150pub struct FleetSecretDistribution {151 #[serde(default)]152 pub owners: BTreeSet<String>,153 #[serde(flatten)]154 pub secret: FleetSecretData,155156 #[serde(default, skip_serializing, alias = "managed")]157 pub _deprecated_managed: bool,158}159160#[derive(Clone)]161#[must_use]162pub struct FleetSecretDistributions(Vec<FleetSecretDistribution>);163164impl Deref for FleetSecretDistributions {165 type Target = [FleetSecretDistribution];166167 fn deref(&self) -> &Self::Target {168 self.0.as_slice()169 }170}171172impl FleetSecretDistributions {173 pub fn owners(&self) -> impl Iterator<Item = &String> {174 self.0.iter().flat_map(|v| v.owners.iter())175 }176 #[allow(177 clippy::len_without_is_empty,178 reason = "should not be empty for a long time"179 )]180 pub fn len(&self) -> usize {181 self.0.len()182 }183184 pub fn get(&self, owner: &str) -> Option<&FleetSecretDistribution> {185 self.0.iter().find(|d| d.owners.contains(owner))186 }187 fn entry(&mut self, owner: String) -> DistEntry<'_> {188 let Some(idx) = self.0.iter().position(|d| d.owners.contains(&owner)) else {189 return DistEntry::Vacant(VacantDistEntry {190 distributions: self,191 owner,192 });193 };194 DistEntry::Occupied(OccupiedDistEntry {195 distributions: self,196 idx,197 owner,198 })199 }200 fn extend(&mut self, dist: FleetSecretDistribution) {201 for owner in &dist.owners {202 self.entry(owner.to_owned()).remove();203 }204 self.0.push(dist);205 }206 pub fn contains(&self, owner: &str) -> bool {207 self.0.iter().any(|d| d.owners.contains(owner))208 }209}210211struct OccupiedDistEntry<'d> {212 distributions: &'d mut FleetSecretDistributions,213 idx: usize,214 owner: String,215}216impl<'d> OccupiedDistEntry<'d> {217 fn remove(self) -> VacantDistEntry<'d> {218 let dist = &mut self.distributions.0[self.idx];219 assert!(220 dist.owners.remove(&self.owner),221 "entry exists, as we have its reference"222 );223 if dist.owners.is_empty() {224 self.distributions.0.remove(self.idx);225 }226 VacantDistEntry {227 distributions: self.distributions,228 owner: self.owner,229 }230 }231 fn set(self, secret: FleetSecretData) -> Self {232 self.remove().set(secret)233 }234}235struct VacantDistEntry<'d> {236 distributions: &'d mut FleetSecretDistributions,237 owner: String,238}239impl<'d> VacantDistEntry<'d> {240 fn set(self, secret: FleetSecretData) -> OccupiedDistEntry<'d> {241 let Self {242 distributions,243 owner,244 } = self;245 let idx = distributions.0.len();246 distributions.0.push(FleetSecretDistribution {247 owners: BTreeSet::from_iter([owner.clone()]),248 secret,249250 _deprecated_managed: true,251 });252 OccupiedDistEntry {253 distributions,254 owner,255 idx,256 }257 }258}259260enum DistEntry<'d> {261 Vacant(VacantDistEntry<'d>),262 Occupied(OccupiedDistEntry<'d>),263}264impl DistEntry<'_> {265 fn remove(self) -> Self {266 match self {267 DistEntry::Vacant(_) => self,268 DistEntry::Occupied(o) => Self::Vacant(o.remove()),269 }270 }271 fn set(self, secret: FleetSecretData) -> Self {272 Self::Occupied(match self {273 DistEntry::Vacant(e) => e.set(secret),274 DistEntry::Occupied(e) => e.set(secret),275 })276 }277}278279impl Serialize for FleetSecretDistributions {280 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>281 where282 S: serde::Serializer,283 {284 let mut found_hosts = BTreeSet::new();285 for ele in self.0.iter() {286 if ele.owners.is_empty() {287 panic!("consistency: secret distribution has no defined owners");288 }289 for ele in ele.owners.iter() {290 if !found_hosts.insert(ele) {291 panic!(292 "consistency: secret distribution contains duplicate entry for the same host",293 );294 }295 }296 }297 match self.0.len() {298 0 => panic!("consistency: empty distributions"),299 1 => self.0[0].serialize(serializer),300 _ => self.0.serialize(serializer),301 }302 }303}304impl<'de> Deserialize<'de> for FleetSecretDistributions {305 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>306 where307 D: serde::Deserializer<'de>,308 {309 #[derive(Deserialize)]310 #[serde(untagged)]311 enum Distributions {312 One(FleetSecretDistribution),313 Many(Vec<FleetSecretDistribution>),314 }315 let d = Distributions::deserialize(deserializer)?;316 let ds = match d {317 Distributions::One(d) => vec![d],318 Distributions::Many(ds) => ds,319 };320 if ds.is_empty() {321 return Err(de::Error::custom("consistency: empty distributions"));322 }323 let mut found_hosts = BTreeSet::new();324 for ele in ds.iter() {325 if ele.owners.is_empty() {326 return Err(de::Error::custom(327 "consistency: secret distribution has no defined owners",328 ));329 }330 for ele in ele.owners.iter() {331 if !found_hosts.insert(ele) {332 return Err(de::Error::custom(333 "consistency: secret distribution contains duplicate entry for the same host",334 ));335 }336 }337 }338 Ok(Self(ds))339 }340}341342#[derive(Serialize, Deserialize, Default)]343pub struct FleetSecrets(BTreeMap<String, FleetSecretDistributions>);344345impl FleetSecrets {346 pub fn keys(&self) -> btree_map::Keys<String, FleetSecretDistributions> {347 self.0.keys()348 }349350 pub fn keys_for_owner(&self, owner: &str) -> impl Iterator<Item = &String> {351 self.0352 .iter()353 .filter(|(_, d)| d.contains(owner))354 .map(|(n, _)| n)355 }356357 pub fn drop_owner_no_reencrypt(&mut self, secret: &str, owner: &str) -> bool {358 let Entry::Occupied(mut dists) = self.0.entry(secret.to_owned()) else {359 return false;360 };361 let DistEntry::Occupied(dist) = dists.get_mut().entry(owner.to_owned()) else {362 return false;363 };364365 dist.remove();366367 if dists.get().0.is_empty() {368 dists.remove();369 };370371 true372 }373 pub fn set_single_data(&mut self, secret: String, owner: String, data: FleetSecretData) {374 let e = self375 .0376 .entry(secret.to_owned())377 .or_insert_with(|| FleetSecretDistributions(Default::default()));378 e.entry(owner.to_owned()).set(data);379 }380 pub fn set_data(&mut self, secret: String, data: FleetSecretDistribution) {381 match self.0.entry(secret) {382 Entry::Vacant(e) => {383 e.insert(FleetSecretDistributions(vec![data]));384 }385 Entry::Occupied(mut e) => {386 let dists = e.get_mut();387 dists.extend(data)388 }389 }390 }391 pub fn get_single(&self, secret: &str, owner: &str) -> Option<&FleetSecretDistribution> {392 let secret = self.0.get(secret)?;393 secret.get(owner)394 }395 pub fn get(&self, secret: &str) -> Option<&FleetSecretDistributions> {396 self.0.get(secret)397 }398399 pub fn contains_for_owner(&self, secret: &str, owner: &str) -> bool {400 let Some(secret) = self.0.get(secret) else {401 return false;402 };403 secret.contains(owner)404 }405 pub fn contains(&self, secret: &str) -> bool {406 self.0.contains_key(secret)407 }408 pub fn remove(&mut self, secret: &str) {409 self.0.remove(secret);410 }411412 fn merge_from_hosts(413 &mut self,414 host_secrets: BTreeMap<String, BTreeMap<String, FleetSecretDistribution>>,415 ) {416 for (host, host_secrets) in host_secrets {417 for (secret_name, mut secret_data) in host_secrets {418 secret_data.owners.insert(host.clone());419 self.set_data(secret_name, secret_data);420 }421 }422 }423}424425#[derive(Debug)]426pub struct Expectations {427 pub owners: BTreeSet<String>,428 pub generation_data: serde_json::Value,429 pub parts: BTreeMap<String, GeneratorPart>,430}431#[derive(Deserialize, Debug, Clone)]432pub struct GeneratorPart {433 pub encrypted: bool,434}crates/fleet-base/src/host.rsdiffbeforeafterboth--- a/crates/fleet-base/src/host.rs
+++ b/crates/fleet-base/src/host.rs
@@ -1,6 +1,5 @@
use std::{
- cell::OnceCell,
- collections::BTreeSet,
+ collections::{BTreeMap, BTreeSet},
ffi::{OsStr, OsString},
fmt::Display,
io::Write,
@@ -11,6 +10,7 @@
};
use anyhow::{Context, Result, anyhow, bail, ensure};
+use chrono::{DateTime, Utc};
use fleet_shared::SecretData;
use nix_eval::{Value, nix_go, nix_go_json, util::assert_warn};
use openssh::{ControlPersist, SessionBuilder};
@@ -22,15 +22,20 @@
use crate::{
command::MyCommand,
- fleetdata::{FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretDistributions},
+ fleetdata::{
+ FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretPart, SecretOwner,
+ },
};
pub struct FleetConfigInternals {
+ pub prefer_identities: BTreeSet<SecretOwner>,
+ pub now: DateTime<Utc>,
+
/// Fleet project directory, containing fleet.nix file.
pub directory: PathBuf,
/// builtins.currentSystem
pub local_system: String,
- pub data: Arc<Mutex<FleetData>>,
+ pub data: Arc<FleetData>,
pub nix_args: Vec<OsString>,
/// fleet_config.config
pub config_field: Value,
@@ -96,16 +101,16 @@
pub struct ConfigHost {
config: Config,
pub name: String,
- groups: OnceCell<Vec<String>>,
+ groups: OnceLock<Vec<String>>,
// TODO: Both of those values are taken from host opts, there should be a cleaner way to specify it
- deploy_kind: OnceCell<DeployKind>,
- session_destination: OnceCell<String>,
- legacy_ssh_store: OnceCell<bool>,
+ deploy_kind: OnceLock<DeployKind>,
+ session_destination: OnceLock<String>,
+ legacy_ssh_store: OnceLock<bool>,
pub host_config: Option<Value>,
- pub nixos_config: OnceCell<Value>,
- pub nixos_unchecked_config: OnceCell<Value>,
+ pub nixos_config: OnceLock<Value>,
+ pub nixos_unchecked_config: OnceLock<Value>,
pub pkgs_override: Option<Value>,
// TODO: Move command helpers away with connectivity refactor
@@ -397,7 +402,38 @@
ensure!(!data.encrypted, "secret came out encrypted");
Ok(data.data)
}
- pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {
+ pub async fn reencrypt_distribution(
+ &self,
+ data: &FleetSecretDistribution,
+ targets: BTreeSet<SecretOwner>,
+ now: DateTime<Utc>,
+ ) -> Result<FleetSecretDistribution> {
+ let mut parts = BTreeMap::new();
+ for (part_name, part) in &data.secret.parts {
+ parts.insert(
+ part_name.clone(),
+ if part.raw.encrypted {
+ FleetSecretPart {
+ raw: self.reencrypt(part.raw.clone(), targets.clone()).await?,
+ }
+ } else {
+ part.clone()
+ },
+ );
+ }
+ let secret = FleetSecretData {
+ created_at: data.secret.created_at,
+ expires_at: data.secret.expires_at,
+ generation_data: data.secret.generation_data.clone(),
+ parts,
+ };
+ Ok(FleetSecretDistribution::new(targets, secret, now))
+ }
+ pub async fn reencrypt(
+ &self,
+ data: SecretData,
+ targets: BTreeSet<SecretOwner>,
+ ) -> Result<SecretData> {
ensure!(data.encrypted, "secret is not encrypted");
let mut cmd = self.cmd("fleet-install-secrets").await?;
cmd.arg("reencrypt").eqarg("--secret", data.to_string());
@@ -537,12 +573,25 @@
}
}
+#[derive(Clone)]
pub struct SharedSecretDefinition(Value);
impl SharedSecretDefinition {
- pub fn expected_owners(&self) -> Result<BTreeSet<String>> {
+ pub fn expected_owners(&self) -> Result<BTreeSet<SecretOwner>> {
let secret = &self.0;
Ok(nix_go_json!(secret.expectedOwners))
}
+ pub fn allow_different(&self) -> Result<bool> {
+ let secret = &self.0;
+ Ok(nix_go_json!(secret.allowDifferent))
+ }
+ pub fn regenerate_on_owner_added(&self) -> Result<bool> {
+ let secret = &self.0;
+ Ok(nix_go_json!(secret.regenerateOnOwnerAdded))
+ }
+ pub fn regenerate_on_owner_removed(&self) -> Result<bool> {
+ let secret = &self.0;
+ Ok(nix_go_json!(secret.regenerateOnOwnerRemoved))
+ }
pub fn generator(&self) -> Result<Value> {
let secret = &self.0;
Ok(nix_go!(secret.generator))
@@ -572,10 +621,10 @@
config: self.clone(),
name: "<virtual localhost>".to_owned(),
host_config: None,
- nixos_config: OnceCell::new(),
- nixos_unchecked_config: OnceCell::new(),
+ nixos_config: OnceLock::new(),
+ nixos_unchecked_config: OnceLock::new(),
groups: {
- let cell = OnceCell::new();
+ let cell = OnceLock::new();
let _ = cell.set(vec![]);
cell
},
@@ -583,9 +632,9 @@
local: true,
session: OnceLock::new(),
- deploy_kind: OnceCell::new(),
- session_destination: OnceCell::new(),
- legacy_ssh_store: OnceCell::new(),
+ deploy_kind: OnceLock::new(),
+ session_destination: OnceLock::new(),
+ legacy_ssh_store: OnceLock::new(),
}
}
@@ -597,17 +646,17 @@
config: self.clone(),
name: name.to_owned(),
host_config: Some(host_config),
- nixos_config: OnceCell::new(),
- nixos_unchecked_config: OnceCell::new(),
- groups: OnceCell::new(),
+ nixos_config: OnceLock::new(),
+ nixos_unchecked_config: OnceLock::new(),
+ groups: OnceLock::new(),
pkgs_override: None,
// TODO: Remove with connectivit refactor
local: self.localhost == name,
session: OnceLock::new(),
- deploy_kind: OnceCell::new(),
- session_destination: OnceCell::new(),
- legacy_ssh_store: OnceCell::new(),
+ deploy_kind: OnceLock::new(),
+ session_destination: OnceLock::new(),
+ legacy_ssh_store: OnceLock::new(),
})
}
pub fn list_hosts(&self) -> Result<Vec<ConfigHost>> {
@@ -623,55 +672,6 @@
pub fn system_config(&self, host: &str) -> Result<Value> {
let fleet_field = &self.config_field;
Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))
- }
-
- /// Shared secrets configured in fleet.nix or in flake
- pub fn list_configured_shared(&self) -> Result<Vec<String>> {
- let config_field = &self.config_field;
- nix_go!(config_field.sharedSecrets).list_fields()
- }
- pub fn has_shared(&self, name: &str) -> bool {
- let data = self.data();
- data.secrets.contains(name)
- }
- pub fn replace_shared(&self, name: String, shared: FleetSecretDistribution) {
- let mut data = self.data_mut();
- data.secrets.set_data(name, shared);
- }
- pub fn remove_shared(&self, secret: &str) {
- let mut data = self.data_mut();
- data.secrets.remove(secret);
- }
-
- pub fn list_secrets_for_owner(&self, host: &str) -> Vec<String> {
- let data = self.data_mut();
- data.secrets.keys_for_owner(host).cloned().collect()
- }
- pub fn list_secrets(&self) -> Vec<String> {
- let data = self.data_mut();
- data.secrets.keys().cloned().collect()
- }
-
- pub fn has_secret(&self, host: &str, secret: &str) -> bool {
- let data = self.data();
- data.secrets.contains_for_owner(secret, host)
- }
- pub fn insert_secret(&self, host: String, secret: String, value: FleetSecretData) {
- let mut data = self.data_mut();
- data.secrets.set_single_data(secret, host, value);
- }
- pub fn remove_secret(&self, host: &str, secret: &str) {
- let mut data = self.data_mut();
- data.secrets.drop_owner_no_reencrypt(secret, host);
- }
-
- pub fn host_secret(&self, host: &str, secret: &str) -> Option<FleetSecretDistribution> {
- let data = self.data();
- data.secrets.get_single(secret, host).cloned()
- }
- pub fn shared_secret(&self, secret: &str) -> Option<FleetSecretDistributions> {
- let data = self.data();
- data.secrets.get(secret).cloned()
}
pub fn secret_definition(&self, secret: &str) -> Result<Option<SharedSecretDefinition>> {
@@ -685,22 +685,9 @@
))))
}
- // TODO: Should this be something modifiable from other processes?
- // E.g terraform provider might want to update FleetData (e.g secrets),
- // and current implementation assumes only one process holds current fleet.nix
- // Given that it is no longer needs to be a file for nix evaluation,
- // maybe it can be a .nix file for persistence, but accessible only
- // thru some shared state controller? Might it be stored in terraform
- // state provider?
- pub fn data(&'_ self) -> MutexGuard<'_, FleetData> {
- self.data.lock().unwrap()
- }
- pub fn data_mut(&'_ self) -> MutexGuard<'_, FleetData> {
- self.data.lock().unwrap()
- }
pub fn save(&self) -> Result<()> {
let mut tempfile = NamedTempFile::new_in(self.directory.clone()).context("failed to create updated version of fleet.nix in the same directory as original.\nDo you have write access to it? Access only to the fleet.nix won't be enough, the directory is used for atomic overwrite operation.\nIt is not recommended to use fleet by root anyway, move fleet project to your home directory.")?;
- let data = nixlike::serialize(&self.data() as &FleetData)?;
+ let data = nixlike::serialize(&*self.data)?;
tempfile.write_all(
format!(
"# This file contains fleet state and shouldn't be edited by hand\n\n{data}\n\n# vim: ts=2 et nowrap\n"
crates/fleet-base/src/keys.rsdiffbeforeafterboth--- a/crates/fleet-base/src/keys.rs
+++ b/crates/fleet-base/src/keys.rs
@@ -1,17 +1,17 @@
use std::str::FromStr as _;
use age::Recipient;
-use anyhow::{Result, anyhow};
+use anyhow::{Result, anyhow, bail};
use futures::{StreamExt as _, TryStreamExt as _};
use itertools::Itertools as _;
use tracing::warn;
-use crate::host::Config;
+use crate::{fleetdata::SecretOwner, host::Config};
impl Config {
- pub fn cached_key(&self, host: &str) -> Option<String> {
- let data = self.data();
- let key = data.hosts.get(host).map(|h| &h.encryption_key);
+ fn cached_host_key(&self, host: &str) -> Option<String> {
+ let hosts = self.data.hosts.read().expect("no poisoning");
+ let key = hosts.get(host).map(|h| &h.encryption_key);
if let Some(key) = key
&& key.is_empty()
{
@@ -20,13 +20,13 @@
key.cloned()
}
pub fn update_key(&self, host: &str, key: String) {
- let mut data = self.data_mut();
- let host = data.hosts.entry(host.to_string()).or_default();
+ let mut hosts = self.data.hosts.write().expect("no poisoning");
+ let host = hosts.entry(host.to_string()).or_default();
host.encryption_key = key.trim().to_string();
}
- pub async fn key(&self, host: &str) -> anyhow::Result<String> {
- if let Some(key) = self.cached_key(host) {
+ pub async fn host_key(&self, host: &str) -> anyhow::Result<String> {
+ if let Some(key) = self.cached_host_key(host) {
Ok(key)
} else {
warn!("Loading key for {}", host);
@@ -38,18 +38,24 @@
Ok(key)
}
}
+ pub async fn key(&self, owner: &SecretOwner) -> anyhow::Result<String> {
+ if let Some(host) = owner.as_host() {
+ self.host_key(host).await
+ } else {
+ bail!("only host keys supported for now")
+ }
+ }
/// Insecure, requires root
- pub async fn recipient(&self, host: &str) -> anyhow::Result<Box<dyn Recipient>> {
+ pub async fn recipient(&self, host: &SecretOwner) -> anyhow::Result<Box<dyn Recipient>> {
let key = self.key(host).await?;
age::ssh::Recipient::from_str(&key)
.map_err(|e| anyhow!("parse recipient error: {e:?}"))
.map(|v| Box::new(v) as Box<dyn Recipient>)
}
- pub async fn recipients(&self, hosts: Vec<String>) -> Result<Vec<Box<dyn Recipient>>> {
- let hosts = self.expand_owner_set(hosts)?;
+ pub async fn recipients(&self, hosts: Vec<SecretOwner>) -> Result<Vec<Box<dyn Recipient>>> {
futures::stream::iter(hosts.iter())
- .then(|m| self.recipient(m.as_ref()))
+ .then(|m| self.recipient(m))
.try_collect::<Vec<_>>()
.await
}
@@ -58,9 +64,8 @@
pub async fn orphaned_data(&self) -> Result<Vec<String>> {
let mut out = Vec::new();
let host_names = self.list_hosts()?.into_iter().map(|h| h.name).collect_vec();
- for hostname in self
- .data()
- .hosts
+ let hosts = self.data.hosts.read().expect("no poisoning");
+ for hostname in hosts
.iter()
.filter(|(_, host)| !host.encryption_key.is_empty())
.map(|(n, _)| n)
crates/fleet-base/src/lib.rsdiffbeforeafterboth--- a/crates/fleet-base/src/lib.rs
+++ b/crates/fleet-base/src/lib.rs
@@ -5,5 +5,4 @@
mod keys;
pub mod opts;
pub mod primops;
-pub mod secret;
pub mod secret_storage;
crates/fleet-base/src/opts.rsdiffbeforeafterboth--- a/crates/fleet-base/src/opts.rs
+++ b/crates/fleet-base/src/opts.rs
@@ -1,5 +1,5 @@
use std::{
- collections::BTreeMap,
+ collections::{BTreeMap, BTreeSet},
env::current_dir,
ffi::OsString,
str::FromStr,
@@ -7,6 +7,7 @@
};
use anyhow::{Context, Result, bail};
+use chrono::Utc;
use nix_eval::{
FetchSettings, FlakeLockFlags, FlakeReference, FlakeReferenceParseFlags, FlakeSettings, Value,
gc_now, nix_go, util::assert_warn,
@@ -212,7 +213,7 @@
}
let bytes =
std::fs::read_to_string(&fleet_data_path).context("reading fleet state (fleet.nix)")?;
- let data = Arc::new(Mutex::new(FleetData::from_str(&bytes)?));
+ let data = Arc::new(FleetData::from_str(&bytes)?);
init_primops();
@@ -265,6 +266,10 @@
gc_now();
}
let config = Config(Arc::new(FleetConfigInternals {
+ // TODO: Load from somewhere
+ prefer_identities: BTreeSet::new(),
+ now: Utc::now(),
+
directory,
data,
flake_outputs: flake,
crates/fleet-base/src/primops.rsdiffbeforeafterboth--- a/crates/fleet-base/src/primops.rs
+++ b/crates/fleet-base/src/primops.rs
@@ -4,19 +4,16 @@
use anyhow::{Context, bail, ensure};
use fleet_shared::SecretData;
use itertools::Itertools;
-use nix_eval::{NativeFn, Value, nix_go, nix_go_json};
+use nix_eval::{NativeFn, Value, await_in_nix, nix_go, nix_go_json};
use serde::Deserialize;
use tracing::{info, warn};
use crate::fleetdata::{
Expectations, FleetSecretData, FleetSecretDistribution, FleetSecretPart, GeneratorPart,
+ RegenerationConstraints, SecretOwner,
};
use crate::host::{Config, ConfigHost};
-use crate::secret::{RegenerationReason, secret_needs_regeneration};
use anyhow::{Result, anyhow};
-
-#[derive(thiserror::Error, Debug)]
-enum Error {}
pub static PRIMOPS_DATA: OnceLock<Config> = OnceLock::new();
@@ -28,7 +25,6 @@
}
pub fn get_pkgs_and_generators(host_on: &ConfigHost, recipients: Vec<String>) -> Result<Value> {
- info!("get pkgs");
let pkgs = host_on.pkgs()?;
let default_mk_secret_generators = nix_go!(pkgs.mkSecretGenerators);
let generators = nix_go!(default_mk_secret_generators(Obj { recipients }));
@@ -57,6 +53,31 @@
Ok(default_generator_drv)
}
+fn secret_to_parts(
+ secret_name: &str,
+ secret: &BTreeMap<String, FleetSecretPart>,
+ expected: &BTreeMap<String, GeneratorPart>,
+) -> Value {
+ let mut out = HashMap::new();
+ for (part_name, part) in secret {
+ if !expected.contains_key(part_name) {
+ warn!(
+ "secret {secret_name} part {part_name} is stored, but not defined in nixos config, it will not be passed to nix"
+ );
+ continue;
+ };
+ out.insert(
+ part_name.as_str(),
+ Value::new_attrs(HashMap::from_iter([(
+ "raw",
+ Value::new_str(&part.raw.to_string()),
+ )])),
+ );
+ }
+
+ Value::new_attrs(out)
+}
+
pub async fn generate(
config: &Config,
expectations: Expectations,
@@ -76,9 +97,12 @@
} else {
config.local_host()
};
- let pkgs_and_generators =
- get_pkgs_and_generators(&host_on, expectations.owners.iter().cloned().collect())
- .context("failed to get pkgs for target host")?;
+ let mut recipients = Vec::new();
+ for owner in &expectations.owners {
+ recipients.push(config.key(owner).await?);
+ }
+ let pkgs_and_generators = get_pkgs_and_generators(&host_on, recipients)
+ .context("failed to get pkgs for target host")?;
let generator = call_package(config, &pkgs_and_generators, generator)
.context("failed to evaluate generator for target host")?;
@@ -147,15 +171,8 @@
generation_data: expectations.generation_data.clone(),
};
- let new_data = FleetSecretDistribution {
- secret: new_data,
- owners: expectations.owners.clone(),
- _deprecated_managed: true,
- };
-
- if let Some(reason) = secret_needs_regeneration(&new_data, &expectations) {
- bail!("newly generated secret needs to be regenerated: {reason}")
- }
+ let new_data =
+ FleetSecretDistribution::new(expectations.owners.clone(), new_data, config.now);
Ok(new_data)
}
@@ -166,18 +183,41 @@
}
pub fn init_primops() {
- info!("initializing primops");
NativeFn::new(
+ c"__fleetEnsureHostSecrets",
+ c"Ensure no extra secrets are stored for the host, pruning unknown",
+ [c"host", c"expectedNonshared", c"expectedShared", c"rest"],
+ |_es, [host, expected_nonshared, expected_shared, rest]| {
+ let host = SecretOwner::host(host.to_string()?);
+ let expected_nonshared: BTreeSet<String> = expected_nonshared.as_json()?;
+ let expected_shared: BTreeSet<String> = expected_shared.as_json()?;
+
+ let mut expected = expected_nonshared;
+ expected.extend(expected_shared);
+
+ let config = PRIMOPS_DATA
+ .get()
+ .expect("primops data should be set on init");
+
+ config
+ .data
+ .secrets
+ .write()
+ .expect("no poisoning")
+ .prune_host(&host, expected);
+
+ Ok(rest.clone())
+ },
+ )
+ .register();
+ NativeFn::new(
c"__fleetEnsureHostSecret",
c"Ensure secret existence for a host, regenerating it in case of some mismatch",
[c"host", c"secret", c"generator"],
|es, [host, secret, generator]| {
- info!("get host");
- let host = host.to_string()?;
- info!("get secret");
+ let host = SecretOwner::host(&host.to_string()?);
let secret = secret.to_string()?;
- info!("get config");
let config = PRIMOPS_DATA
.get()
.expect("primops data should be set on init");
@@ -193,50 +233,101 @@
ensure!(expected_owners.contains(&host), "secret {secret} does not define {host} as expected owner");
- (true, shared_def.generator()?, expected_owners)
+ (Some(shared_def.clone()), shared_def.generator()?, expected_owners)
} else {
if shared_def.is_some() {
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")
}
- (false, generator.clone(), BTreeSet::from_iter([host.clone()]))
+ (None, generator.clone(), BTreeSet::from_iter([host.clone()]))
};
- let default_generator_drv = get_default_generator_drv(config, &generator).context("failed to evaluate default generator")?;
- let expectations = Expectations {
+ let default_generator_drv = get_default_generator_drv(config, &generator)?;
+ let mut expectations = Expectations {
parts: nix_go_json!(default_generator_drv.parts),
generation_data: nix_go_json!(default_generator_drv.generationData),
- owners: expected_owners,
+ owners: expected_owners.clone(),
+ };
+ let constraints = if let Some(shared) = &shared{
+ RegenerationConstraints {
+ allow_different: nix_go_json!(default_generator_drv.allowDifferent) && shared.allow_different()?,
+ regenerate_on_owner_added: shared.regenerate_on_owner_added()?,
+ regenerate_on_owner_removed: shared.regenerate_on_owner_added()?,
+ }
+ } else {
+ RegenerationConstraints::host_personal()
};
- let reason: RegenerationReason = 'regenerate: {
- let Some(existing) = config
- .host_secret(&host, &secret) else {
- break 'regenerate RegenerationReason::Missing;
+ let mut secrets = config.data.secrets.write().expect("no poisoning");
+ let dists = secrets.get_or_create(&secret);
+
+ if shared.is_some() {
+ 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);
+ } else {
+ dists.prune_host(host.clone(), &expectations.parts, &expectations.generation_data, config.now);
+ };
+
+ if let Some(dist) = dists.get(&host) {
+ return Ok(secret_to_parts(&secret, &dist.secret.parts, &expectations.parts));
};
- if let Some(reason) = secret_needs_regeneration(&existing, &expectations) {
- break 'regenerate reason;
+
+ let mut reencrypt_targets = expectations.owners.clone();
+ for dist in dists.distributions() {
+ for own in dist.owners() {
+ reencrypt_targets.remove(own);
+ }
}
+ if !constraints.regenerate_on_owner_added {
+ if let Some(unpruned) = dists.try_unprune(host.clone()) {
+ return Ok(secret_to_parts(&secret, &unpruned.secret.parts, &expectations.parts));
+ } else if let Some(best) = dists.best_distribution_for_reencryption(&config.prefer_identities) {
+ let new_owners = reencrypt_targets.clone();
+ let mut reencrypt_targets = reencrypt_targets;
+ reencrypt_targets.extend(best.owners().cloned());
- let mut parts = expectations.parts.clone();
+ let mut preferred = best.owners().collect_vec();
+ preferred.sort_by_key(|v| !config.prefer_identities.contains(*v));
+
+ warn!("reencrypting secret {secret} as it is missing for host {host}");
- let mut out = HashMap::new();
- for (part_name, part) in &existing.secret.parts {
- let Some(definition) = parts.remove(part_name) else {
- warn!("secret {secret} part {part_name} is stored, but not defined in nixos config, it will not be passed to nix");
- continue;
+ for owner in preferred {
+ if let Some(hostname) = owner.as_host() && let Ok(host) = config.host(hostname) {
+ let best = best.clone();
+ let reencrypt_targets = reencrypt_targets.clone();
+ let reencrypted = match await_in_nix(async move {
+ host.reencrypt_distribution(&best, reencrypt_targets.clone(), config.now).await
+ }) {
+ Ok(r) => r,
+ Err(e) => {
+ warn!("reencryption failed on {hostname}: {e:?}");
+ continue;
+ }
+ };
+ dists.extend(reencrypted.clone(), format!("secret was reencrypted to extend with new owners: {new_owners:?}"));
+ return Ok(secret_to_parts(&secret, &reencrypted.secret.parts, &expectations.parts));
+ };
+ }
+ warn!("failed to reencrypt using any host")
};
- assert!(definition.encrypted != part.raw.encrypted, "encryption status is checked by secret_needs_regeneration");
- out.insert(part_name.as_str(), Value::new_attrs(HashMap::from_iter([("raw", Value::new_str(&part.raw.to_string()))])));
- }
- assert!(parts.is_empty(), "secret part is missing, secret_needs_regeneration should check that");
+ };
- return Ok(Value::new_attrs(out))
- };
+ if constraints.allow_different {
+ for dist in dists.distributions() {
+ for own in dist.owners() {
+ expectations.owners.remove(own);
+ }
+ }
+ }
+ info!("secret {secret} is being generated for {:?}", expectations.owners);
- todo!()
+ let expectations_ = expectations.clone();
+ let generated = await_in_nix(async move {
+ generate(config, expectations_, &generator, &default_generator_drv).await
+ })?;
+ dists.extend(generated.clone(), format!("secret was generated"));
+ return Ok(secret_to_parts(&secret, &generated.secret.parts, &expectations.parts));
},
)
.register();
crates/fleet-base/src/secret.rsdiffbeforeafterboth--- a/crates/fleet-base/src/secret.rs
+++ /dev/null
@@ -1,92 +0,0 @@
-use std::collections::{BTreeMap, BTreeSet};
-
-use chrono::{DateTime, Utc};
-
-use crate::fleetdata::{Expectations, FleetSecretData, FleetSecretDistribution, GeneratorPart};
-
-#[derive(thiserror::Error, Debug)]
-pub enum RegenerationReason {
- #[error("owners added: {0:?}")]
- OwnersAdded(BTreeSet<String>),
- #[error("owners added: {0:?}")]
- OwnersRemoved(BTreeSet<String>),
- #[error("unexpected generation data, expected: {expected:?}, found: {found:?}")]
- GenerationData {
- expected: serde_json::Value,
- found: serde_json::Value,
- },
- #[error("unexpected part list, expected: {expected:?}, found: {found:?}")]
- PartList {
- expected: BTreeSet<String>,
- found: BTreeSet<String>,
- },
- #[error("part {0} is expected to be encrypted")]
- ExpectedPrivate(String),
- #[error("part {0} is not expected to be encrypted")]
- ExpectedPublic(String),
- #[error("secret is expired at {0}")]
- Expired(DateTime<Utc>),
-
- #[error("secret is not generated for this host")]
- Missing,
-}
-
-pub fn secret_needs_regeneration(
- secret: &FleetSecretDistribution,
- expectations: &Expectations,
-) -> Option<RegenerationReason> {
- let added: BTreeSet<String> = expectations
- .owners
- .difference(&secret.owners)
- .cloned()
- .collect();
- if !added.is_empty() {
- return Some(RegenerationReason::OwnersAdded(added));
- }
-
- let removed: BTreeSet<String> = secret
- .owners
- .difference(&expectations.owners)
- .cloned()
- .collect();
- if !removed.is_empty() {
- return Some(RegenerationReason::OwnersRemoved(removed));
- }
-
- if secret.secret.generation_data != expectations.generation_data {
- return Some(RegenerationReason::GenerationData {
- expected: expectations.generation_data.clone(),
- found: secret.secret.generation_data.clone(),
- });
- }
-
- let expected: BTreeSet<String> = expectations.parts.keys().cloned().collect();
- let found: BTreeSet<String> = secret.secret.parts.keys().cloned().collect();
-
- if found != expected {
- return Some(RegenerationReason::PartList { expected, found });
- }
-
- for (name, value) in secret.secret.parts.iter() {
- let expectation = expectations
- .parts
- .get(name)
- .expect("found == expected checked");
- if value.raw.encrypted {
- if !expectation.encrypted {
- return Some(RegenerationReason::ExpectedPrivate(name.clone()));
- }
- } else if expectation.encrypted {
- return Some(RegenerationReason::ExpectedPublic(name.clone()));
- }
- }
-
- if let Some(expiration) = secret.secret.expires_at {
- // TODO: Leeway?
- if expiration < Utc::now() {
- return Some(RegenerationReason::Expired(expiration));
- }
- }
-
- None
-}
crates/fleet-shared/Cargo.tomldiffbeforeafterboth--- a/crates/fleet-shared/Cargo.toml
+++ b/crates/fleet-shared/Cargo.toml
@@ -8,4 +8,3 @@
base64 = "0.22.1"
serde = "1.0.219"
unicode_categories = "0.1.1"
-z85 = "3.0.6"
crates/fleet-shared/src/encoding.rsdiffbeforeafterboth--- a/crates/fleet-shared/src/encoding.rs
+++ b/crates/fleet-shared/src/encoding.rs
@@ -1,5 +1,4 @@
use std::{
- collections::BTreeMap,
fmt::{self, Display},
str::FromStr,
};
@@ -15,7 +14,6 @@
}
const BASE64_ENCODED_PREFIX: &str = "<BASE64-ENCODED>\n";
-const Z85_ENCODED_PREFIX: &str = "<Z85-ENCODED>\n";
// Multiline text in Nix can only end with \n, which is not cool for actual single-line strings.
const PLAINTEXT_NEWLINE_PREFIX: &str = "<PLAINTEXT-NL>\n";
const PLAINTEXT_PREFIX: &str = "<PLAINTEXT>";
@@ -54,18 +52,12 @@
STANDARD_NO_PAD
.decode(unprefixed.replace(['\n', '\t', ' '], ""))
.map_err(|e| format!("base64-encoded failed: {e}"))?
- } else if let Some(unprefixed) = string.strip_prefix(Z85_ENCODED_PREFIX) {
- z85::decode(unprefixed.replace(['\n', '\t', ' '], ""))
- .map_err(|e| format!("z85-encoded failed: {e}"))?
} else if let Some(unprefixed) = string.strip_prefix(PLAINTEXT_NEWLINE_PREFIX) {
unprefixed.as_bytes().to_owned()
} else if let Some(unprefixed) = string.strip_prefix(PLAINTEXT_PREFIX) {
unprefixed.as_bytes().to_owned()
} else {
- let secret_prefix = format!("{SECRET_PREFIX}{Z85_ENCODED_PREFIX}");
- return Err(format!(
- "unknown secret encoding. If you're migrating from old version of fleet, prefix public secret fields with {PLAINTEXT_PREFIX:?}, and encrypted data with {secret_prefix:?}: {string}"
- ));
+ return Err(format!("unknown secret encoding"));
};
Ok(Self { data, encrypted })
}
crates/nix-eval/Cargo.tomldiffbeforeafterboth--- a/crates/nix-eval/Cargo.toml
+++ b/crates/nix-eval/Cargo.toml
@@ -18,6 +18,7 @@
test-log = { version = "0.2.18", features = ["trace"] }
tracing-indicatif = { version = "0.3.13", optional = true }
vte = { version = "0.15.0", features = ["ansi"] }
+tokio.workspace = true
[build-dependencies]
bindgen = "0.72.0"
crates/nix-eval/src/lib.rsdiffbeforeafterboth--- a/crates/nix-eval/src/lib.rs
+++ b/crates/nix-eval/src/lib.rs
@@ -2,7 +2,7 @@
use std::cell::RefCell;
use std::ffi::{CStr, CString, c_char, c_int, c_uint, c_void};
use std::ptr::{null, null_mut};
-use std::sync::LazyLock;
+use std::sync::{Arc, LazyLock, OnceLock};
use std::{array, fmt, slice};
use std::{collections::HashMap, path::PathBuf};
@@ -10,9 +10,10 @@
use itertools::Itertools;
use serde::Serialize;
use serde::de::DeserializeOwned;
+use std::mem::transmute;
pub use anyhow::Result;
-use tracing::{info, instrument, warn};
+use tracing::{Instrument, info, instrument, warn};
use self::logging::{ErrorInfoBuilder, nix_logging_cxx};
use self::nix_cxx::set_fetcher_setting;
@@ -172,9 +173,16 @@
#[repr(transparent)]
pub struct NixContext(*mut c_context);
impl NixContext {
- pub fn set_err(&mut self, err: NixErrorKind, msg: &CStr) {
+ pub fn set_err_raw(&mut self, err: NixErrorKind, msg: &CStr) {
unsafe { set_err_msg(self.0, err as c_int, msg.as_ptr()) };
}
+ pub fn set_err(&mut self, err: anyhow::Error) {
+ let mut fmt = format!("{err:?}").replace("\0", "\\0");
+ self.set_err_raw(
+ NixErrorKind::Generic,
+ &CString::new(fmt).expect("NUL bytes were just replaced"),
+ );
+ }
pub fn new() -> Self {
let ctx = unsafe { c_context_create() };
Self(ctx)
@@ -931,6 +939,8 @@
}
}
+static TOKIO_FOR_NIX: OnceLock<Arc<tokio::runtime::Runtime>> = OnceLock::new();
+
pub fn init_libraries() {
unsafe { GC_allow_register_threads() };
@@ -945,37 +955,33 @@
nix_logging_cxx::apply_tracing_logger();
}
+pub fn init_tokio_for_nix(tokio: Arc<tokio::runtime::Runtime>) {
+ TOKIO_FOR_NIX
+ .set(tokio)
+ .expect("tokio for nix should only be initialized once");
+}
+
+pub fn await_in_nix<F: Send + 'static>(f: impl Future<Output = F> + Send + 'static) -> F {
+ // It should be possible to do Handle::current(), but some of the planned features don't work well with that
+ let runtime = TOKIO_FOR_NIX
+ .get()
+ .expect("init_tokio_for_nix was not called");
+ std::thread::spawn(move || runtime.block_on(f)).join().expect("await_in_nix inner thread panicked")
+}
+
unsafe extern "C" fn nix_primop_closure_adapter<const N: usize>(
user_data: *mut c_void,
- context: *mut c_context,
+ mut context: *mut c_context,
state: *mut nix_raw::EvalState,
args: *mut *mut value,
ret: *mut value,
) {
let user_closure: &UserClosure<N> = unsafe { &*user_data.cast_const().cast() };
- let mut e = None;
let args: [&Value; N] = array::from_fn(|i| {
let v: &mut Value = unsafe { &mut *args.add(i).cast() };
-
- info!("forcing arg");
- if matches!(v.type_of(), NixType::Thunk)
- && let Err(err) = v.force(state)
- {
- e = Some(err);
- };
v as &Value
});
- info!("args forced");
- let ctx: &mut NixContext = unsafe { &mut *context.cast() };
-
- if let Some(e) = e {
- warn!("set err = {e}");
- unsafe { init_int(context, ret, 0) };
- return ctx.set_err(
- NixErrorKind::Unknown,
- &CString::new(e.to_string()).expect("forcing argument value failed"),
- );
- }
+ let ctx: &mut NixContext = unsafe { transmute(&mut context) };
let state: &EvalState = unsafe { std::mem::transmute(&state) };
@@ -984,12 +990,7 @@
unsafe { copy_value(context, ret, v.0) };
}
Err(e) => {
- unsafe { init_int(context, ret, 0) };
- warn!("set err = {e:#?}");
- ctx.set_err(
- NixErrorKind::Unknown,
- &CString::new(e.to_string()).expect("error should not contain internal nuls"),
- );
+ ctx.set_err(e);
}
}
}
crates/nix-eval/src/macros.rsdiffbeforeafterboth--- a/crates/nix-eval/src/macros.rs
+++ b/crates/nix-eval/src/macros.rs
@@ -68,13 +68,13 @@
#[macro_export]
macro_rules! nix_go {
(@o($o:expr, $path:expr) . $var:ident $($tt:tt)*) => {{
- nix_go!(@o($o.get_field(stringify!($var)).context(concat!("getting nested ", $path))?, $path) $($tt)*)
+ nix_go!(@o(tokio::task::block_in_place(|| $o.get_field(stringify!($var))).context(concat!("getting nested ", $path))?, $path) $($tt)*)
}};
(@o($o:expr, $path:expr) [ $v:expr ] $($tt:tt)*) => {{
- nix_go!(@o($o.get_field($v).context(concat!("getting nested ", $path))?, $path) $($tt)*)
+ nix_go!(@o(tokio::task::block_in_place(|| $o.get_field($v)).context(concat!("getting nested ", $path))?, $path) $($tt)*)
}};
(@o($o:expr, $path:expr) ($($var:tt)*) $($tt:tt)*) => {
- nix_go!(@o($o.call($crate::nix_expr_inner!($($var)+)).context(concat!("getting nested ", $path))?, $path) $($tt)*)
+ nix_go!(@o(tokio::task::block_in_place(|| $o.call($crate::nix_expr_inner!($($var)+))).context(concat!("getting nested ", $path))?, $path) $($tt)*)
};
(@o($o:expr, $path:expr)) => {$o};
($field:ident $($tt:tt)+) => {{
@@ -87,6 +87,6 @@
#[macro_export]
macro_rules! nix_go_json {
($($tt:tt)*) => {{
- $crate::nix_go!($($tt)*).as_json()?
+ tokio::task::block_in_place(|| $crate::nix_go!($($tt)*).as_json())?
}};
}
flake.lockdiffbeforeafterboth--- a/flake.lock
+++ b/flake.lock
@@ -2,10 +2,10 @@
"nodes": {
"crane": {
"locked": {
- "lastModified": 1767461147,
+ "lastModified": 1768700043,
"owner": "ipetkov",
"repo": "crane",
- "rev": "7d59256814085fd9666a2ae3e774dc5ee216b630",
+ "rev": "935de8bd6838d940988bb065be2a2034259327b9",
"type": "github"
},
"original": {
@@ -37,10 +37,10 @@
]
},
"locked": {
- "lastModified": 1767609335,
+ "lastModified": 1768135262,
"owner": "hercules-ci",
"repo": "flake-parts",
- "rev": "250481aafeb741edfe23d29195671c19b36b6dca",
+ "rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
"type": "github"
},
"original": {
@@ -111,10 +111,10 @@
"nixpkgs-regression": "nixpkgs-regression"
},
"locked": {
- "lastModified": 1767670640,
+ "lastModified": 1768702010,
"owner": "deltarocks",
"repo": "nix",
- "rev": "2181cd07134c9049bd77b7f48c3b1ea8647267de",
+ "rev": "b05b52670b9c7affff5b9be3edb539a1603c39e6",
"type": "github"
},
"original": {
@@ -126,10 +126,10 @@
},
"nixpkgs": {
"locked": {
- "lastModified": 1767657734,
+ "lastModified": 1768697925,
"owner": "nixos",
"repo": "nixpkgs",
- "rev": "d4ccebf51ee4dbeb9df364dce1fe9848635c1258",
+ "rev": "665062f7df2c7db8fdbbec4f1b730091143828a3",
"type": "github"
},
"original": {
@@ -190,10 +190,10 @@
]
},
"locked": {
- "lastModified": 1767667566,
+ "lastModified": 1768617670,
"owner": "oxalica",
"repo": "rust-overlay",
- "rev": "056ce5b125ab32ffe78c7d3e394d9da44733c95e",
+ "rev": "56d0fbdd732f3686e8414b857cf885038fc17d57",
"type": "github"
},
"original": {
@@ -223,10 +223,10 @@
]
},
"locked": {
- "lastModified": 1767468822,
+ "lastModified": 1768158989,
"owner": "numtide",
"repo": "treefmt-nix",
- "rev": "d56486eb9493ad9c4777c65932618e9c2d0468fc",
+ "rev": "e96d59dff5c0d7fddb9d113ba108f03c3ef99eca",
"type": "github"
},
"original": {
modules/nixos.nixdiffbeforeafterboth--- a/modules/nixos.nix
+++ b/modules/nixos.nix
@@ -10,7 +10,7 @@
let
inherit (lib.attrsets) mapAttrs;
inherit (lib.options) mkOption;
- inherit (lib.types) deferredModule unspecified;
+ inherit (lib.types) deferredModule unspecified uniq str;
inherit (lib.strings) escapeNixIdentifier;
inherit (fleetLib.options) mkHostsOption;
@@ -24,9 +24,18 @@
'';
type = deferredModule;
};
- hosts = mkHostsOption (hostArgs: {
+ hosts = mkHostsOption (hostArgs: let
+ hostName = hostArgs.config._module.args.name;
+ in {
inherit _file;
options = {
+ name = mkOption {
+ description = ''
+ Host name (alias)
+ '';
+ type = uniq str;
+ default = hostName;
+ };
nixos = mkOption {
description = ''
Nixos configuration for the current host.
@@ -42,7 +51,7 @@
prefix = [
"fleetConfiguration"
"hosts"
- hostArgs.config._module.args.name
+ hostName
"nixos"
];
modules = (import "${modulesPath}/module-list.nix") ++ [
modules/nixos/secrets.nixdiffbeforeafterboth--- a/modules/nixos/secrets.nix
+++ b/modules/nixos/secrets.nix
@@ -15,7 +15,12 @@
inherit (lib.stringsWithDeps) stringAfter;
inherit (lib.options) mkOption literalExpression;
inherit (lib.lists) optional elem;
- inherit (lib.attrsets) mapAttrs mapAttrsToList;
+ inherit (lib.attrsets)
+ mapAttrs
+ mapAttrsToList
+ filterAttrs
+ attrNames
+ ;
inherit (lib.modules) mkIf;
inherit (lib.types)
submodule
@@ -25,9 +30,9 @@
uniq
functionTo
package
- bool
enum
either
+ listOf
;
inherit (fleetLib.strings) decodeRawSecret;
@@ -132,10 +137,33 @@
in
{
options = {
+ _providedSharedSecrets = mkOption {
+ description = ''
+ List of shared secrets, for which the current host was specified as `expectedOwners`
+ '';
+ type = listOf str;
+ default = [];
+ internal = true;
+ };
secrets = mkOption {
type = attrsOf secretType;
default = { };
- apply = mapAttrs (_: secret: secret.parts // { definition = secret; });
+ apply =
+ secrets:
+ mapAttrs (_: secret: secret.parts // { definition = secret; })
+
+ (
+ let
+ hostName = host.name;
+ expectedNonshared = attrNames (filterAttrs (_: def: def.generator != "shared") secrets);
+ expectedShared = config._providedSharedSecrets;
+ in
+ builtins.deepSeq [
+ hostName
+ expectedNonshared
+ expectedShared
+ ] (builtins.fleetEnsureHostSecrets hostName expectedNonshared expectedShared secrets)
+ );
description = "Host-local secrets";
};
system.secretsData = mkOption {
@@ -163,7 +191,7 @@
(secret.definition.generator == "shared") == hasSharedDefinition
&& (
hasSharedDefinition
- -> (elem host._module.args.name fleetConfiguration.secrets.${name}.expectedOwners)
+ -> (elem host.name fleetConfiguration.secrets.${name}.expectedOwners)
);
message =
if hasSharedDefinition then
modules/secrets.nixdiffbeforeafterboth--- a/modules/secrets.nix
+++ b/modules/secrets.nix
@@ -1,9 +1,10 @@
{
lib,
+ config,
...
}:
let
- inherit (lib.options) mkOption literalExpression;
+ inherit (lib.options) mkOption;
inherit (lib.types)
nullOr
listOf
@@ -16,6 +17,8 @@
uniq
;
inherit (lib.strings) concatStringsSep;
+ inherit (lib.lists) elem filter;
+ inherit (lib.attrsets) attrNames;
sharedSecret =
{ config, ... }:
@@ -30,28 +33,33 @@
regenerateOnOwnerAdded = mkOption {
type = bool;
description = ''
- Controls whether the secret must be regenerated when new owners are added.
+ Whether the secret prefers to be rotated when new owners are added.
- Set to true when the secret contains owner-specific references (e.g., X.509 Subject Alternative Names).
- When true, adding a new owner will trigger secret regeneration instead of simple re-encryption.
+ Note that this is only a security measure, if the secret needs to be regenerated due to e.g X.509 SANs
+ changes - then you most likely want to use generationData for that instead.
'';
+ default = false;
};
regenerateOnOwnerRemoved = mkOption {
- default = config.regenerateOnOwnerAdded;
- defaultText = literalExpression "regenerateOnOwnerAdded";
type = bool;
description = ''
- Determines secret behavior when owners are removed from the configuration.
-
- Typically mirrors regenerateOnOwnerAdded. Override cautiously.
- Set to false if host permissions are revoked through alternative mechanisms like firewall rules.
+ Whether the secret prefers to be rotated when the owners are removed, so the encrypted data
+ stored in fleet state can't be decrypted by those. Note that the secrets are still present in encrypted
+ form on those hosts until gc happens.
'';
+ default = false;
};
allowDifferent = mkOption {
type = bool;
description = ''
- When adding owner, do not update secret value for other owners, instead creating a new distribution
+ When adding owner, do not update secret value for other owners, instead creating a new distribution.
+
+ Defaults to true, since all secrets might differ on hosts on some point of deployment process.
+
+ Secret generator might also have opinion on this, like it makes little sense for askPass/synchronizing
+ generators to keep old data.
'';
+ default = true;
};
generator = mkOption {
type = uniq (nullOr (functionTo package));
@@ -75,6 +83,9 @@
};
};
config = {
+ nixos = {host, ...}: {
+ _providedSharedSecrets = filter (name: elem host.name config.secrets.${name}.expectedOwners) (attrNames config.secrets);
+ };
nixpkgs.overlays = [
(final: prev: {
mkSecretGenerators =
@@ -90,6 +101,7 @@
# (Some secrets-encryption-in-git/managed PKI solution is expected)
impureOn ? null,
generationData ? null,
+ allowDifferent ? true,
parts,
}:
(prev.writeShellScript "impureGenerator.sh" ''
@@ -118,7 +130,12 @@
'').overrideAttrs
(old: {
passthru = {
- inherit impureOn parts generationData;
+ inherit
+ impureOn
+ parts
+ generationData
+ allowDifferent
+ ;
generatorKind = "impure";
};
});