difftreelog
feat expected secret parts
in: trunk
16 files changed
Cargo.lockdiffbeforeafterboth--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1055,6 +1055,7 @@
"shlex",
"tabled",
"tempfile",
+ "thiserror 2.0.17",
"time",
"tokio",
"tokio-util",
@@ -1087,6 +1088,7 @@
"serde_json",
"tabled",
"tempfile",
+ "thiserror 2.0.17",
"time",
"tokio",
"tokio-util",
cmds/fleet/Cargo.tomldiffbeforeafterboth--- a/cmds/fleet/Cargo.toml
+++ b/cmds/fleet/Cargo.toml
@@ -47,6 +47,7 @@
nom = "8.0.0"
opentelemetry = "0.30.0"
opentelemetry_sdk = "0.30.0"
+thiserror.workspace = true
tracing-indicatif = { version = "0.3", optional = true }
tracing-opentelemetry = "0.31.0"
cmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth--- a/cmds/fleet/src/cmds/secrets/mod.rs
+++ b/cmds/fleet/src/cmds/secrets/mod.rs
@@ -2,17 +2,18 @@
collections::{BTreeMap, BTreeSet, HashSet},
io::{self, Read, Write, stdin, stdout},
path::PathBuf,
- slice,
};
-use age::Recipient;
use anyhow::{Context, Result, anyhow, bail, ensure};
use chrono::{DateTime, Utc};
use clap::Parser;
use fleet_base::{
- fleetdata::{FleetSecret, FleetSecretPart, FleetSharedSecret, encrypt_secret_data},
+ fleetdata::{
+ FleetHostSecret, FleetSecretData, FleetSecretPart, FleetSharedSecret, encrypt_secret_data,
+ },
host::Config,
opts::FleetOpts,
+ secret::{Expectations, RegenerationReason, SharedSecretDefinition, secret_needs_regeneration},
};
use fleet_shared::SecretData;
use nix_eval::{NixType, Value, nix_go, nix_go_json};
@@ -144,76 +145,55 @@
#[clap(short = 'p', long, default_value = "secret")]
part: String,
},
-}
-
-fn secret_needs_regeneration(
- secret: &FleetSecret,
- expected_generation_data: &serde_json::Value,
-) -> bool {
- let data_is_expected = secret.generation_data == *expected_generation_data;
- // TODO: Leeway?
- let expired = secret
- .expires_at
- .map(|expiration| expiration < Utc::now())
- .unwrap_or(false);
- expired || !data_is_expected
}
#[allow(clippy::too_many_arguments)]
-#[tracing::instrument(skip(config, secret, field, prefer_identities))]
+#[tracing::instrument(skip(config, secret, definition, prefer_identities))]
async fn maybe_regenerate_shared_secret(
secret_name: &str,
config: &Config,
mut secret: FleetSharedSecret,
- field: Value,
- expected_owners: &[String],
- expected_generation_data: serde_json::Value,
+ definition: SharedSecretDefinition,
prefer_identities: &[String],
+ expectations: &Expectations,
) -> Result<FleetSharedSecret> {
- let original_set = secret.owners.clone();
+ let reason = secret_needs_regeneration(&secret.secret, &secret.owners, expectations);
+ let value = definition.inner();
- let set = original_set.iter().collect::<BTreeSet<_>>();
- let expected_set = expected_owners.iter().collect::<BTreeSet<_>>();
-
- let regeneration_required =
- secret_needs_regeneration(&secret.secret, &expected_generation_data);
-
- if set == expected_set && !regeneration_required {
- info!("no need to update owner list, it is already correct");
- return Ok(secret);
- }
-
- let should_regenerate = if regeneration_required {
- info!("secret has its generation data changed, regeneration is required");
- true
- } else if set.difference(&expected_set).next().is_some() {
- // TODO: Remove this warning for revokable secrets.
- warn!(
- "host was removed from secret owners, but until this host rebuild, the secret will still be stored on it."
- );
- nix_go_json!(field.regenerateOnOwnerRemoved)
- } else if expected_set.difference(&set).next().is_some() {
- nix_go_json!(field.regenerateOnOwnerAdded)
- } else {
- false
+ let (should_reencrypt, reason) = match reason {
+ Some(RegenerationReason::OwnersAdded(_)) => {
+ // Secret always needs to be reencrypted for new owners to be able to read it
+ (
+ true,
+ if nix_go_json!(value.regenerateOnOwnerAdded) {
+ reason
+ } else {
+ None
+ },
+ )
+ }
+ Some(RegenerationReason::OwnersRemoved(_)) => {
+ // No need to reencrypt, we can just leave stanzas in place.
+ if nix_go_json!(value.regenerateOnOwnerRemoved) {
+ (true, reason)
+ } else {
+ (false, None)
+ }
+ }
+ Some(_) => (true, reason),
+ None => (false, None),
};
- if should_regenerate {
- info!("secret needs to be regenerated");
- let generated = generate_shared(
- config,
- secret_name,
- field,
- expected_owners.to_vec(),
- expected_generation_data,
- )
- .await?;
+ if let Some(reason) = reason {
+ info!("secret needs to be regenerated: {reason}");
+ let generated = generate_shared(config, secret_name, definition, expectations).await?;
Ok(generated)
- } else {
+ } else if should_reencrypt {
+ info!("secret needs to be reencrypted");
let identity_holder = if !prefer_identities.is_empty() {
prefer_identities
.iter()
- .find(|i| original_set.iter().any(|s| s == *i))
+ .find(|i| secret.owners.iter().any(|s| s == *i))
} else {
secret.owners.first()
};
@@ -228,12 +208,16 @@
}
let host = config.host(identity_holder).await?;
let encrypted = host
- .reencrypt(part.raw.clone(), expected_owners.to_vec())
+ .reencrypt(
+ part.raw.clone(),
+ expectations.owners.iter().cloned().collect(),
+ )
.await?;
part.raw = encrypted;
}
-
- secret.owners = expected_owners.to_vec();
+ secret.owners = expectations.owners.clone();
+ Ok(secret)
+ } else {
Ok(secret)
}
}
@@ -250,8 +234,8 @@
_display_name: &str,
_secret: Value,
_default_generator: Value,
- _owners: &[String],
-) -> Result<FleetSecret> {
+ _expectations: &Expectations,
+) -> Result<FleetSecretData> {
bail!("pure generators are broken for now")
}
async fn generate_impure(
@@ -259,9 +243,8 @@
_display_name: &str,
secret: Value,
default_generator: Value,
- expected_owners: &[String],
- expected_generation_data: serde_json::Value,
-) -> Result<FleetSecret> {
+ expectations: &Expectations,
+) -> Result<FleetSecretData> {
let generator = nix_go!(secret.generator);
let on: Option<String> = nix_go_json!(default_generator.impureOn);
@@ -276,12 +259,11 @@
let mk_secret_generators = nix_go!(on_pkgs.mkSecretGenerators);
let mut recipients = Vec::new();
- for owner in expected_owners {
+ for owner in &expectations.owners {
let key = config.key(owner).await?;
recipients.push(key);
}
let generators = nix_go!(mk_secret_generators(Obj { recipients }));
- // FIXME: Apparently, // operator is slow in nix
let pkgs_and_generators = on_pkgs.attrs_update(generators)?;
let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));
@@ -331,20 +313,26 @@
let created_at = host.read_file_value(format!("{out}/created_at")).await?;
let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();
- Ok(FleetSecret {
+ let new_data = FleetSecretData {
created_at,
expires_at,
parts,
- generation_data: expected_generation_data,
- })
+ generation_data: expectations.generation_data.clone(),
+ };
+
+ if let Some(reason) = secret_needs_regeneration(&new_data, &expectations.owners, expectations) {
+ bail!("newly generated secret needs to be regenerated: {reason}")
+ }
+
+ Ok(new_data)
}
+
async fn generate(
config: &Config,
display_name: &str,
secret: Value,
- expected_owners: &[String],
- expected_generation_data: serde_json::Value,
-) -> Result<FleetSecret> {
+ expectations: &Expectations,
+) -> Result<FleetSecretData> {
let generator = nix_go!(secret.generator);
// Can't properly check on nix module system level
{
@@ -388,8 +376,7 @@
display_name,
secret,
default_generator,
- expected_owners,
- expected_generation_data,
+ expectations,
)
.await
}
@@ -399,7 +386,7 @@
display_name,
secret,
default_generator,
- expected_owners,
+ expectations,
)
.await
}
@@ -408,21 +395,14 @@
async fn generate_shared(
config: &Config,
display_name: &str,
- secret: Value,
- expected_owners: Vec<String>,
- expected_generation_data: serde_json::Value,
+ secret: SharedSecretDefinition,
+ expectations: &Expectations,
) -> Result<FleetSharedSecret> {
// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);
Ok(FleetSharedSecret {
- secret: generate(
- config,
- display_name,
- secret,
- &expected_owners,
- expected_generation_data,
- )
- .await?,
- owners: expected_owners,
+ managed: Some(true),
+ secret: generate(config, display_name, secret.inner(), expectations).await?,
+ owners: expectations.owners.clone(),
})
}
@@ -457,11 +437,11 @@
}
fn parse_machines(
- initial: Vec<String>,
+ initial: BTreeSet<String>,
machines: Option<Vec<String>>,
mut add_machines: Vec<String>,
mut remove_machines: Vec<String>,
-) -> Result<Vec<String>> {
+) -> Result<BTreeSet<String>> {
if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {
bail!("no operation");
}
@@ -470,7 +450,6 @@
let mut target_machines = initial;
info!("Currently encrypted for {initial_machines:?}");
- // ensure!(machines.is_some() || !add_machines.is_empty() || )
if let Some(machines) = machines {
ensure!(
add_machines.is_empty() && remove_machines.is_empty(),
@@ -487,20 +466,13 @@
}
for machine in &remove_machines {
- let mut removed = false;
- while let Some(pos) = target_machines.iter().position(|m| m == machine) {
- target_machines.swap_remove(pos);
- removed = true;
- }
- if !removed {
+ if !target_machines.remove(machine) {
warn!("secret is not enabled for {machine}");
}
}
for machine in &add_machines {
- if target_machines.iter().any(|m| m == machine) {
+ if !target_machines.insert(machine.to_owned()) {
warn!("secret is already added to {machine}");
- } else {
- target_machines.push(machine.to_owned());
}
}
if !remove_machines.is_empty() {
@@ -527,7 +499,7 @@
}
}
Secret::AddShared {
- mut machines,
+ machines,
name,
force,
public,
@@ -537,25 +509,32 @@
re_add,
part: part_name,
} => {
+ let mut machines: BTreeSet<String> = machines.into_iter().collect();
// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).
- let exists = config.has_shared(&name);
- if exists && !force && !re_add {
- bail!("secret already defined");
- }
- if re_add {
- // Fixme: use clap to limit this usage
- ensure!(!force, "--force and --readd are not compatible");
- ensure!(exists, "secret doesn't exists");
- ensure!(
- machines.is_empty(),
- "you can't use machines argument for --readd"
- );
- let shared = config.shared_secret(&name)?;
- machines = shared.owners;
- }
+ if let Some(old_shared) = config.shared_secret(&name)? {
+ if !force && !re_add {
+ bail!("secret already defined");
+ };
+ if old_shared.managed.unwrap_or(false) {
+ bail!("secret is marked as managed, should not be updated manually");
+ };
+ if re_add {
+ // Fixme: use clap to limit this usage
+ ensure!(!force, "--force and --readd are not compatible");
+ ensure!(
+ machines.is_empty(),
+ "you can't use machines argument for --readd"
+ );
+ machines = old_shared.owners;
+ }
+ } else if re_add {
+ bail!("secret doesn't exists");
+ };
- let recipients = config.recipients(machines.clone()).await?;
+ let recipients = config
+ .recipients(machines.iter().cloned().collect())
+ .await?;
let mut parts = BTreeMap::new();
@@ -563,9 +542,8 @@
io::stdin().read_to_end(&mut input)?;
if !input.is_empty() {
- let encrypted =
- encrypt_secret_data(recipients.iter().map(|r| r as &dyn Recipient), input)
- .ok_or_else(|| anyhow!("no recipients provided"))?;
+ let encrypted = encrypt_secret_data(recipients.iter(), input)
+ .ok_or_else(|| anyhow!("no recipients provided"))?;
parts.insert(part_name, FleetSecretPart { raw: encrypted });
}
@@ -576,8 +554,9 @@
config.replace_shared(
name,
FleetSharedSecret {
+ managed: Some(false),
owners: machines,
- secret: FleetSecret {
+ secret: FleetSecretData {
created_at: Utc::now(),
expires_at,
parts,
@@ -607,34 +586,47 @@
.host_secret(&machine, &name)
.context("failed to read existing secret for --merge")?
} else {
- FleetSecret {
- created_at: Utc::now(),
- expires_at: None,
- parts: BTreeMap::new(),
- generation_data: serde_json::Value::Null,
+ FleetHostSecret {
+ managed: Some(false),
+ secret: FleetSecretData {
+ created_at: Utc::now(),
+ expires_at: None,
+ parts: BTreeMap::new(),
+ generation_data: serde_json::Value::Null,
+ },
}
};
+ if out.managed.unwrap_or(false) {
+ bail!("secret is managed by fleet and should not be updated manually");
+ }
+ out.managed = Some(false);
if let Some(secret) = parse_secret().await? {
let recipient = config.recipient(&machine).await?;
- let encrypted = encrypt_secret_data([&recipient as &dyn Recipient], secret)
- .expect("recipient provided");
+ let encrypted =
+ encrypt_secret_data([&recipient], secret).expect("recipient provided");
if out
+ .secret
.parts
.insert(part_name.clone(), FleetSecretPart { raw: encrypted })
.is_some() && !replace
{
- bail!("part {part_name:?} is already defined");
+ bail!(
+ "part {part_name:?} is already defined, use --replace if you wish to replace it"
+ );
}
}
if let Some(public) = parse_public(public, public_file).await? {
if out
+ .secret
.parts
.insert(public_name.clone(), FleetSecretPart { raw: public })
.is_some() && !replace
{
- bail!("part {public_name:?} is already defined");
+ bail!(
+ "part {public_name:?} is already defined, use --replace if you wish to replace it"
+ );
}
};
@@ -647,7 +639,7 @@
part: part_name,
} => {
let secret = config.host_secret(&machine, &name)?;
- let Some(secret) = secret.parts.get(&part_name) else {
+ let Some(secret) = secret.secret.parts.get(&part_name) else {
bail!("no part {part_name} in secret {name}");
};
let data = if secret.raw.encrypted {
@@ -664,7 +656,9 @@
part: part_name,
prefer_identities,
} => {
- let secret = config.shared_secret(&name)?;
+ let Some(secret) = config.shared_secret(&name)? else {
+ bail!("secret doesn't exists");
+ };
let Some(part) = secret.secret.parts.get(&part_name) else {
bail!("no part {part_name} in secret {name}");
};
@@ -695,7 +689,9 @@
} => {
// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).
- let secret = config.shared_secret(&name)?;
+ let Some(secret) = config.shared_secret(&name)? else {
+ bail!("secret doesn't exists");
+ };
if secret.secret.parts.values().all(|v| !v.raw.encrypted) {
bail!("no secret");
}
@@ -714,20 +710,16 @@
return Ok(());
}
- let config_field = &config.config_field;
- let name_clone = name.clone();
- let field = nix_go!(config_field.sharedSecrets[name_clone]);
- let expected_generation_data = nix_go_json!(field.expectedGenerationData);
+ let definition = config.shared_secret_definition(&name)?;
+ let expectations = definition.expectations()?;
let updated = maybe_regenerate_shared_secret(
&name,
config,
secret,
- field,
- &target_machines,
- expected_generation_data,
+ definition,
&prefer_identities,
- // None,
+ &expectations,
)
.await?;
config.replace_shared(name, updated);
@@ -737,36 +729,26 @@
skip_hosts,
} => {
info!("checking for secrets to regenerate");
+ let expected_shared_set = config
+ .list_configured_shared()
+ .await?
+ .into_iter()
+ .collect::<HashSet<_>>();
let stored_shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();
{
// Generate missing shared
let _span = info_span!("shared").entered();
- let expected_shared_set = config
- .list_configured_shared()
- .await?
- .into_iter()
- .collect::<HashSet<_>>();
for missing in expected_shared_set.difference(&stored_shared_set) {
- let config_field = &config.config_field;
- let secret = nix_go!(config_field.sharedSecrets[{ missing }]);
- let expected_generation_data: serde_json::Value =
- nix_go_json!(secret.expectedGenerationData);
- let expected_owners: Option<Vec<String>> =
- nix_go_json!(secret.expectedOwners);
- let Some(expected_owners) = expected_owners else {
- // Can't generate this missing secret, as it has no defined owners.
+ let definition = config.shared_secret_definition(missing)?;
+ if !definition.is_managed()? {
+ info!("skipping unmanaged secret: {missing}");
continue;
- };
+ }
+ let expectations = definition.expectations()?;
info!("generating secret: {missing}");
- let shared = generate_shared(
- config,
- missing,
- secret,
- expected_owners,
- expected_generation_data,
- )
- .in_current_span()
- .await?;
+ let shared = generate_shared(config, missing, definition, &expectations)
+ .in_current_span()
+ .await?;
config.replace_shared(missing.to_string(), shared)
}
}
@@ -778,26 +760,22 @@
let _span = info_span!("host", host = host.name).entered();
let expected_set = host
- .list_configured_secrets()
- .in_current_span()
- .await?
+ .list_defined_secrets()?
.into_iter()
.collect::<HashSet<_>>();
let stored_set = config
.list_secrets(&host.name)
.into_iter()
.collect::<HashSet<_>>();
- for missing in expected_set.difference(&stored_set) {
- info!("generating secret: {missing}");
- let secret = host.secret_field(missing).in_current_span().await?;
- let expected_generation_data =
- nix_go_json!(secret.expectedGenerationData);
+ for missing_secret in expected_set.difference(&stored_set) {
+ info!("generating missing secret: {missing_secret}");
+ let definition = host.secret_definition(missing_secret)?;
+ let expectations = definition.expectations()?;
let generated = match generate(
config,
- missing,
- secret,
- slice::from_ref(&host.name),
- expected_generation_data,
+ missing_secret,
+ definition.inner(),
+ &expectations,
)
.in_current_span()
.await
@@ -808,21 +786,27 @@
continue;
}
};
- config.insert_secret(&host.name, missing.to_string(), generated)
+ config.insert_secret(
+ &host.name,
+ missing_secret.to_string(),
+ FleetHostSecret {
+ managed: Some(true),
+ secret: generated,
+ },
+ )
}
- for name in stored_set {
- info!("updating secret: {name}");
- let data = config.host_secret(&host.name, &name)?;
- let secret = host.secret_field(&name).in_current_span().await?;
- let expected_generation_data =
- nix_go_json!(secret.expectedGenerationData);
- if secret_needs_regeneration(&data, &expected_generation_data) {
+ for known_secret in stored_set.intersection(&expected_set) {
+ info!("updating secret: {known_secret}");
+ let data = config.host_secret(&host.name, known_secret)?;
+ let definition = host.secret_definition(known_secret)?;
+ let expectations = definition.expectations()?;
+ if let Some(regen_reason) = data.needs_regeneration(&expectations) {
+ info!("needs regeneration: {regen_reason}");
let generated = match generate(
config,
- &name,
- secret,
- slice::from_ref(&host.name),
- expected_generation_data,
+ known_secret,
+ definition.inner(),
+ &expectations,
)
.in_current_span()
.await
@@ -833,43 +817,44 @@
continue;
}
};
- config.insert_secret(&host.name, name.to_string(), generated)
+ config.insert_secret(
+ &host.name,
+ known_secret.to_string(),
+ FleetHostSecret {
+ managed: Some(true),
+ secret: generated,
+ },
+ )
}
}
+ for removed_secret in stored_set.difference(&expected_set) {
+ info!("removing secret: {removed_secret}");
+ config.remove_secret(&host.name, removed_secret);
+ }
}
}
- let mut to_remove = Vec::new();
- for name in &stored_shared_set {
- info!("updating secret: {name}");
- let data = config.shared_secret(name)?;
- let config_field = &config.config_field;
- let expected_owners: Option<Vec<String>> =
- nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);
- let Some(expected_owners) = expected_owners else {
- warn!("secret was removed from fleet config: {name}, removing from data");
- to_remove.push(name.to_string());
- continue;
- };
+ for known_secret in stored_shared_set.intersection(&expected_shared_set) {
+ info!("updating shared secret: {known_secret}");
+ let data = config.shared_secret(known_secret)?.expect("exists");
- let secret = nix_go!(config_field.sharedSecrets[{ name }]);
- let expected_generation_data = nix_go_json!(secret.expectedGenerationData);
+ let definition = config.shared_secret_definition(known_secret)?;
+ let expectations = definition.expectations()?;
config.replace_shared(
- name.to_owned(),
+ known_secret.to_owned(),
maybe_regenerate_shared_secret(
- name,
+ known_secret,
config,
data,
- secret,
- &expected_owners,
- expected_generation_data,
+ definition,
&prefer_identities,
- // None,
+ &expectations,
)
.await?,
);
}
- for k in to_remove {
- config.remove_shared(&k);
+ for removed_secret in stored_shared_set.difference(&expected_shared_set) {
+ info!("removing shared secret: {removed_secret}");
+ config.remove_shared(removed_secret);
}
}
Secret::List {} => {
@@ -885,13 +870,14 @@
let mut table = vec![];
for name in configured.iter().cloned() {
let config = config.clone();
- let expected_owners = config.shared_secret_expected_owners(&name).await?;
- let data = config.shared_secret(&name)?;
+ let data = config.shared_secret(&name)?.expect("exists");
+ let definition = config.shared_secret_definition(&name)?;
+ let expectations = definition.expectations()?;
let owners = data
.owners
.iter()
.map(|o| {
- if expected_owners.contains(o) {
+ if expectations.owners.contains(o) {
o.green().to_string()
} else {
o.red().to_string()
@@ -912,7 +898,7 @@
add,
} => {
let secret = config.host_secret(&machine, &name)?;
- if let Some(data) = secret.parts.get(&part) {
+ if let Some(data) = secret.secret.parts.get(&part) {
let host = config.host(&machine).await?;
let secret = host.decrypt(data.raw.clone()).await?;
String::from_utf8(secret).context("secret is not utf8")?
cmds/fleet/src/main.rsdiffbeforeafterboth--- a/cmds/fleet/src/main.rs
+++ b/cmds/fleet/src/main.rs
@@ -27,7 +27,7 @@
use tracing::{Instrument, error, info, info_span};
#[cfg(feature = "indicatif")]
use tracing_indicatif::IndicatifLayer;
-use tracing_subscriber::{EnvFilter, fmt::format::Format, prelude::*};
+use tracing_subscriber::{EnvFilter, prelude::*};
#[derive(Parser)]
struct Prefetch {}
crates/fleet-base/Cargo.tomldiffbeforeafterboth--- a/crates/fleet-base/Cargo.toml
+++ b/crates/fleet-base/Cargo.toml
@@ -24,6 +24,7 @@
serde_json = "1.0.140"
tabled = "0.20.0"
tempfile.workspace = true
+thiserror.workspace = true
time = { version = "0.3.41", features = ["parsing"] }
tokio.workspace = true
tokio-util = "0.7.15"
crates/fleet-base/src/fleetdata.rsdiffbeforeafterboth--- a/crates/fleet-base/src/fleetdata.rs
+++ b/crates/fleet-base/src/fleetdata.rs
@@ -1,5 +1,5 @@
use std::{
- collections::BTreeMap,
+ collections::{BTreeMap, BTreeSet},
io::{self, Cursor},
};
@@ -13,6 +13,8 @@
use serde::{Deserialize, Serialize, de::Error};
use serde_json::Value;
+use crate::secret::{Expectations, RegenerationReason, secret_needs_regeneration};
+
#[derive(Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct HostData {
@@ -75,30 +77,21 @@
pub shared_secrets: BTreeMap<String, FleetSharedSecret>,
#[serde(default)]
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
- pub host_secrets: BTreeMap<String, BTreeMap<String, FleetSecret>>,
+ pub host_secrets: BTreeMap<String, BTreeMap<String, FleetHostSecret>>,
// extra_name => anything
#[serde(default)]
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, Value>,
-}
-
-#[derive(Serialize, Deserialize, Clone)]
-#[serde(rename_all = "camelCase")]
-#[must_use]
-pub struct FleetSharedSecret {
- pub owners: Vec<String>,
- #[serde(flatten)]
- pub secret: FleetSecret,
}
/// Returns None if recipients.is_empty()
-pub fn encrypt_secret_data<'a>(
- recipients: impl IntoIterator<Item = &'a dyn Recipient>,
+pub fn encrypt_secret_data<'r>(
+ recipients: impl IntoIterator<Item = &'r Box<dyn Recipient>>,
data: Vec<u8>,
) -> Option<SecretData> {
let mut encrypted = vec![];
- let mut encryptor = age::Encryptor::with_recipients(recipients.into_iter())
+ let mut encryptor = age::Encryptor::with_recipients(recipients.into_iter().map(|v| &**v))
.ok()?
.wrap_output(&mut encrypted)
.expect("in memory write");
@@ -118,7 +111,7 @@
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[must_use]
-pub struct FleetSecret {
+pub struct FleetSecretData {
#[serde(default = "Utc::now")]
pub created_at: DateTime<Utc>,
#[serde(default)]
@@ -132,3 +125,31 @@
#[serde(skip_serializing_if = "Value::is_null")]
pub generation_data: Value,
}
+
+#[derive(Serialize, Deserialize, Clone)]
+#[serde(rename_all = "camelCase")]
+#[must_use]
+pub struct FleetHostSecret {
+ #[serde(default)]
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub managed: Option<bool>,
+ #[serde(flatten)]
+ pub secret: FleetSecretData,
+}
+impl FleetHostSecret {
+ pub fn needs_regeneration(&self, expectations: &Expectations) -> Option<RegenerationReason> {
+ secret_needs_regeneration(&self.secret, &expectations.owners, expectations)
+ }
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+#[serde(rename_all = "camelCase")]
+#[must_use]
+pub struct FleetSharedSecret {
+ #[serde(default)]
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub managed: Option<bool>,
+ pub owners: BTreeSet<String>,
+ #[serde(flatten)]
+ pub secret: FleetSecretData,
+}
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, FleetSecret, FleetSharedSecret},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: 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}474impl ConfigHost {475 // TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,476 // assuming getting tags always returns the same value.477 pub async fn tags(&self) -> Result<Vec<String>> {478 if let Some(v) = self.groups.get() {479 return Ok(v.clone());480 }481 let Some(host_config) = &self.host_config else {482 return Ok(vec![]);483 };484 let tags: Vec<String> = nix_go_json!(host_config.tags);485486 let _ = self.groups.set(tags.clone());487488 Ok(tags)489 }490 pub async fn nixos_config(&self) -> Result<Value> {491 if let Some(v) = self.nixos_config.get() {492 return Ok(v.clone());493 }494 let Some(host_config) = &self.host_config else {495 bail!("local host has no nixos_config");496 };497 let nixos_config = nix_go!(host_config.nixos.config);498 assert_warn("nixos config evaluation", &nixos_config).await?;499500 let _ = self.nixos_config.set(nixos_config.clone());501502 Ok(nixos_config)503 }504 pub async fn nixos_unchecked_config(&self) -> Result<Value> {505 if let Some(v) = self.nixos_unchecked_config.get() {506 return Ok(v.clone());507 }508 let Some(host_config) = &self.host_config else {509 bail!("local host has no nixos_config");510 };511 let nixos_config = nix_go!(host_config.nixos_unchecked.config);512513 let _ = self.nixos_unchecked_config.set(nixos_config.clone());514515 Ok(nixos_config)516 }517518 pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {519 let nixos = self.nixos_unchecked_config().await?;520 let secrets = nix_go!(nixos.secrets);521 let mut out = Vec::new();522 for name in secrets.list_fields()? {523 let secret = secrets.get_field(&name).context("getting secret")?;524 let is_shared: bool = nix_go_json!(secret.shared);525 if is_shared {526 continue;527 }528 out.push(name);529 }530 Ok(out)531 }532 pub async fn secret_field(&self, name: &str) -> Result<Value> {533 let nixos = self.nixos_unchecked_config().await?;534 Ok(nix_go!(nixos.secrets[{ name }]))535 }536537 /// Packages for this host, resolved with nixpkgs overlays538 pub async fn pkgs(&self) -> Result<Value> {539 if let Some(value) = &self.pkgs_override {540 return Ok(value.clone());541 }542 let Some(host_config) = &self.host_config else {543 bail!("local host has no host_config");544 };545 // TODO: Should nixos.options be cached?546 Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))547 }548}549550impl Config {551 pub async fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {552 let config = &self.config_field;553 let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);554 Ok(tagged)555 }556 pub async fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {557 let mut out = BTreeSet::new();558 for owner in owners {559 if let Some(tag) = owner.strip_prefix('@') {560 let hosts = self.tagged_hostnames(tag).await?;561 out.extend(hosts);562 } else {563 out.insert(owner);564 }565 }566 Ok(out)567 }568 pub fn local_host(&self) -> ConfigHost {569 ConfigHost {570 config: self.clone(),571 name: "<virtual localhost>".to_owned(),572 host_config: None,573 nixos_config: OnceCell::new(),574 nixos_unchecked_config: OnceCell::new(),575 groups: {576 let cell = OnceCell::new();577 let _ = cell.set(vec![]);578 cell579 },580 pkgs_override: Some(self.default_pkgs.clone()),581582 local: true,583 session: OnceLock::new(),584 deploy_kind: OnceCell::new(),585 session_destination: OnceCell::new(),586 legacy_ssh_store: OnceCell::new(),587 }588 }589590 pub async fn host(&self, name: &str) -> Result<ConfigHost> {591 let config = &self.config_field;592 let host_config = nix_go!(config.hosts[{ name }]);593594 Ok(ConfigHost {595 config: self.clone(),596 name: name.to_owned(),597 host_config: Some(host_config),598 nixos_config: OnceCell::new(),599 nixos_unchecked_config: OnceCell::new(),600 groups: OnceCell::new(),601 pkgs_override: None,602603 // TODO: Remove with connectivit refactor604 local: self.localhost == name,605 session: OnceLock::new(),606 deploy_kind: OnceCell::new(),607 session_destination: OnceCell::new(),608 legacy_ssh_store: OnceCell::new(),609 })610 }611 pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {612 let config = &self.config_field;613 let names = nix_go!(config.hosts).list_fields()?;614 let mut out = vec![];615 for name in names {616 out.push(self.host(&name).await?);617 }618 Ok(out)619 }620 // TODO: Replace usages with .host().nixos_config621 pub async fn system_config(&self, host: &str) -> Result<Value> {622 let fleet_field = &self.config_field;623 Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))624 }625626 /// Shared secrets configured in fleet.nix or in flake627 pub async fn list_configured_shared(&self) -> Result<Vec<String>> {628 let config_field = &self.config_field;629 nix_go!(config_field.sharedSecrets).list_fields()630 }631 /// Shared secrets configured in fleet.nix632 pub fn list_shared(&self) -> Vec<String> {633 let data = self.data();634 data.shared_secrets.keys().cloned().collect()635 }636 pub fn has_shared(&self, name: &str) -> bool {637 let data = self.data();638 data.shared_secrets.contains_key(name)639 }640 pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {641 let mut data = self.data_mut();642 data.shared_secrets.insert(name.to_owned(), shared);643 }644 pub fn remove_shared(&self, secret: &str) {645 let mut data = self.data_mut();646 data.shared_secrets.remove(secret);647 }648649 pub fn list_secrets(&self, host: &str) -> Vec<String> {650 let data = self.data();651 let Some(secrets) = data.host_secrets.get(host) else {652 return Vec::new();653 };654 secrets.keys().cloned().collect()655 }656657 pub fn has_secret(&self, host: &str, secret: &str) -> bool {658 let data = self.data();659 let Some(host_secrets) = data.host_secrets.get(host) else {660 return false;661 };662 host_secrets.contains_key(secret)663 }664 pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {665 let mut data = self.data_mut();666 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();667 host_secrets.insert(secret, value);668 }669670 pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {671 let data = self.data();672 let Some(host_secrets) = data.host_secrets.get(host) else {673 bail!("no secrets for machine {host}");674 };675 let Some(secret) = host_secrets.get(secret) else {676 bail!("machine {host} has no secret {secret}");677 };678 Ok(secret.clone())679 }680 pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {681 let data = self.data();682 let Some(secret) = data.shared_secrets.get(secret) else {683 bail!("no shared secret {secret}");684 };685 Ok(secret.clone())686 }687 pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {688 let config_field = &self.config_field;689 Ok(nix_go_json!(690 config_field.sharedSecrets[{ secret }].expectedOwners691 ))692 }693694 // TODO: Should this be something modifiable from other processes?695 // E.g terraform provider might want to update FleetData (e.g secrets),696 // and current implementation assumes only one process holds current fleet.nix697 // Given that it is no longer needs to be a file for nix evaluation,698 // maybe it can be a .nix file for persistence, but accessible only699 // thru some shared state controller? Might it be stored in terraform700 // state provider?701 pub fn data(&'_ self) -> MutexGuard<'_, FleetData> {702 self.data.lock().unwrap()703 }704 pub fn data_mut(&'_ self) -> MutexGuard<'_, FleetData> {705 self.data.lock().unwrap()706 }707 pub fn save(&self) -> Result<()> {708 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.")?;709 let data = nixlike::serialize(&self.data() as &FleetData)?;710 tempfile.write_all(711 format!(712 "# This file contains fleet state and shouldn't be edited by hand\n\n{data}\n\n# vim: ts=2 et nowrap\n"713 )714 .as_bytes(),715 )?;716 let mut fleet_data_path = self.directory.clone();717 fleet_data_path.push("fleet.nix");718 tempfile.persist(fleet_data_path)?;719 Ok(())720 }721}crates/fleet-base/src/keys.rsdiffbeforeafterboth--- a/crates/fleet-base/src/keys.rs
+++ b/crates/fleet-base/src/keys.rs
@@ -39,12 +39,14 @@
}
}
/// Insecure, requires root
- pub async fn recipient(&self, host: &str) -> anyhow::Result<impl Recipient + use<>> {
+ pub async fn recipient(&self, host: &str) -> 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))
+ 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<impl Recipient + use<>>> {
+ pub async fn recipients(&self, hosts: Vec<String>) -> Result<Vec<Box<dyn Recipient>>> {
let hosts = self.expand_owner_set(hosts).await?;
futures::stream::iter(hosts.iter())
.then(|m| self.recipient(m.as_ref()))
crates/fleet-base/src/lib.rsdiffbeforeafterboth--- a/crates/fleet-base/src/lib.rs
+++ b/crates/fleet-base/src/lib.rs
@@ -4,3 +4,4 @@
pub mod host;
mod keys;
pub mod opts;
+pub mod secret;
crates/fleet-base/src/secret.rsdiffbeforeafterboth--- /dev/null
+++ b/crates/fleet-base/src/secret.rs
@@ -0,0 +1,136 @@
+use std::collections::BTreeSet;
+
+use anyhow::Result;
+use chrono::{DateTime, Utc};
+use nix_eval::{Value, nix_go, nix_go_json};
+
+use crate::fleetdata::FleetSecretData;
+
+#[derive(Debug)]
+pub struct Expectations {
+ pub owners: BTreeSet<String>,
+ pub generation_data: serde_json::Value,
+ pub public_parts: BTreeSet<String>,
+ pub private_parts: BTreeSet<String>,
+}
+
+pub struct HostSecretDefinition(pub(crate) String, pub(crate) Value);
+impl HostSecretDefinition {
+ pub fn is_managed(&self) -> Result<bool> {
+ let value = &self.1;
+ Ok(!nix_go!(value.generator).is_null())
+ }
+ pub fn expectations(&self) -> Result<Expectations> {
+ let value = &self.1;
+ Ok(Expectations {
+ owners: BTreeSet::from([self.0.clone()]),
+ generation_data: nix_go_json!(value.expectedGenerationData),
+ public_parts: nix_go_json!(value.expectedPublicParts),
+ private_parts: nix_go_json!(value.expectedPrivateParts),
+ })
+ }
+ pub fn inner(&self) -> Value {
+ self.1.clone()
+ }
+}
+
+pub struct SharedSecretDefinition(pub(crate) Value);
+impl SharedSecretDefinition {
+ pub fn is_managed(&self) -> Result<bool> {
+ let value = &self.0;
+ Ok(!nix_go!(value.generator).is_null())
+ }
+ pub fn expectations(&self) -> Result<Expectations> {
+ let value = &self.0;
+ Ok(Expectations {
+ owners: nix_go_json!(value.expectedOwners),
+ generation_data: nix_go_json!(value.expectedGenerationData),
+ public_parts: nix_go_json!(value.expectedPublicParts),
+ private_parts: nix_go_json!(value.expectedPrivateParts),
+ })
+ }
+ pub fn inner(&self) -> Value {
+ self.0.clone()
+ }
+}
+
+#[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>),
+}
+
+pub fn secret_needs_regeneration(
+ secret: &FleetSecretData,
+ owners: &BTreeSet<String>,
+ expectations: &Expectations,
+) -> Option<RegenerationReason> {
+ if !owners.is_empty() {
+ let added: BTreeSet<String> = expectations.owners.difference(owners).cloned().collect();
+ if !added.is_empty() {
+ return Some(RegenerationReason::OwnersAdded(added));
+ }
+
+ let removed: BTreeSet<String> = owners.difference(&expectations.owners).cloned().collect();
+ if !removed.is_empty() {
+ return Some(RegenerationReason::OwnersRemoved(removed));
+ }
+ }
+
+ if secret.generation_data != expectations.generation_data {
+ return Some(RegenerationReason::GenerationData {
+ expected: expectations.generation_data.clone(),
+ found: secret.generation_data.clone(),
+ });
+ }
+
+ if !expectations.public_parts.is_empty() || !expectations.private_parts.is_empty() {
+ let expected: BTreeSet<String> = expectations
+ .public_parts
+ .union(&expectations.private_parts)
+ .cloned()
+ .collect();
+ let found: BTreeSet<String> = secret.parts.keys().cloned().collect();
+
+ if found != expected {
+ return Some(RegenerationReason::PartList { expected, found });
+ }
+
+ for (name, value) in secret.parts.iter() {
+ if value.raw.encrypted {
+ if !expectations.private_parts.contains(name) {
+ return Some(RegenerationReason::ExpectedPrivate(name.clone()));
+ }
+ } else if !expectations.public_parts.contains(name) {
+ return Some(RegenerationReason::ExpectedPublic(name.clone()));
+ }
+ }
+ }
+
+ if let Some(expiration) = secret.expires_at {
+ // TODO: Leeway?
+ if expiration < Utc::now() {
+ return Some(RegenerationReason::Expired(expiration));
+ }
+ }
+
+ None
+}
crates/nix-eval/src/lib.rsdiffbeforeafterboth--- a/crates/nix-eval/src/lib.rs
+++ b/crates/nix-eval/src/lib.rs
@@ -729,10 +729,13 @@
with_default_context(|c, es| unsafe { get_list_byidx(c, self.0, es, v as u32) }).map(Self)
}
- pub fn attrs_update(self, other: Value/*, ignore_errors: bool*/) -> Result<Self> {
+ pub fn attrs_update(self, other: Value /*, ignore_errors: bool*/) -> Result<Self> {
let attrs_update_fn = Self::eval("a: b: a // b")?;
- attrs_update_fn.call(self)?.call(other).context("attrs update")
+ attrs_update_fn
+ .call(self)?
+ .call(other)
+ .context("attrs update")
}
pub fn get_field(&self, name: impl AsFieldName) -> Result<Self> {
if !matches!(self.type_of(), NixType::Attrs) {
@@ -840,6 +843,9 @@
pub fn is_function(&self) -> bool {
self.functor_kind().is_some()
}
+ pub fn is_null(&self) -> bool {
+ matches!(self.type_of(), NixType::Null)
+ }
}
impl From<String> for Value {
flake.nixdiffbeforeafterboth--- a/flake.nix
+++ b/flake.nix
@@ -181,7 +181,7 @@
inputs'.nix.packages.nix-fetchers-c
inputs'.nix.packages.nix-store-c
- (rage.overrideAttrs {cargoFeatures = ["plugin"];})
+ (rage.overrideAttrs { cargoFeatures = [ "plugin" ]; })
];
environment.PROTOC = "${pkgs.protobuf}/bin/protoc";
};
modules/extras/tf.nixdiffbeforeafterboth--- a/modules/extras/tf.nix
+++ b/modules/extras/tf.nix
@@ -38,17 +38,19 @@
# will be somehow processed by fleet tf.
sensitive = true;
};
- fleetConfigurations.default = {config, ...}: {
- options.data = mkDataOption {
- # host => hostData
- options.extra.terraformHosts = mkOption {
- default = { };
- type = attrsOf (attrsOf unspecified);
- description = "Hosts data provided by fleet tf";
+ fleetConfigurations.default =
+ { config, ... }:
+ {
+ options.data = mkDataOption {
+ # host => hostData
+ options.extra.terraformHosts = mkOption {
+ default = { };
+ type = attrsOf (attrsOf unspecified);
+ description = "Hosts data provided by fleet tf";
+ };
};
+ config.hosts = config.data.extra.terraformHosts;
};
- config.hosts = config.data.extra.terraformHosts;
- };
perSystem.imports = [ ./tf-bootstrap.nix ];
};
modules/nixos/secrets.nixdiffbeforeafterboth--- a/modules/nixos/secrets.nix
+++ b/modules/nixos/secrets.nix
@@ -6,11 +6,11 @@
...
}:
let
- inherit (builtins) hashString;
+ inherit (builtins) hashString elemAt length toJSON filter;
inherit (lib.stringsWithDeps) stringAfter;
inherit (lib.options) mkOption literalExpression;
inherit (lib.lists) optional;
- inherit (lib.attrsets) mapAttrs;
+ inherit (lib.attrsets) mapAttrs mapAttrsToList;
inherit (lib.modules) mkIf;
inherit (lib.types)
submodule
@@ -22,10 +22,29 @@
uniq
functionTo
package
+ listOf
;
inherit (fleetLib.strings) decodeRawSecret;
sysConfig = config;
+ secretPartDataType = submodule {
+ options = {
+ raw = mkOption {
+ type = str;
+ internal = true;
+ description = "Encoded & Encrypted secret part data, passed from fleet.nix";
+ };
+ };
+ };
+ secretDataType = submodule {
+ freeformType = lazyAttrsOf secretPartDataType;
+ options = {
+ shared = mkOption {
+ description = "Is this secret owned by this machine, or propagated from shared secrets";
+ default = false;
+ };
+ };
+ };
secretPartType =
secretName:
submodule (
@@ -35,11 +54,6 @@
in
{
options = {
- raw = mkOption {
- type = str;
- internal = true;
- description = "Encoded & Encrypted secret part data, passed from fleet.nix";
- };
hash = mkOption {
type = str;
description = "Hash of secret in encoded format";
@@ -50,34 +64,50 @@
};
stablePath = mkOption {
type = str;
- description = "Path to secret part, incorporating data hash (thus it will be updated on secret change)";
+ description = "Path to secret part, stable path (users are expected to watch for file changes/re-read secret on demand)";
};
data = mkOption {
type = str;
description = "Secret public data (only available for plaintext)";
};
};
- config = {
- hash = hashString "sha1" config.raw;
- data = decodeRawSecret config.raw;
- path = "/run/secrets/${secretName}/${config.hash}-${partName}";
- stablePath = "/run/secrets/${secretName}/${partName}";
- };
+ config =
+ let
+ raw = sysConfig.data.secrets.${secretName}.${partName}.raw;
+ in
+ {
+ hash = hashString "sha1" raw;
+ data = decodeRawSecret raw;
+ path = "/run/secrets/${secretName}/${config.hash}-${partName}";
+ stablePath = "/run/secrets/${secretName}/${partName}";
+ };
}
);
secretType = submodule (
- { config, ... }:
+ {
+ config,
+ loc,
+ options,
+ ...
+ }:
let
- secretName = config._module.args.name;
+ secretName =
+ # Due to config definition for freeformType, we can't just use _module.args due to infinite recursion, instead
+ # extract the secret name the ugly way...
+ let
+ saLoc = options._module.specialArgs.loc;
+ comp = elemAt saLoc;
+ in
+ assert
+ (length saLoc == 2 ||
+ length saLoc == 4 &&
+ comp 0 == "secrets" && comp 2 == "_module" && comp 3 == "specialArgs") ||
+ throw "Unexpected module structure ${toJSON saLoc}";
+ if length saLoc == 2 then "documentation generator stub" else comp 1;
in
{
freeformType = lazyAttrsOf (secretPartType secretName);
options = {
- shared = mkOption {
- description = "Is this secret owned by this machine, or propagated from shared secrets";
- default = false;
- };
-
generator = mkOption {
type = uniq (nullOr (functionTo package));
description = "Derivation to evaluate for secret generation";
@@ -104,18 +134,30 @@
description = "Data that gets embedded into secret part";
default = null;
};
+ expectedPrivateParts = mkOption {
+ type = listOf str;
+ default = [ ];
+ description = "List of parts that are expected to be encrypted";
+ };
+ expectedPublicParts = mkOption {
+ type = listOf str;
+ default = [ ];
+ description = "List of parts that are expected to be public";
+ };
};
+ config = mapAttrs (_: _: { }) (removeAttrs (sysConfig.data.secrets.${secretName} or {}) [ "shared" ]);
}
);
- processPart = part: {
- inherit (part) raw path stablePath;
+ processPart = secretName: partName: part: {
+ inherit (part) path stablePath;
+ raw = config.data.secrets.${secretName}.${partName}.raw;
};
processSecret =
- secret:
+ secretName: secret:
{
inherit (secret) group mode owner;
}
- // (mapAttrs (_: processPart) (
+ // (mapAttrs (processPart secretName) (
removeAttrs secret [
"shared"
"generator"
@@ -123,11 +165,14 @@
"group"
"owner"
"expectedGenerationData"
+ "expectedPrivateParts"
+ "expectedPublicParts"
]
));
+ secretsData = (mapAttrs (processSecret) config.secrets);
secretsFile = pkgs.writeTextFile {
name = "secrets.json";
- text = builtins.toJSON (mapAttrs (_: processSecret) config.secrets);
+ text = toJSON secretsData;
};
useSysusers =
(config.systemd ? sysusers && config.systemd.sysusers.enable)
@@ -135,15 +180,38 @@
in
{
options = {
+ data.secrets = mkOption {
+ type = attrsOf secretDataType;
+ default = { };
+ description = "Host-local secret data";
+ };
secrets = mkOption {
type = attrsOf secretType;
default = { };
description = "Host-local secrets";
};
+ system.secretsData = mkOption {
+ type = unspecified;
+ default = {};
+ description = "secrets.json contents";
+ };
};
config = {
+ system = {inherit secretsData;};
environment.systemPackages = [ pkgs.fleet-install-secrets ];
+ warnings = filter (v: v!=null) (mapAttrsToList (
+ name: secret:
+ if
+ secret.expectedPrivateParts == [ ]
+ && secret.expectedPublicParts == [ ]
+ && !(config.data.secrets.${name} or { shared = false; }).shared
+ then
+ "Secret ${name} has no expected parts defined, this is deprecated for better visibility"
+ else
+ null
+ ) config.secrets);
+
systemd.services.fleet-install-secrets = mkIf useSysusers {
wantedBy = [ "sysinit.target" ];
after = [ "systemd-sysusers.service" ];
modules/secrets-data.nixdiffbeforeafterboth--- a/modules/secrets-data.nix
+++ b/modules/secrets-data.nix
@@ -151,8 +151,9 @@
toJSON (config.data.sharedSecrets.${name} or { owners = [ ]; }).owners
}. Run fleet secrets regenerate to fix";
}) config.sharedSecrets)
+
++ (mapAttrsToList (name: secret: {
- # TODO: Same aassertion should be in host secrets
+ # TODO: Same assertion should be in host secrets
assertion =
(config.data.sharedSecrets.${name} or { generationData = null; }).generationData
== secret.expectedGenerationData;
@@ -160,6 +161,5 @@
toJSON (config.data.sharedSecrets.${name} or { generationData = null; }).generationData
}. Run fleet secrets regenerate to fix";
}) config.sharedSecrets);
- sharedSecrets = mapAttrs (_: _: { }) config.data.sharedSecrets;
};
}
modules/secrets.nixdiffbeforeafterboth--- a/modules/secrets.nix
+++ b/modules/secrets.nix
@@ -69,6 +69,16 @@
description = "Contextual metadata embedded within the secret part value";
default = null;
};
+ expectedPrivateParts = mkOption {
+ type = listOf str;
+ default = [ ];
+ description = "List of parts that are expected to be encrypted";
+ };
+ expectedPublicParts = mkOption {
+ type = listOf str;
+ default = [ ];
+ description = "List of parts that are expected to be public";
+ };
};
};
in
@@ -81,16 +91,25 @@
};
};
config = {
- hosts = mapAttrs (_: secretMap: {
- nixos.secrets = mapAttrs (
- _: s:
- removeAttrs s [
- "createdAt"
- "expiresAt"
- "generationData"
- ]
- ) secretMap;
- }) config.data.hostSecrets;
+ hosts = mapAttrs (
+ _: secretMap:
+ let
+ partsOf =
+ s:
+ removeAttrs s [
+ "createdAt"
+ "expiresAt"
+ "generationData"
+ ];
+
+ in
+ {
+ nixos.data.secrets = mapAttrs (_: s: partsOf s) secretMap;
+ # nixos.secrets = mapAttrs (
+ # _: s: mapAttrs (_: _: {}) (partsOf s)
+ # ) secretMap;
+ }
+ ) config.data.hostSecrets;
nixpkgs.overlays = [
(final: prev: {
mkSecretGenerators =