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.rsdiffbeforeafterboth--- a/crates/fleet-base/src/fleetdata.rs
+++ b/crates/fleet-base/src/fleetdata.rs
@@ -1,10 +1,12 @@
use std::{
+ cmp::Ordering,
collections::{
BTreeMap, BTreeSet,
btree_map::{self, Entry},
},
+ fmt,
io::{self, Cursor},
- ops::Deref,
+ sync::RwLock,
};
use age::Recipient;
@@ -77,19 +79,18 @@
pub manager_keys: Vec<ManagerKey>,
#[serde(default)]
- pub hosts: BTreeMap<String, HostData>,
+ pub hosts: RwLock<BTreeMap<String, HostData>>,
#[serde(default, alias = "shared_secrets")]
- pub secrets: FleetSecrets,
+ pub secrets: RwLock<FleetSecrets>,
// extra_name => anything
#[serde(default)]
- #[serde(skip_serializing_if = "BTreeMap::is_empty")]
- pub extra: BTreeMap<String, Value>,
+ pub extra: RwLock<BTreeMap<String, Value>>,
#[serde(default)]
- #[serde(skip_serializing_if = "BTreeMap::is_empty")]
- host_secrets: BTreeMap<String, BTreeMap<String, FleetSecretDistribution>>,
+ #[serde(skip_serializing)]
+ host_secrets: BTreeMap<SecretOwner, BTreeMap<String, FleetSecretDistribution>>,
}
impl FleetData {
pub fn from_str(s: &str) -> anyhow::Result<Self> {
@@ -97,6 +98,8 @@
if !data.host_secrets.is_empty() {
info!("migrating host secrets into shared secrets structure");
data.secrets
+ .write()
+ .expect("no poisoning")
.merge_from_hosts(std::mem::take(&mut data.host_secrets));
}
Ok(data)
@@ -130,128 +133,431 @@
#[serde(rename_all = "camelCase")]
#[must_use]
pub struct FleetSecretData {
- #[serde(default = "Utc::now")]
pub created_at: DateTime<Utc>,
- #[serde(default)]
- #[serde(skip_serializing_if = "Option::is_none", alias = "expire_at")]
+ #[serde(default, skip_serializing_if = "Option::is_none", alias = "expire_at")]
pub expires_at: Option<DateTime<Utc>>,
#[serde(flatten)]
pub parts: BTreeMap<String, FleetSecretPart>,
- #[serde(default)]
- #[serde(skip_serializing_if = "Value::is_null")]
+ #[serde(default, skip_serializing_if = "Value::is_null")]
pub generation_data: Value,
}
+fn is_false(b: &bool) -> bool {
+ *b == false
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, Ord, PartialEq, Eq)]
+#[repr(transparent)]
+pub struct SecretOwner(String);
+
+impl fmt::Display for SecretOwner {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "host:{}", self.0)
+ }
+}
+
+impl SecretOwner {
+ pub fn host(s: impl AsRef<str>) -> SecretOwner {
+ SecretOwner(s.as_ref().to_owned())
+ }
+ pub fn as_host(&self) -> Option<&str> {
+ Some(&self.0)
+ }
+}
+
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
#[must_use]
pub struct FleetSecretDistribution {
#[serde(default)]
- pub owners: BTreeSet<String>,
+ owners: BTreeSet<SecretOwner>,
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ owners_pending_prune: BTreeMap<SecretOwner, String>,
+
#[serde(flatten)]
pub secret: FleetSecretData,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pending_prune: Option<String>,
#[serde(default, skip_serializing, alias = "managed")]
- pub _deprecated_managed: bool,
+ _deprecated_managed: bool,
+}
+
+const EMPTY_PENDING_PRUNE: &BTreeMap<SecretOwner, String> = &BTreeMap::new();
+impl FleetSecretDistribution {
+ pub fn new(owners: BTreeSet<SecretOwner>, secret: FleetSecretData, now: DateTime<Utc>) -> Self {
+ assert!(
+ !owners.is_empty(),
+ "distribution should have at least one owner"
+ );
+ if let Some(expires_at) = &secret.expires_at {
+ assert!(
+ *expires_at > now,
+ "secret should not be expired on creation"
+ );
+ }
+ Self {
+ owners,
+ secret,
+ owners_pending_prune: BTreeMap::new(),
+ pending_prune: None,
+ _deprecated_managed: true,
+ }
+ }
+
+ fn owners_ex(&self, including_pruned: bool) -> impl Iterator<Item = &SecretOwner> {
+ let pending_prune = if including_pruned {
+ &self.owners_pending_prune
+ } else {
+ EMPTY_PENDING_PRUNE
+ };
+ self.owners.iter().chain(pending_prune.keys())
+ }
+ pub fn owners(&self) -> impl Iterator<Item = &SecretOwner> {
+ self.owners_ex(false)
+ }
+
+ pub fn prune(&mut self, reason: String) {
+ assert!(
+ self.pending_prune.is_none(),
+ "it shouldn't be possible to prune the same distribution twice using public api"
+ );
+ self.pending_prune = Some(reason);
+ }
+ pub fn prune_owners(&mut self, owners: &BTreeSet<SecretOwner>, reason: String) {
+ // if self.owners.iter().all(|o| owners.contains(o)) && self.owners_pending_prune.is_empty() {
+ // self.prune(format!("all owners were pruned: {reason}"));
+ // return;
+ // }
+ for owner in owners {
+ if self.owners.remove(owner) {
+ self.owners_pending_prune
+ .insert(owner.to_owned(), reason.clone());
+ }
+ }
+ // if self.owners.is_empty() {
+ // self.prune("no owners left".to_owned());
+ // }
+ }
+ pub fn unprune_owner(&mut self, owner: SecretOwner) {
+ if self.owners_pending_prune.remove(&owner).is_some() {
+ self.owners.insert(owner);
+ }
+ }
}
-#[derive(Clone)]
+#[derive(Clone, Debug, Default)]
#[must_use]
-pub struct FleetSecretDistributions(Vec<FleetSecretDistribution>);
+pub struct FleetSecretDistributions {
+ stored: Vec<FleetSecretDistribution>,
+}
-impl Deref for FleetSecretDistributions {
- type Target = [FleetSecretDistribution];
-
- fn deref(&self) -> &Self::Target {
- self.0.as_slice()
+fn compare_dists(
+ a: &FleetSecretDistribution,
+ b: &FleetSecretDistribution,
+ prefer_identities: &BTreeSet<SecretOwner>,
+ include_pruned_owners: bool,
+) -> Ordering {
+ use Ordering::*;
+ if prefer_identities.is_empty() {
+ let a_has = a
+ .owners_ex(include_pruned_owners)
+ .any(|o| prefer_identities.contains(o));
+ let b_has = b
+ .owners_ex(include_pruned_owners)
+ .any(|o| prefer_identities.contains(o));
+ match (a_has, b_has) {
+ (true, false) => return Greater,
+ (false, true) => return Less,
+ _ => {}
+ }
+ }
+ match (a.secret.expires_at, b.secret.expires_at) {
+ (None, Some(_)) => return Greater,
+ (Some(_), None) => return Less,
+ (Some(a), Some(b)) => {
+ // Later is better
+ return a.cmp(&b);
+ }
+ (None, None) => {}
}
+
+ // Which one is easier to access
+ return a.owners.len().cmp(&b.owners.len());
}
impl FleetSecretDistributions {
- pub fn owners(&self) -> impl Iterator<Item = &String> {
- self.0.iter().flat_map(|v| v.owners.iter())
+ /// Drop expired distributions
+ fn prune_expired(&mut self, now: DateTime<Utc>) {
+ for ele in self.distributions_mut() {
+ if let Some(expires_at) = ele.secret.expires_at {
+ if expires_at < now {
+ ele.prune(format!("expired during check at {now}"));
+ }
+ }
+ }
+ }
+ /// Perform all pruning relevant to shared secrets
+ /// Also see expected_owner_removed
+ pub fn prune_shared(
+ &mut self,
+ expected_owners: &BTreeSet<SecretOwner>,
+ unique: bool,
+ expected_parts: &BTreeMap<String, GeneratorPart>,
+ expected_generation_data: &Value,
+ regenerate_on_owner_removed: bool,
+ regenerate_on_owner_added: bool,
+ prefer_identities: &BTreeSet<SecretOwner>,
+ now: DateTime<Utc>,
+ ) {
+ self.prune_expired(now);
+ self.prune_generation_data(expected_generation_data, None);
+ self.prune_missing_parts(expected_parts, None);
+
+ let current_owners = self.owners().cloned().collect::<BTreeSet<SecretOwner>>();
+
+ let mut to_add = expected_owners.difference(¤t_owners);
+ if to_add.next().is_some() && unique && regenerate_on_owner_added {
+ for dist in self.distributions_mut() {
+ dist.prune(format!(
+ "owners missing, can't add new distribution, regeneration preferred"
+ ));
+ }
+ return;
+ }
+
+ for to_remove in current_owners.difference(&expected_owners) {
+ self.entry(to_remove.clone()).remove(
+ regenerate_on_owner_removed,
+ "owner was removed from expected owners list, regenerate_on_owner_removed is set"
+ .to_string(),
+ );
+ }
+ if unique {
+ self.prune_nonunique(prefer_identities);
+ }
+ }
+ pub fn prune_host(
+ &mut self,
+ owner: SecretOwner,
+ expected_parts: &BTreeMap<String, GeneratorPart>,
+ expected_generation_data: &Value,
+ now: DateTime<Utc>,
+ ) {
+ self.prune_expired(now);
+ self.prune_generation_data(expected_generation_data, Some(&owner));
+ // TODO: Owner-based pruning is warranted (e.g host no longer has secret defined)
+ self.prune_missing_parts(expected_parts, Some(&owner));
+ }
+ /// Position of best distributions as in iterator returned by distributions()
+ /// None if distributions not found
+ fn best_idx(
+ &self,
+ prefer_identities: &BTreeSet<SecretOwner>,
+ include_pruned_owners: bool,
+ ) -> Option<usize> {
+ self.distributions()
+ .enumerate()
+ .max_by(|(_, a), (_, b)| {
+ compare_dists(&a, &b, prefer_identities, include_pruned_owners)
+ })
+ .map(|(p, _)| p)
+ }
+ /// Secret wants to be the same on all hosts, leave only one unpruned version of it
+ fn prune_nonunique(&mut self, prefer_identities: &BTreeSet<SecretOwner>) {
+ if self.distributions().next().is_none() {
+ return;
+ }
+ let best = self.best_idx(prefer_identities, false).expect("not empty");
+ for (i, dist) in self.distributions_mut().enumerate() {
+ if i != best {
+ dist.prune(
+ "secret wants to be the same on all hosts, only the best one was left"
+ .to_owned(),
+ );
+ }
+ }
+ }
+
+ pub fn try_unprune(&mut self, owner: SecretOwner) -> Option<&FleetSecretDistribution> {
+ assert!(self.get(&owner).is_none(), "secret is not pruned for host");
+ if let Some(dist) = self
+ .distributions_mut()
+ .find(|v| v.owners_pending_prune.contains_key(&owner))
+ {
+ dist.unprune_owner(owner);
+ Some(dist)
+ } else {
+ None
+ }
+ }
+
+ pub fn best_distribution_for_reencryption(
+ &mut self,
+ prefer_identities: &BTreeSet<SecretOwner>,
+ ) -> Option<&mut FleetSecretDistribution> {
+ let best_idx = self.best_idx(prefer_identities, true)?;
+ self.distributions_mut().nth(best_idx)
+ }
+
+ fn prune_missing_parts(
+ &mut self,
+ expected_parts: &BTreeMap<String, GeneratorPart>,
+ filter_owner: Option<&SecretOwner>,
+ ) {
+ 'dist: for ele in self.distributions_mut() {
+ if let Some(filter_owner) = filter_owner {
+ if !ele.owners.contains(filter_owner) {
+ continue;
+ }
+ // Note: secret still can have multiple owners even if it is host-owned
+ // in this case we expect that all owners using the same generator, so we can prune distribution for all of them
+ }
+ for (name, part) in expected_parts {
+ let Some(stored_part) = ele.secret.parts.get(name) else {
+ ele.prune(format!("secret definition added new part: {name}"));
+ continue 'dist;
+ };
+ if part.encrypted != stored_part.raw.encrypted {
+ ele.prune(format!(
+ "secret definition now requires part to be {}",
+ if part.encrypted {
+ "encrypted"
+ } else {
+ "non-encrypted"
+ }
+ ));
+ continue 'dist;
+ }
+ }
+ }
+ }
+ fn prune_generation_data(
+ &mut self,
+ expected_generation_data: &Value,
+ filter_owner: Option<&SecretOwner>,
+ ) {
+ for ele in self.distributions_mut() {
+ if let Some(filter_owner) = filter_owner {
+ if !ele.owners.contains(filter_owner) {
+ continue;
+ }
+ // Note: secret still can have multiple owners even if it is host-owned
+ // in this case we expect that all owners using the same generator, so we can prune distribution for all of them
+ }
+ if ele.secret.generation_data != *expected_generation_data {
+ ele.prune(format!(
+ "expected generation data mismatch: {expected_generation_data:?}"
+ ));
+ }
+ }
}
+
+ /// Prune all distributions with no unpruned owners.
+ /// For ease of reencryption where possible, it is only called on persistence, when in memory - pruned owners are kept and
+ /// can decrypt their secrets.
+ fn prune_dead(&mut self) {
+ for ele in self.distributions_mut() {
+ if ele.owners.is_empty() {
+ ele.prune("no owners left".to_owned());
+ }
+ }
+ }
+
+ pub fn distributions(&self) -> impl Iterator<Item = &FleetSecretDistribution> {
+ self.stored.iter().filter(|v| v.pending_prune.is_none())
+ }
+ pub fn distributions_mut(&mut self) -> impl Iterator<Item = &mut FleetSecretDistribution> {
+ self.stored.iter_mut().filter(|v| v.pending_prune.is_none())
+ }
+ pub fn owners(&self) -> impl Iterator<Item = &SecretOwner> {
+ self.distributions().flat_map(|v| v.owners.iter())
+ }
#[allow(
clippy::len_without_is_empty,
reason = "should not be empty for a long time"
)]
pub fn len(&self) -> usize {
- self.0.len()
+ self.distributions().count()
}
- pub fn get(&self, owner: &str) -> Option<&FleetSecretDistribution> {
- self.0.iter().find(|d| d.owners.contains(owner))
+ pub fn get(&self, owner: &SecretOwner) -> Option<&FleetSecretDistribution> {
+ self.distributions().find(|d| d.owners.contains(owner))
}
- fn entry(&mut self, owner: String) -> DistEntry<'_> {
- let Some(idx) = self.0.iter().position(|d| d.owners.contains(&owner)) else {
+ fn entry(&mut self, owner: SecretOwner) -> DistEntry<'_> {
+ let Some((idx, dist)) = self
+ .distributions()
+ .enumerate()
+ .find(|(_, d)| d.owners.contains(&owner))
+ else {
return DistEntry::Vacant(VacantDistEntry {
distributions: self,
- owner,
+ owners: BTreeSet::from([owner]),
});
};
DistEntry::Occupied(OccupiedDistEntry {
+ owners: dist.owners.clone(),
distributions: self,
idx,
- owner,
})
}
- fn extend(&mut self, dist: FleetSecretDistribution) {
- for owner in &dist.owners {
- self.entry(owner.to_owned()).remove();
+ pub fn extend(&mut self, dist: FleetSecretDistribution, reason: String) {
+ for ele in self.distributions_mut() {
+ ele.prune_owners(&dist.owners, reason.clone());
}
- self.0.push(dist);
+ self.stored.push(dist);
}
- pub fn contains(&self, owner: &str) -> bool {
- self.0.iter().any(|d| d.owners.contains(owner))
+ pub fn contains(&self, owner: &SecretOwner) -> bool {
+ self.distributions().any(|d| d.owners.contains(owner))
}
}
struct OccupiedDistEntry<'d> {
distributions: &'d mut FleetSecretDistributions,
idx: usize,
- owner: String,
+ owners: BTreeSet<SecretOwner>,
}
impl<'d> OccupiedDistEntry<'d> {
- fn remove(self) -> VacantDistEntry<'d> {
- let dist = &mut self.distributions.0[self.idx];
- assert!(
- dist.owners.remove(&self.owner),
- "entry exists, as we have its reference"
- );
- if dist.owners.is_empty() {
- self.distributions.0.remove(self.idx);
+ fn remove(self, whole_dist: bool, reason: String) -> VacantDistEntry<'d> {
+ let dist = &mut self.distributions.stored[self.idx];
+ if whole_dist {
+ dist.prune(reason);
+ } else {
+ dist.prune_owners(&self.owners, reason);
}
VacantDistEntry {
distributions: self.distributions,
- owner: self.owner,
+ owners: self.owners,
}
}
- fn set(self, secret: FleetSecretData) -> Self {
- self.remove().set(secret)
+ fn set(self, secret: FleetSecretData, reason: String) -> Self {
+ self.remove(false, reason).set(secret)
}
}
struct VacantDistEntry<'d> {
distributions: &'d mut FleetSecretDistributions,
- owner: String,
+ owners: BTreeSet<SecretOwner>,
}
impl<'d> VacantDistEntry<'d> {
fn set(self, secret: FleetSecretData) -> OccupiedDistEntry<'d> {
let Self {
distributions,
- owner,
+ owners,
} = self;
- let idx = distributions.0.len();
- distributions.0.push(FleetSecretDistribution {
- owners: BTreeSet::from_iter([owner.clone()]),
+ let idx = distributions.stored.len();
+ distributions.stored.push(FleetSecretDistribution {
+ owners: owners.clone(),
secret,
+ owners_pending_prune: BTreeMap::new(),
+ pending_prune: None,
_deprecated_managed: true,
});
OccupiedDistEntry {
distributions,
- owner,
+ owners,
idx,
}
}
@@ -262,16 +568,16 @@
Occupied(OccupiedDistEntry<'d>),
}
impl DistEntry<'_> {
- fn remove(self) -> Self {
+ fn remove(self, whole_dist: bool, reason: String) -> Self {
match self {
DistEntry::Vacant(_) => self,
- DistEntry::Occupied(o) => Self::Vacant(o.remove()),
+ DistEntry::Occupied(o) => Self::Vacant(o.remove(whole_dist, reason)),
}
}
- fn set(self, secret: FleetSecretData) -> Self {
+ fn set(self, secret: FleetSecretData, reason: String) -> Self {
Self::Occupied(match self {
DistEntry::Vacant(e) => e.set(secret),
- DistEntry::Occupied(e) => e.set(secret),
+ DistEntry::Occupied(e) => e.set(secret, reason),
})
}
}
@@ -281,8 +587,13 @@
where
S: serde::Serializer,
{
+ let mut v = self.clone();
+ v.prune_dead();
let mut found_hosts = BTreeSet::new();
- for ele in self.0.iter() {
+ for ele in v.distributions() {
+ if ele.pending_prune.is_some() {
+ continue;
+ }
if ele.owners.is_empty() {
panic!("consistency: secret distribution has no defined owners");
}
@@ -294,10 +605,15 @@
}
}
}
- match self.0.len() {
+ match v.stored.len() {
0 => panic!("consistency: empty distributions"),
- 1 => self.0[0].serialize(serializer),
- _ => self.0.serialize(serializer),
+ 1 => v.stored[0].serialize(serializer),
+ _ => {
+ let mut sorted = v.stored.clone();
+ // Store outdated distributions last
+ sorted.sort_by_key(|v| v.pending_prune.is_some() as u32);
+ sorted.serialize(serializer)
+ }
}
}
}
@@ -313,15 +629,18 @@
Many(Vec<FleetSecretDistribution>),
}
let d = Distributions::deserialize(deserializer)?;
- let ds = match d {
+ let stored = match d {
Distributions::One(d) => vec![d],
Distributions::Many(ds) => ds,
};
- if ds.is_empty() {
+ if stored.is_empty() {
return Err(de::Error::custom("consistency: empty distributions"));
}
let mut found_hosts = BTreeSet::new();
- for ele in ds.iter() {
+ for ele in stored.iter() {
+ if ele.pending_prune.is_some() {
+ continue;
+ }
if ele.owners.is_empty() {
return Err(de::Error::custom(
"consistency: secret distribution has no defined owners",
@@ -335,73 +654,65 @@
}
}
}
- Ok(Self(ds))
+ Ok(Self { stored })
}
}
-#[derive(Serialize, Deserialize, Default)]
+#[derive(Deserialize, Default)]
pub struct FleetSecrets(BTreeMap<String, FleetSecretDistributions>);
+impl Serialize for FleetSecrets {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ let data: BTreeMap<String, FleetSecretDistributions> = self
+ .0
+ .iter()
+ .filter(|(_, v)| !v.stored.is_empty())
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect();
+
+ data.serialize(serializer)
+ }
+}
+
impl FleetSecrets {
pub fn keys(&self) -> btree_map::Keys<String, FleetSecretDistributions> {
self.0.keys()
}
- pub fn keys_for_owner(&self, owner: &str) -> impl Iterator<Item = &String> {
+ pub fn keys_for_owner(&self, owner: &SecretOwner) -> impl Iterator<Item = &String> {
self.0
.iter()
.filter(|(_, d)| d.contains(owner))
.map(|(n, _)| n)
}
- pub fn drop_owner_no_reencrypt(&mut self, secret: &str, owner: &str) -> bool {
- let Entry::Occupied(mut dists) = self.0.entry(secret.to_owned()) else {
- return false;
- };
- let DistEntry::Occupied(dist) = dists.get_mut().entry(owner.to_owned()) else {
- return false;
- };
-
- dist.remove();
-
- if dists.get().0.is_empty() {
- dists.remove();
- };
-
- true
- }
- pub fn set_single_data(&mut self, secret: String, owner: String, data: FleetSecretData) {
- let e = self
- .0
- .entry(secret.to_owned())
- .or_insert_with(|| FleetSecretDistributions(Default::default()));
- e.entry(owner.to_owned()).set(data);
- }
pub fn set_data(&mut self, secret: String, data: FleetSecretDistribution) {
match self.0.entry(secret) {
Entry::Vacant(e) => {
- e.insert(FleetSecretDistributions(vec![data]));
+ e.insert(FleetSecretDistributions { stored: vec![data] });
}
Entry::Occupied(mut e) => {
let dists = e.get_mut();
- dists.extend(data)
+ dists.extend(data, "secret data was replaced".to_owned())
}
}
- }
- pub fn get_single(&self, secret: &str, owner: &str) -> Option<&FleetSecretDistribution> {
- let secret = self.0.get(secret)?;
- secret.get(owner)
}
pub fn get(&self, secret: &str) -> Option<&FleetSecretDistributions> {
self.0.get(secret)
}
+ pub fn get_mut(&mut self, secret: &str) -> Option<&mut FleetSecretDistributions> {
+ self.0.get_mut(secret)
+ }
- pub fn contains_for_owner(&self, secret: &str, owner: &str) -> bool {
- let Some(secret) = self.0.get(secret) else {
- return false;
- };
- secret.contains(owner)
+ pub fn get_or_create(&mut self, secret: &str) -> &mut FleetSecretDistributions {
+ self.0
+ .entry(secret.to_owned())
+ .or_insert(FleetSecretDistributions::default())
}
+
pub fn contains(&self, secret: &str) -> bool {
self.0.contains_key(secret)
}
@@ -411,7 +722,7 @@
fn merge_from_hosts(
&mut self,
- host_secrets: BTreeMap<String, BTreeMap<String, FleetSecretDistribution>>,
+ host_secrets: BTreeMap<SecretOwner, BTreeMap<String, FleetSecretDistribution>>,
) {
for (host, host_secrets) in host_secrets {
for (secret_name, mut secret_data) in host_secrets {
@@ -420,11 +731,27 @@
}
}
}
+
+ pub fn prune_host(&mut self, host: &SecretOwner, expected_nonshared: BTreeSet<String>) {
+ for (name, dists) in self.0.iter_mut() {
+ if expected_nonshared.contains(name) {
+ continue;
+ }
+ for dist in dists.distributions_mut() {
+ if dist.owners.contains(host) {
+ dist.prune_owners(
+ &BTreeSet::from([host.to_owned()]),
+ "host no longer defines this secret".to_owned(),
+ );
+ }
+ }
+ }
+ }
}
-#[derive(Debug)]
+#[derive(Debug, Clone)]
pub struct Expectations {
- pub owners: BTreeSet<String>,
+ pub owners: BTreeSet<SecretOwner>,
pub generation_data: serde_json::Value,
pub parts: BTreeMap<String, GeneratorPart>,
}
@@ -432,3 +759,26 @@
pub struct GeneratorPart {
pub encrypted: bool,
}
+
+#[derive(Debug, Clone, Copy)]
+pub struct RegenerationConstraints {
+ pub allow_different: bool,
+ pub regenerate_on_owner_added: bool,
+ pub regenerate_on_owner_removed: bool,
+}
+impl RegenerationConstraints {
+ pub fn host_personal() -> Self {
+ Self {
+ allow_different: false,
+ regenerate_on_owner_added: true,
+ regenerate_on_owner_removed: true,
+ }
+ }
+ pub fn without_preferences(self) -> Self {
+ Self {
+ allow_different: self.allow_different,
+ regenerate_on_owner_added: false,
+ regenerate_on_owner_removed: false,
+ }
+ }
+}
crates/fleet-base/src/host.rsdiffbeforeafterboth1use std::{2 cell::OnceCell,3 collections::BTreeSet,4 ffi::{OsStr, OsString},5 fmt::Display,6 io::Write,7 ops::Deref,8 path::PathBuf,9 str::FromStr,10 sync::{Arc, Mutex, MutexGuard, OnceLock},11};1213use anyhow::{Context, Result, anyhow, bail, ensure};14use fleet_shared::SecretData;15use nix_eval::{Value, nix_go, nix_go_json, util::assert_warn};16use openssh::{ControlPersist, SessionBuilder};17use serde::de::DeserializeOwned;18use tabled::Tabled;19use tempfile::NamedTempFile;20use time::{UtcDateTime, format_description};21use tracing::warn;2223use crate::{24 command::MyCommand,25 fleetdata::{FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretDistributions},26};2728pub struct FleetConfigInternals {29 /// Fleet project directory, containing fleet.nix file.30 pub directory: PathBuf,31 /// builtins.currentSystem32 pub local_system: String,33 pub data: Arc<Mutex<FleetData>>,34 pub nix_args: Vec<OsString>,35 /// fleet_config.config36 pub config_field: Value,37 /// flake.output38 pub flake_outputs: Value,39 // TODO: Remove with connectivity refactor40 pub localhost: String,4142 /// import nixpkgs {system = local};43 pub default_pkgs: Value,44 /// inputs.nixpkgs45 pub nixpkgs: Value,46}4748// TODO: Make field not pub49#[derive(Clone)]50pub struct Config(pub Arc<FleetConfigInternals>);5152impl Deref for Config {53 type Target = FleetConfigInternals;5455 fn deref(&self) -> &Self::Target {56 &self.057 }58}5960#[derive(Clone, Copy, Debug)]61pub enum EscalationStrategy {62 Sudo,63 Run0,64 Su,65}6667#[derive(Clone, PartialEq, Copy, Debug)]68pub enum DeployKind {69 /// NixOS => NixOS managed by fleet70 UpgradeToFleet,71 /// NixOS managed by fleet => NixOS managed by fleet72 Fleet,73 /// Remote host has /mnt, /mnt/boot mounted,74 /// generated config is added to fleet configuration.75 NixosInstall,76 /// Remote host has some system and nix installed in multi-user mode (/nix is owned by root),77 /// generated config is added to fleet configuration,78 /// and /etc/NIXOS_LUSTRATE exists, fleet will perform the rest.79 NixosLustrate,80}8182impl FromStr for DeployKind {83 type Err = anyhow::Error;84 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {85 match s {86 "upgrade-to-fleet" => Ok(Self::UpgradeToFleet),87 "fleet" => Ok(Self::Fleet),88 "nixos-install" => Ok(Self::NixosInstall),89 "nixos-lustrate" => Ok(Self::NixosLustrate),90 v => bail!(91 "unknown deploy_kind: {v}; expected on of \"upgrade-to-fleet\", \"fleet\", \"nixos-install\", \"nixos-lustrate\""92 ),93 }94 }95}96pub struct ConfigHost {97 config: Config,98 pub name: String,99 groups: OnceCell<Vec<String>>,100101 // TODO: Both of those values are taken from host opts, there should be a cleaner way to specify it102 deploy_kind: OnceCell<DeployKind>,103 session_destination: OnceCell<String>,104 legacy_ssh_store: OnceCell<bool>,105106 pub host_config: Option<Value>,107 pub nixos_config: OnceCell<Value>,108 pub nixos_unchecked_config: OnceCell<Value>,109 pub pkgs_override: Option<Value>,110111 // TODO: Move command helpers away with connectivity refactor112 pub local: bool,113 pub session: OnceLock<Arc<openssh::Session>>,114}115116#[derive(Debug, Clone, Copy)]117pub enum GenerationStorage {118 Deployer,119 Machine,120 Pusher,121}122impl GenerationStorage {123 fn prefix(&self) -> &'static str {124 match self {125 GenerationStorage::Deployer => "deployer.",126 GenerationStorage::Machine => "",127 GenerationStorage::Pusher => "pusher.",128 }129 }130}131132#[derive(Tabled, Debug)]133pub struct Generation {134 #[tabled(rename = "ID", format("{}", self.rollback_id()))]135 pub id: u32,136 #[tabled(rename = "Current")]137 pub current: bool,138 #[tabled(rename = "Created at")]139 pub datetime: UtcDateTime,140 #[tabled(format = "{:?}")]141 pub store_path: PathBuf,142 #[tabled(skip)]143 pub location: GenerationStorage,144}145impl Generation {146 pub fn rollback_id(&self) -> String {147 format!("{}{}", self.location.prefix(), self.id)148 }149}150151fn parse_generation_line(g: &str) -> Option<Generation> {152 let mut parts = g.split_whitespace();153 let id = parts.next()?;154 let id: u32 = id.parse().ok()?;155 let date = parts.next()?;156 let time = parts.next()?;157 let current = if let Some(current) = parts.next() {158 if current == "(current)" {159 Some(true)160 } else {161 None162 }163 } else {164 Some(false)165 };166 let current = current?;167 if parts.next().is_some() {168 warn!("unexpected text after generation: {g}");169 }170171 let format = format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]")172 .expect("valid format");173 let datetime = UtcDateTime::parse(&format!("{date} {time}"), &format).ok()?;174175 Some(Generation {176 id,177 current,178 datetime,179 store_path: PathBuf::new(),180 location: GenerationStorage::Machine,181 })182}183// TODO: Move command helpers away with connectivity refactor184impl ConfigHost {185 pub async fn list_generations(&self, profile: &str) -> Result<Vec<Generation>> {186 let mut cmd = self.cmd("nix-env").await?;187 cmd.comparg("--profile", format!("/nix/var/nix/profiles/{profile}"))188 .arg("--list-generations")189 .env("TZ", "UTC");190 // Sudo is required because --list-generations tries to acquire profile lock191 let data = cmd.sudo().run_string().await?;192 let mut generations = data193 .split('\n')194 .map(|e| e.trim())195 .filter(|&l| !l.is_empty())196 .filter_map(|g| {197 let generation = parse_generation_line(g);198 if generation.is_none() {199 warn!("bad generation: {g}");200 };201 generation202 })203 .collect::<Vec<_>>();204 for ele in generations.iter_mut() {205 let mut cmd = self.cmd("readlink").await?;206 cmd.arg("--")207 .arg(format!("/nix/var/nix/profiles/{profile}-{}-link", ele.id));208 let path = cmd.run_string().await?;209 ele.store_path = PathBuf::from(path.trim_end_matches("\n"));210 }211212 Ok(generations)213 }214215 pub fn set_session_destination(&self, dest: String) {216 self.session_destination217 .set(dest)218 .expect("session destination is already set")219 }220 pub fn set_deploy_kind(&self, kind: DeployKind) {221 self.deploy_kind222 .set(kind)223 .expect("deploy kind is already set");224 }225 pub fn set_legacy_ssh_store(&self, legacy: bool) {226 self.legacy_ssh_store227 .set(legacy)228 .expect("legacy ssh store is already set")229 }230 pub async fn deploy_kind(&self) -> Result<DeployKind> {231 if let Some(kind) = self.deploy_kind.get() {232 return Ok(*kind);233 }234 let is_fleet_managed = match self.file_exists("/etc/FLEET_HOST").await {235 Ok(v) => v,236 Err(e) => {237 bail!("failed to query remote system kind: {e}");238 }239 };240 if !is_fleet_managed {241 bail!(242 "{}",243 indoc::indoc! {"244 host is not marked as managed by fleet245 if you're not trying to lustrate/install system from scratch,246 you should either247 1. manually create /etc/FLEET_HOST file on the target host,248 2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet249 3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos250 "}251 );252 }253 // TOCTOU is possible254 let _ = self.deploy_kind.set(DeployKind::Fleet);255 Ok(*self.deploy_kind.get().expect("deploy kind is just set"))256 }257 pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {258 // Prefer sudo, as run0 has some gotchas with polkit259 // and too many repeating prompts.260 if (self.find_in_path("sudo").await).is_ok() {261 return Ok(EscalationStrategy::Sudo);262 }263 if (self.find_in_path("run0").await).is_ok() {264 return Ok(EscalationStrategy::Run0);265 }266 Ok(EscalationStrategy::Su)267 }268 async fn open_session(&self) -> Result<Arc<openssh::Session>> {269 assert!(!self.local, "do not open ssh connection to local session");270 // FIXME: TOCTOU271 if let Some(session) = &self.session.get() {272 return Ok((*session).clone());273 };274 let mut session = SessionBuilder::default();275 session.control_persist(ControlPersist::ClosedAfterInitialConnection);276277 let dest = self.session_destination.get().unwrap_or(&self.name);278 let session = session279 .connect(&dest)280 .await281 .map_err(|e| anyhow!("ssh error while connecting to {}: {e:#?}", self.name))?;282 let session = Arc::new(session);283 self.session.set(session.clone()).expect("TOCTOU happened");284 Ok(session)285 }286 pub async fn mktemp_dir(&self) -> Result<String> {287 let mut cmd = self.cmd("mktemp").await?;288 cmd.arg("-d");289 let path = cmd.run_string().await?;290 Ok(path.trim_end().to_owned())291 }292 pub async fn file_exists(&self, path: impl AsRef<OsStr>) -> Result<bool> {293 let mut cmd = self.cmd("sh").await?;294 cmd.arg("-c")295 .arg("test -e \"$1\" && echo true || echo false")296 .arg("_")297 .arg(path);298 cmd.run_value().await299 }300 pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {301 let mut cmd = self.cmd("cat").await?;302 cmd.arg(path);303 cmd.run_bytes().await304 }305 pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {306 let mut cmd = self.cmd("cat").await?;307 cmd.arg(path);308 cmd.run_string().await309 }310 pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {311 let mut cmd = self.cmd("ls").await?;312 cmd.arg(path);313 let out = cmd.run_string().await?;314 let mut lines = out.split('\n');315 if let Some(last) = lines.next_back() {316 ensure!(last.is_empty(), "output of ls should end with newline");317 }318 Ok(lines.map(ToOwned::to_owned).collect())319 }320 #[allow(dead_code)]321 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {322 let text = self.read_file_text(path).await?;323 Ok(serde_json::from_str(&text)?)324 }325 pub async fn read_env(&self, env: &str) -> Result<String> {326 let mut cmd = self.cmd("printenv").await?;327 cmd.arg(env);328 cmd.run_string().await329 }330 pub async fn find_in_path(&self, command: &str) -> Result<String> {331 // // `which` is not a part of coreutils, and it might not exist on machine.332 // let path = self.read_env("PATH").await?;333 // // Assuming delimiter is :, we don't work with windows host, this check will be much334 // // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)335 // for ele in path.split(':') {336 // let test_path = format!("{ele}/{cmd}");337 // test -x etc338 // }339 // let mut cmd = self.cmd("printenv").await?;340 // cmd.arg(env);341 // Ok(cmd.run_string().await?)342 // Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.343 let mut cmd = self344 .cmd_escalation(345 // Not used346 EscalationStrategy::Su,347 "which",348 )349 .await?;350 cmd.arg(command);351 cmd.run_string().await352 }353 pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>354 where355 <D as FromStr>::Err: Display,356 {357 let text = self.read_file_text(path).await?;358 D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))359 }360 pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {361 self.cmd_escalation(self.escalation_strategy().await?, cmd)362 .await363 }364 pub async fn cmd_escalation(365 &self,366 escalation: EscalationStrategy,367 cmd: impl AsRef<OsStr>,368 ) -> Result<MyCommand> {369 if self.local {370 Ok(MyCommand::new(escalation, cmd))371 } else {372 let session = self.open_session().await?;373 Ok(MyCommand::new_on(escalation, cmd, session))374 }375 }376 pub async fn nix_cmd(&self) -> Result<MyCommand> {377 let mut nix = self.cmd("nix").await?;378 nix.args([379 "--extra-experimental-features",380 "nix-command",381 "--extra-experimental-features",382 "flakes",383 ]);384 Ok(nix)385 }386387 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {388 ensure!(data.encrypted, "secret is not encrypted");389 let mut cmd = self.cmd("fleet-install-secrets").await?;390 cmd.arg("decrypt").eqarg("--secret", data.to_string());391 let encoded = cmd392 .sudo()393 .run_string()394 .await395 .context("failed to call remote host for decrypt")?;396 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;397 ensure!(!data.encrypted, "secret came out encrypted");398 Ok(data.data)399 }400 pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {401 ensure!(data.encrypted, "secret is not encrypted");402 let mut cmd = self.cmd("fleet-install-secrets").await?;403 cmd.arg("reencrypt").eqarg("--secret", data.to_string());404 for target in targets {405 let key = self.config.key(&target).await?;406 cmd.eqarg("--targets", key);407 }408 let encoded = cmd409 .sudo()410 .run_string()411 .await412 .context("failed to call remote host for decrypt")?;413 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;414 ensure!(data.encrypted, "secret came out not encrypted");415 Ok(data)416 }417 /// Returns path for futureproofing, as path might change i.e on conversion to CA418 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {419 if self.local {420 // Path is located locally, thus already trusted.421 return Ok(path.to_owned());422 }423 let mut nix = MyCommand::new(424 // Not used425 EscalationStrategy::Su,426 "nix",427 );428 nix.arg("copy").arg("--substitute-on-destination");429430 let proto = if self.legacy_ssh_store.get().cloned().unwrap_or(false) {431 "ssh"432 } else {433 "ssh-ng"434 };435436 match self.deploy_kind().await? {437 DeployKind::Fleet | DeployKind::UpgradeToFleet | DeployKind::NixosLustrate => {438 nix.comparg("--to", format!("{proto}://{}", self.name));439 }440 DeployKind::NixosInstall => {441 nix442 // Signature checking makes no sense with remote-store store argument set, as we're not even interacting with remote nix daemon443 .arg("--no-check-sigs")444 .comparg(445 "--to",446 format!("{proto}://root@{}?remote-store=/mnt", self.name),447 );448 }449 }450 nix.arg(path);451 nix.run_nix().await.context("nix copy")?;452 Ok(path.to_owned())453 }454 pub async fn systemctl_stop(&self, name: &str) -> Result<()> {455 let mut cmd = self.cmd("systemctl").await?;456 cmd.arg("stop").arg(name);457 cmd.sudo().run().await458 }459 pub async fn systemctl_start(&self, name: &str) -> Result<()> {460 let mut cmd = self.cmd("systemctl").await?;461 cmd.arg("start").arg(name);462 cmd.sudo().run().await463 }464465 pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {466 let mut cmd = self.cmd("rm").await?;467 cmd.arg("-f").arg(path);468 if sudo {469 cmd = cmd.sudo()470 }471 cmd.run().await472 }473}474475struct HostSecretDefinition(Value);476477impl ConfigHost {478 // TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,479 // assuming getting tags always returns the same value.480 pub fn tags(&self) -> Result<Vec<String>> {481 if let Some(v) = self.groups.get() {482 return Ok(v.clone());483 }484 let Some(host_config) = &self.host_config else {485 return Ok(vec![]);486 };487 let tags: Vec<String> = nix_go_json!(host_config.tags);488489 let _ = self.groups.set(tags.clone());490491 Ok(tags)492 }493 pub fn nixos_config(&self) -> Result<Value> {494 if let Some(v) = self.nixos_config.get() {495 return Ok(v.clone());496 }497 let Some(host_config) = &self.host_config else {498 bail!("local host has no nixos_config");499 };500 let nixos_config = nix_go!(host_config.nixos.config);501 assert_warn("nixos config evaluation", &nixos_config)?;502503 let _ = self.nixos_config.set(nixos_config.clone());504505 Ok(nixos_config)506 }507 pub fn nixos_unchecked_config(&self) -> Result<Value> {508 if let Some(v) = self.nixos_unchecked_config.get() {509 return Ok(v.clone());510 }511 let Some(host_config) = &self.host_config else {512 bail!("local host has no nixos_config");513 };514 let nixos_config = nix_go!(host_config.nixos_unchecked.config);515516 let _ = self.nixos_unchecked_config.set(nixos_config.clone());517518 Ok(nixos_config)519 }520521 pub fn list_defined_secrets(&self) -> Result<Vec<String>> {522 let nixos = self.nixos_unchecked_config()?;523 let secrets = nix_go!(nixos.secrets);524 secrets.list_fields()525 }526527 /// Packages for this host, resolved with nixpkgs overlays528 pub fn pkgs(&self) -> Result<Value> {529 if let Some(value) = &self.pkgs_override {530 return Ok(value.clone());531 }532 let Some(host_config) = &self.host_config else {533 bail!("local host has no host_config");534 };535 // TODO: Should nixos.options be cached?536 Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))537 }538}539540pub struct SharedSecretDefinition(Value);541impl SharedSecretDefinition {542 pub fn expected_owners(&self) -> Result<BTreeSet<String>> {543 let secret = &self.0;544 Ok(nix_go_json!(secret.expectedOwners))545 }546 pub fn generator(&self) -> Result<Value> {547 let secret = &self.0;548 Ok(nix_go!(secret.generator))549 }550}551552impl Config {553 pub fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {554 let config = &self.config_field;555 let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);556 Ok(tagged)557 }558 pub fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {559 let mut out = BTreeSet::new();560 for owner in owners {561 if let Some(tag) = owner.strip_prefix('@') {562 let hosts = self.tagged_hostnames(tag)?;563 out.extend(hosts);564 } else {565 out.insert(owner);566 }567 }568 Ok(out)569 }570 pub fn local_host(&self) -> ConfigHost {571 ConfigHost {572 config: self.clone(),573 name: "<virtual localhost>".to_owned(),574 host_config: None,575 nixos_config: OnceCell::new(),576 nixos_unchecked_config: OnceCell::new(),577 groups: {578 let cell = OnceCell::new();579 let _ = cell.set(vec![]);580 cell581 },582 pkgs_override: Some(self.default_pkgs.clone()),583584 local: true,585 session: OnceLock::new(),586 deploy_kind: OnceCell::new(),587 session_destination: OnceCell::new(),588 legacy_ssh_store: OnceCell::new(),589 }590 }591592 pub fn host(&self, name: &str) -> Result<ConfigHost> {593 let config = &self.config_field;594 let host_config = nix_go!(config.hosts[{ name }]);595596 Ok(ConfigHost {597 config: self.clone(),598 name: name.to_owned(),599 host_config: Some(host_config),600 nixos_config: OnceCell::new(),601 nixos_unchecked_config: OnceCell::new(),602 groups: OnceCell::new(),603 pkgs_override: None,604605 // TODO: Remove with connectivit refactor606 local: self.localhost == name,607 session: OnceLock::new(),608 deploy_kind: OnceCell::new(),609 session_destination: OnceCell::new(),610 legacy_ssh_store: OnceCell::new(),611 })612 }613 pub fn list_hosts(&self) -> Result<Vec<ConfigHost>> {614 let config = &self.config_field;615 let names = nix_go!(config.hosts).list_fields()?;616 let mut out = vec![];617 for name in names {618 out.push(self.host(&name)?);619 }620 Ok(out)621 }622 // TODO: Replace usages with .host().nixos_config623 pub fn system_config(&self, host: &str) -> Result<Value> {624 let fleet_field = &self.config_field;625 Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))626 }627628 /// Shared secrets configured in fleet.nix or in flake629 pub fn list_configured_shared(&self) -> Result<Vec<String>> {630 let config_field = &self.config_field;631 nix_go!(config_field.sharedSecrets).list_fields()632 }633 pub fn has_shared(&self, name: &str) -> bool {634 let data = self.data();635 data.secrets.contains(name)636 }637 pub fn replace_shared(&self, name: String, shared: FleetSecretDistribution) {638 let mut data = self.data_mut();639 data.secrets.set_data(name, shared);640 }641 pub fn remove_shared(&self, secret: &str) {642 let mut data = self.data_mut();643 data.secrets.remove(secret);644 }645646 pub fn list_secrets_for_owner(&self, host: &str) -> Vec<String> {647 let data = self.data_mut();648 data.secrets.keys_for_owner(host).cloned().collect()649 }650 pub fn list_secrets(&self) -> Vec<String> {651 let data = self.data_mut();652 data.secrets.keys().cloned().collect()653 }654655 pub fn has_secret(&self, host: &str, secret: &str) -> bool {656 let data = self.data();657 data.secrets.contains_for_owner(secret, host)658 }659 pub fn insert_secret(&self, host: String, secret: String, value: FleetSecretData) {660 let mut data = self.data_mut();661 data.secrets.set_single_data(secret, host, value);662 }663 pub fn remove_secret(&self, host: &str, secret: &str) {664 let mut data = self.data_mut();665 data.secrets.drop_owner_no_reencrypt(secret, host);666 }667668 pub fn host_secret(&self, host: &str, secret: &str) -> Option<FleetSecretDistribution> {669 let data = self.data();670 data.secrets.get_single(secret, host).cloned()671 }672 pub fn shared_secret(&self, secret: &str) -> Option<FleetSecretDistributions> {673 let data = self.data();674 data.secrets.get(secret).cloned()675 }676677 pub fn secret_definition(&self, secret: &str) -> Result<Option<SharedSecretDefinition>> {678 let config = &self.config_field;679 let shared_secrets = nix_go!(config.secrets);680 if !shared_secrets.has_field(secret)? {681 return Ok(None);682 }683 Ok(Some(SharedSecretDefinition(nix_go!(684 shared_secrets[secret]685 ))))686 }687688 // TODO: Should this be something modifiable from other processes?689 // E.g terraform provider might want to update FleetData (e.g secrets),690 // and current implementation assumes only one process holds current fleet.nix691 // Given that it is no longer needs to be a file for nix evaluation,692 // maybe it can be a .nix file for persistence, but accessible only693 // thru some shared state controller? Might it be stored in terraform694 // state provider?695 pub fn data(&'_ self) -> MutexGuard<'_, FleetData> {696 self.data.lock().unwrap()697 }698 pub fn data_mut(&'_ self) -> MutexGuard<'_, FleetData> {699 self.data.lock().unwrap()700 }701 pub fn save(&self) -> Result<()> {702 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.")?;703 let data = nixlike::serialize(&self.data() as &FleetData)?;704 tempfile.write_all(705 format!(706 "# This file contains fleet state and shouldn't be edited by hand\n\n{data}\n\n# vim: ts=2 et nowrap\n"707 )708 .as_bytes(),709 )?;710 let mut fleet_data_path = self.directory.clone();711 fleet_data_path.push("fleet.nix");712 tempfile.persist(fleet_data_path)?;713 Ok(())714 }715}1use std::{2 collections::{BTreeMap, BTreeSet},3 ffi::{OsStr, OsString},4 fmt::Display,5 io::Write,6 ops::Deref,7 path::PathBuf,8 str::FromStr,9 sync::{Arc, Mutex, MutexGuard, OnceLock},10};1112use anyhow::{Context, Result, anyhow, bail, ensure};13use chrono::{DateTime, Utc};14use fleet_shared::SecretData;15use nix_eval::{Value, nix_go, nix_go_json, util::assert_warn};16use openssh::{ControlPersist, SessionBuilder};17use serde::de::DeserializeOwned;18use tabled::Tabled;19use tempfile::NamedTempFile;20use time::{UtcDateTime, format_description};21use tracing::warn;2223use crate::{24 command::MyCommand,25 fleetdata::{26 FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretPart, SecretOwner,27 },28};2930pub struct FleetConfigInternals {31 pub prefer_identities: BTreeSet<SecretOwner>,32 pub now: DateTime<Utc>,3334 /// Fleet project directory, containing fleet.nix file.35 pub directory: PathBuf,36 /// builtins.currentSystem37 pub local_system: String,38 pub data: Arc<FleetData>,39 pub nix_args: Vec<OsString>,40 /// fleet_config.config41 pub config_field: Value,42 /// flake.output43 pub flake_outputs: Value,44 // TODO: Remove with connectivity refactor45 pub localhost: String,4647 /// import nixpkgs {system = local};48 pub default_pkgs: Value,49 /// inputs.nixpkgs50 pub nixpkgs: Value,51}5253// TODO: Make field not pub54#[derive(Clone)]55pub struct Config(pub Arc<FleetConfigInternals>);5657impl Deref for Config {58 type Target = FleetConfigInternals;5960 fn deref(&self) -> &Self::Target {61 &self.062 }63}6465#[derive(Clone, Copy, Debug)]66pub enum EscalationStrategy {67 Sudo,68 Run0,69 Su,70}7172#[derive(Clone, PartialEq, Copy, Debug)]73pub enum DeployKind {74 /// NixOS => NixOS managed by fleet75 UpgradeToFleet,76 /// NixOS managed by fleet => NixOS managed by fleet77 Fleet,78 /// Remote host has /mnt, /mnt/boot mounted,79 /// generated config is added to fleet configuration.80 NixosInstall,81 /// Remote host has some system and nix installed in multi-user mode (/nix is owned by root),82 /// generated config is added to fleet configuration,83 /// and /etc/NIXOS_LUSTRATE exists, fleet will perform the rest.84 NixosLustrate,85}8687impl FromStr for DeployKind {88 type Err = anyhow::Error;89 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {90 match s {91 "upgrade-to-fleet" => Ok(Self::UpgradeToFleet),92 "fleet" => Ok(Self::Fleet),93 "nixos-install" => Ok(Self::NixosInstall),94 "nixos-lustrate" => Ok(Self::NixosLustrate),95 v => bail!(96 "unknown deploy_kind: {v}; expected on of \"upgrade-to-fleet\", \"fleet\", \"nixos-install\", \"nixos-lustrate\""97 ),98 }99 }100}101pub struct ConfigHost {102 config: Config,103 pub name: String,104 groups: OnceLock<Vec<String>>,105106 // TODO: Both of those values are taken from host opts, there should be a cleaner way to specify it107 deploy_kind: OnceLock<DeployKind>,108 session_destination: OnceLock<String>,109 legacy_ssh_store: OnceLock<bool>,110111 pub host_config: Option<Value>,112 pub nixos_config: OnceLock<Value>,113 pub nixos_unchecked_config: OnceLock<Value>,114 pub pkgs_override: Option<Value>,115116 // TODO: Move command helpers away with connectivity refactor117 pub local: bool,118 pub session: OnceLock<Arc<openssh::Session>>,119}120121#[derive(Debug, Clone, Copy)]122pub enum GenerationStorage {123 Deployer,124 Machine,125 Pusher,126}127impl GenerationStorage {128 fn prefix(&self) -> &'static str {129 match self {130 GenerationStorage::Deployer => "deployer.",131 GenerationStorage::Machine => "",132 GenerationStorage::Pusher => "pusher.",133 }134 }135}136137#[derive(Tabled, Debug)]138pub struct Generation {139 #[tabled(rename = "ID", format("{}", self.rollback_id()))]140 pub id: u32,141 #[tabled(rename = "Current")]142 pub current: bool,143 #[tabled(rename = "Created at")]144 pub datetime: UtcDateTime,145 #[tabled(format = "{:?}")]146 pub store_path: PathBuf,147 #[tabled(skip)]148 pub location: GenerationStorage,149}150impl Generation {151 pub fn rollback_id(&self) -> String {152 format!("{}{}", self.location.prefix(), self.id)153 }154}155156fn parse_generation_line(g: &str) -> Option<Generation> {157 let mut parts = g.split_whitespace();158 let id = parts.next()?;159 let id: u32 = id.parse().ok()?;160 let date = parts.next()?;161 let time = parts.next()?;162 let current = if let Some(current) = parts.next() {163 if current == "(current)" {164 Some(true)165 } else {166 None167 }168 } else {169 Some(false)170 };171 let current = current?;172 if parts.next().is_some() {173 warn!("unexpected text after generation: {g}");174 }175176 let format = format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]")177 .expect("valid format");178 let datetime = UtcDateTime::parse(&format!("{date} {time}"), &format).ok()?;179180 Some(Generation {181 id,182 current,183 datetime,184 store_path: PathBuf::new(),185 location: GenerationStorage::Machine,186 })187}188// TODO: Move command helpers away with connectivity refactor189impl ConfigHost {190 pub async fn list_generations(&self, profile: &str) -> Result<Vec<Generation>> {191 let mut cmd = self.cmd("nix-env").await?;192 cmd.comparg("--profile", format!("/nix/var/nix/profiles/{profile}"))193 .arg("--list-generations")194 .env("TZ", "UTC");195 // Sudo is required because --list-generations tries to acquire profile lock196 let data = cmd.sudo().run_string().await?;197 let mut generations = data198 .split('\n')199 .map(|e| e.trim())200 .filter(|&l| !l.is_empty())201 .filter_map(|g| {202 let generation = parse_generation_line(g);203 if generation.is_none() {204 warn!("bad generation: {g}");205 };206 generation207 })208 .collect::<Vec<_>>();209 for ele in generations.iter_mut() {210 let mut cmd = self.cmd("readlink").await?;211 cmd.arg("--")212 .arg(format!("/nix/var/nix/profiles/{profile}-{}-link", ele.id));213 let path = cmd.run_string().await?;214 ele.store_path = PathBuf::from(path.trim_end_matches("\n"));215 }216217 Ok(generations)218 }219220 pub fn set_session_destination(&self, dest: String) {221 self.session_destination222 .set(dest)223 .expect("session destination is already set")224 }225 pub fn set_deploy_kind(&self, kind: DeployKind) {226 self.deploy_kind227 .set(kind)228 .expect("deploy kind is already set");229 }230 pub fn set_legacy_ssh_store(&self, legacy: bool) {231 self.legacy_ssh_store232 .set(legacy)233 .expect("legacy ssh store is already set")234 }235 pub async fn deploy_kind(&self) -> Result<DeployKind> {236 if let Some(kind) = self.deploy_kind.get() {237 return Ok(*kind);238 }239 let is_fleet_managed = match self.file_exists("/etc/FLEET_HOST").await {240 Ok(v) => v,241 Err(e) => {242 bail!("failed to query remote system kind: {e}");243 }244 };245 if !is_fleet_managed {246 bail!(247 "{}",248 indoc::indoc! {"249 host is not marked as managed by fleet250 if you're not trying to lustrate/install system from scratch,251 you should either252 1. manually create /etc/FLEET_HOST file on the target host,253 2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet254 3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos255 "}256 );257 }258 // TOCTOU is possible259 let _ = self.deploy_kind.set(DeployKind::Fleet);260 Ok(*self.deploy_kind.get().expect("deploy kind is just set"))261 }262 pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {263 // Prefer sudo, as run0 has some gotchas with polkit264 // and too many repeating prompts.265 if (self.find_in_path("sudo").await).is_ok() {266 return Ok(EscalationStrategy::Sudo);267 }268 if (self.find_in_path("run0").await).is_ok() {269 return Ok(EscalationStrategy::Run0);270 }271 Ok(EscalationStrategy::Su)272 }273 async fn open_session(&self) -> Result<Arc<openssh::Session>> {274 assert!(!self.local, "do not open ssh connection to local session");275 // FIXME: TOCTOU276 if let Some(session) = &self.session.get() {277 return Ok((*session).clone());278 };279 let mut session = SessionBuilder::default();280 session.control_persist(ControlPersist::ClosedAfterInitialConnection);281282 let dest = self.session_destination.get().unwrap_or(&self.name);283 let session = session284 .connect(&dest)285 .await286 .map_err(|e| anyhow!("ssh error while connecting to {}: {e:#?}", self.name))?;287 let session = Arc::new(session);288 self.session.set(session.clone()).expect("TOCTOU happened");289 Ok(session)290 }291 pub async fn mktemp_dir(&self) -> Result<String> {292 let mut cmd = self.cmd("mktemp").await?;293 cmd.arg("-d");294 let path = cmd.run_string().await?;295 Ok(path.trim_end().to_owned())296 }297 pub async fn file_exists(&self, path: impl AsRef<OsStr>) -> Result<bool> {298 let mut cmd = self.cmd("sh").await?;299 cmd.arg("-c")300 .arg("test -e \"$1\" && echo true || echo false")301 .arg("_")302 .arg(path);303 cmd.run_value().await304 }305 pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {306 let mut cmd = self.cmd("cat").await?;307 cmd.arg(path);308 cmd.run_bytes().await309 }310 pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {311 let mut cmd = self.cmd("cat").await?;312 cmd.arg(path);313 cmd.run_string().await314 }315 pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {316 let mut cmd = self.cmd("ls").await?;317 cmd.arg(path);318 let out = cmd.run_string().await?;319 let mut lines = out.split('\n');320 if let Some(last) = lines.next_back() {321 ensure!(last.is_empty(), "output of ls should end with newline");322 }323 Ok(lines.map(ToOwned::to_owned).collect())324 }325 #[allow(dead_code)]326 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {327 let text = self.read_file_text(path).await?;328 Ok(serde_json::from_str(&text)?)329 }330 pub async fn read_env(&self, env: &str) -> Result<String> {331 let mut cmd = self.cmd("printenv").await?;332 cmd.arg(env);333 cmd.run_string().await334 }335 pub async fn find_in_path(&self, command: &str) -> Result<String> {336 // // `which` is not a part of coreutils, and it might not exist on machine.337 // let path = self.read_env("PATH").await?;338 // // Assuming delimiter is :, we don't work with windows host, this check will be much339 // // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)340 // for ele in path.split(':') {341 // let test_path = format!("{ele}/{cmd}");342 // test -x etc343 // }344 // let mut cmd = self.cmd("printenv").await?;345 // cmd.arg(env);346 // Ok(cmd.run_string().await?)347 // Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.348 let mut cmd = self349 .cmd_escalation(350 // Not used351 EscalationStrategy::Su,352 "which",353 )354 .await?;355 cmd.arg(command);356 cmd.run_string().await357 }358 pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>359 where360 <D as FromStr>::Err: Display,361 {362 let text = self.read_file_text(path).await?;363 D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))364 }365 pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {366 self.cmd_escalation(self.escalation_strategy().await?, cmd)367 .await368 }369 pub async fn cmd_escalation(370 &self,371 escalation: EscalationStrategy,372 cmd: impl AsRef<OsStr>,373 ) -> Result<MyCommand> {374 if self.local {375 Ok(MyCommand::new(escalation, cmd))376 } else {377 let session = self.open_session().await?;378 Ok(MyCommand::new_on(escalation, cmd, session))379 }380 }381 pub async fn nix_cmd(&self) -> Result<MyCommand> {382 let mut nix = self.cmd("nix").await?;383 nix.args([384 "--extra-experimental-features",385 "nix-command",386 "--extra-experimental-features",387 "flakes",388 ]);389 Ok(nix)390 }391392 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {393 ensure!(data.encrypted, "secret is not encrypted");394 let mut cmd = self.cmd("fleet-install-secrets").await?;395 cmd.arg("decrypt").eqarg("--secret", data.to_string());396 let encoded = cmd397 .sudo()398 .run_string()399 .await400 .context("failed to call remote host for decrypt")?;401 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;402 ensure!(!data.encrypted, "secret came out encrypted");403 Ok(data.data)404 }405 pub async fn reencrypt_distribution(406 &self,407 data: &FleetSecretDistribution,408 targets: BTreeSet<SecretOwner>,409 now: DateTime<Utc>,410 ) -> Result<FleetSecretDistribution> {411 let mut parts = BTreeMap::new();412 for (part_name, part) in &data.secret.parts {413 parts.insert(414 part_name.clone(),415 if part.raw.encrypted {416 FleetSecretPart {417 raw: self.reencrypt(part.raw.clone(), targets.clone()).await?,418 }419 } else {420 part.clone()421 },422 );423 }424 let secret = FleetSecretData {425 created_at: data.secret.created_at,426 expires_at: data.secret.expires_at,427 generation_data: data.secret.generation_data.clone(),428 parts,429 };430 Ok(FleetSecretDistribution::new(targets, secret, now))431 }432 pub async fn reencrypt(433 &self,434 data: SecretData,435 targets: BTreeSet<SecretOwner>,436 ) -> Result<SecretData> {437 ensure!(data.encrypted, "secret is not encrypted");438 let mut cmd = self.cmd("fleet-install-secrets").await?;439 cmd.arg("reencrypt").eqarg("--secret", data.to_string());440 for target in targets {441 let key = self.config.key(&target).await?;442 cmd.eqarg("--targets", key);443 }444 let encoded = cmd445 .sudo()446 .run_string()447 .await448 .context("failed to call remote host for decrypt")?;449 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;450 ensure!(data.encrypted, "secret came out not encrypted");451 Ok(data)452 }453 /// Returns path for futureproofing, as path might change i.e on conversion to CA454 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {455 if self.local {456 // Path is located locally, thus already trusted.457 return Ok(path.to_owned());458 }459 let mut nix = MyCommand::new(460 // Not used461 EscalationStrategy::Su,462 "nix",463 );464 nix.arg("copy").arg("--substitute-on-destination");465466 let proto = if self.legacy_ssh_store.get().cloned().unwrap_or(false) {467 "ssh"468 } else {469 "ssh-ng"470 };471472 match self.deploy_kind().await? {473 DeployKind::Fleet | DeployKind::UpgradeToFleet | DeployKind::NixosLustrate => {474 nix.comparg("--to", format!("{proto}://{}", self.name));475 }476 DeployKind::NixosInstall => {477 nix478 // Signature checking makes no sense with remote-store store argument set, as we're not even interacting with remote nix daemon479 .arg("--no-check-sigs")480 .comparg(481 "--to",482 format!("{proto}://root@{}?remote-store=/mnt", self.name),483 );484 }485 }486 nix.arg(path);487 nix.run_nix().await.context("nix copy")?;488 Ok(path.to_owned())489 }490 pub async fn systemctl_stop(&self, name: &str) -> Result<()> {491 let mut cmd = self.cmd("systemctl").await?;492 cmd.arg("stop").arg(name);493 cmd.sudo().run().await494 }495 pub async fn systemctl_start(&self, name: &str) -> Result<()> {496 let mut cmd = self.cmd("systemctl").await?;497 cmd.arg("start").arg(name);498 cmd.sudo().run().await499 }500501 pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {502 let mut cmd = self.cmd("rm").await?;503 cmd.arg("-f").arg(path);504 if sudo {505 cmd = cmd.sudo()506 }507 cmd.run().await508 }509}510511struct HostSecretDefinition(Value);512513impl ConfigHost {514 // TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,515 // assuming getting tags always returns the same value.516 pub fn tags(&self) -> Result<Vec<String>> {517 if let Some(v) = self.groups.get() {518 return Ok(v.clone());519 }520 let Some(host_config) = &self.host_config else {521 return Ok(vec![]);522 };523 let tags: Vec<String> = nix_go_json!(host_config.tags);524525 let _ = self.groups.set(tags.clone());526527 Ok(tags)528 }529 pub fn nixos_config(&self) -> Result<Value> {530 if let Some(v) = self.nixos_config.get() {531 return Ok(v.clone());532 }533 let Some(host_config) = &self.host_config else {534 bail!("local host has no nixos_config");535 };536 let nixos_config = nix_go!(host_config.nixos.config);537 assert_warn("nixos config evaluation", &nixos_config)?;538539 let _ = self.nixos_config.set(nixos_config.clone());540541 Ok(nixos_config)542 }543 pub fn nixos_unchecked_config(&self) -> Result<Value> {544 if let Some(v) = self.nixos_unchecked_config.get() {545 return Ok(v.clone());546 }547 let Some(host_config) = &self.host_config else {548 bail!("local host has no nixos_config");549 };550 let nixos_config = nix_go!(host_config.nixos_unchecked.config);551552 let _ = self.nixos_unchecked_config.set(nixos_config.clone());553554 Ok(nixos_config)555 }556557 pub fn list_defined_secrets(&self) -> Result<Vec<String>> {558 let nixos = self.nixos_unchecked_config()?;559 let secrets = nix_go!(nixos.secrets);560 secrets.list_fields()561 }562563 /// Packages for this host, resolved with nixpkgs overlays564 pub fn pkgs(&self) -> Result<Value> {565 if let Some(value) = &self.pkgs_override {566 return Ok(value.clone());567 }568 let Some(host_config) = &self.host_config else {569 bail!("local host has no host_config");570 };571 // TODO: Should nixos.options be cached?572 Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))573 }574}575576#[derive(Clone)]577pub struct SharedSecretDefinition(Value);578impl SharedSecretDefinition {579 pub fn expected_owners(&self) -> Result<BTreeSet<SecretOwner>> {580 let secret = &self.0;581 Ok(nix_go_json!(secret.expectedOwners))582 }583 pub fn allow_different(&self) -> Result<bool> {584 let secret = &self.0;585 Ok(nix_go_json!(secret.allowDifferent))586 }587 pub fn regenerate_on_owner_added(&self) -> Result<bool> {588 let secret = &self.0;589 Ok(nix_go_json!(secret.regenerateOnOwnerAdded))590 }591 pub fn regenerate_on_owner_removed(&self) -> Result<bool> {592 let secret = &self.0;593 Ok(nix_go_json!(secret.regenerateOnOwnerRemoved))594 }595 pub fn generator(&self) -> Result<Value> {596 let secret = &self.0;597 Ok(nix_go!(secret.generator))598 }599}600601impl Config {602 pub fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {603 let config = &self.config_field;604 let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);605 Ok(tagged)606 }607 pub fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {608 let mut out = BTreeSet::new();609 for owner in owners {610 if let Some(tag) = owner.strip_prefix('@') {611 let hosts = self.tagged_hostnames(tag)?;612 out.extend(hosts);613 } else {614 out.insert(owner);615 }616 }617 Ok(out)618 }619 pub fn local_host(&self) -> ConfigHost {620 ConfigHost {621 config: self.clone(),622 name: "<virtual localhost>".to_owned(),623 host_config: None,624 nixos_config: OnceLock::new(),625 nixos_unchecked_config: OnceLock::new(),626 groups: {627 let cell = OnceLock::new();628 let _ = cell.set(vec![]);629 cell630 },631 pkgs_override: Some(self.default_pkgs.clone()),632633 local: true,634 session: OnceLock::new(),635 deploy_kind: OnceLock::new(),636 session_destination: OnceLock::new(),637 legacy_ssh_store: OnceLock::new(),638 }639 }640641 pub fn host(&self, name: &str) -> Result<ConfigHost> {642 let config = &self.config_field;643 let host_config = nix_go!(config.hosts[{ name }]);644645 Ok(ConfigHost {646 config: self.clone(),647 name: name.to_owned(),648 host_config: Some(host_config),649 nixos_config: OnceLock::new(),650 nixos_unchecked_config: OnceLock::new(),651 groups: OnceLock::new(),652 pkgs_override: None,653654 // TODO: Remove with connectivit refactor655 local: self.localhost == name,656 session: OnceLock::new(),657 deploy_kind: OnceLock::new(),658 session_destination: OnceLock::new(),659 legacy_ssh_store: OnceLock::new(),660 })661 }662 pub fn list_hosts(&self) -> Result<Vec<ConfigHost>> {663 let config = &self.config_field;664 let names = nix_go!(config.hosts).list_fields()?;665 let mut out = vec![];666 for name in names {667 out.push(self.host(&name)?);668 }669 Ok(out)670 }671 // TODO: Replace usages with .host().nixos_config672 pub fn system_config(&self, host: &str) -> Result<Value> {673 let fleet_field = &self.config_field;674 Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))675 }676677 pub fn secret_definition(&self, secret: &str) -> Result<Option<SharedSecretDefinition>> {678 let config = &self.config_field;679 let shared_secrets = nix_go!(config.secrets);680 if !shared_secrets.has_field(secret)? {681 return Ok(None);682 }683 Ok(Some(SharedSecretDefinition(nix_go!(684 shared_secrets[secret]685 ))))686 }687688 pub fn save(&self) -> Result<()> {689 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.")?;690 let data = nixlike::serialize(&*self.data)?;691 tempfile.write_all(692 format!(693 "# This file contains fleet state and shouldn't be edited by hand\n\n{data}\n\n# vim: ts=2 et nowrap\n"694 )695 .as_bytes(),696 )?;697 let mut fleet_data_path = self.directory.clone();698 fleet_data_path.push("fleet.nix");699 tempfile.persist(fleet_data_path)?;700 Ok(())701 }702}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";
};
});