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, FleetHostSecret, FleetSharedSecret},26 secret::{HostSecretDefinition, SharedSecretDefinition},27};2829pub struct FleetConfigInternals {30 /// Fleet project directory, containing fleet.nix file.31 pub directory: PathBuf,32 /// builtins.currentSystem33 pub local_system: String,34 pub data: Mutex<FleetData>,35 pub nix_args: Vec<OsString>,36 /// fleet_config.config37 pub config_field: Value,38 /// flake.output39 pub flake_outputs: Value,40 // TODO: Remove with connectivity refactor41 pub localhost: String,4243 /// import nixpkgs {system = local};44 pub default_pkgs: Value,45 /// inputs.nixpkgs46 pub nixpkgs: Value,47}4849// TODO: Make field not pub50#[derive(Clone)]51pub struct Config(pub Arc<FleetConfigInternals>);5253impl Deref for Config {54 type Target = FleetConfigInternals;5556 fn deref(&self) -> &Self::Target {57 &self.058 }59}6061#[derive(Clone, Copy, Debug)]62pub enum EscalationStrategy {63 Sudo,64 Run0,65 Su,66}6768#[derive(Clone, PartialEq, Copy, Debug)]69pub enum DeployKind {70 /// NixOS => NixOS managed by fleet71 UpgradeToFleet,72 /// NixOS managed by fleet => NixOS managed by fleet73 Fleet,74 /// Remote host has /mnt, /mnt/boot mounted,75 /// generated config is added to fleet configuration.76 NixosInstall,77 /// Remote host has some system and nix installed in multi-user mode (/nix is owned by root),78 /// generated config is added to fleet configuration,79 /// and /etc/NIXOS_LUSTRATE exists, fleet will perform the rest.80 NixosLustrate,81}8283impl FromStr for DeployKind {84 type Err = anyhow::Error;85 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {86 match s {87 "upgrade-to-fleet" => Ok(Self::UpgradeToFleet),88 "fleet" => Ok(Self::Fleet),89 "nixos-install" => Ok(Self::NixosInstall),90 "nixos-lustrate" => Ok(Self::NixosLustrate),91 v => bail!(92 "unknown deploy_kind: {v}; expected on of \"upgrade-to-fleet\", \"fleet\", \"nixos-install\", \"nixos-lustrate\""93 ),94 }95 }96}97pub struct ConfigHost {98 config: Config,99 pub name: String,100 groups: OnceCell<Vec<String>>,101102 // TODO: Both of those values are taken from host opts, there should be a cleaner way to specify it103 deploy_kind: OnceCell<DeployKind>,104 session_destination: OnceCell<String>,105 legacy_ssh_store: OnceCell<bool>,106107 pub host_config: Option<Value>,108 pub nixos_config: OnceCell<Value>,109 pub nixos_unchecked_config: OnceCell<Value>,110 pub pkgs_override: Option<Value>,111112 // TODO: Move command helpers away with connectivity refactor113 pub local: bool,114 pub session: OnceLock<Arc<openssh::Session>>,115}116117#[derive(Debug, Clone, Copy)]118pub enum GenerationStorage {119 Deployer,120 Machine,121 Pusher,122}123impl GenerationStorage {124 fn prefix(&self) -> &'static str {125 match self {126 GenerationStorage::Deployer => "deployer.",127 GenerationStorage::Machine => "",128 GenerationStorage::Pusher => "pusher.",129 }130 }131}132133#[derive(Tabled, Debug)]134pub struct Generation {135 #[tabled(rename = "ID", format("{}", self.rollback_id()))]136 pub id: u32,137 #[tabled(rename = "Current")]138 pub current: bool,139 #[tabled(rename = "Created at")]140 pub datetime: UtcDateTime,141 #[tabled(format = "{:?}")]142 pub store_path: PathBuf,143 #[tabled(skip)]144 pub location: GenerationStorage,145}146impl Generation {147 pub fn rollback_id(&self) -> String {148 format!("{}{}", self.location.prefix(), self.id)149 }150}151152fn parse_generation_line(g: &str) -> Option<Generation> {153 let mut parts = g.split_whitespace();154 let id = parts.next()?;155 let id: u32 = id.parse().ok()?;156 let date = parts.next()?;157 let time = parts.next()?;158 let current = if let Some(current) = parts.next() {159 if current == "(current)" {160 Some(true)161 } else {162 None163 }164 } else {165 Some(false)166 };167 let current = current?;168 if parts.next().is_some() {169 warn!("unexpected text after generation: {g}");170 }171172 let format = format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]")173 .expect("valid format");174 let datetime = UtcDateTime::parse(&format!("{date} {time}"), &format).ok()?;175176 Some(Generation {177 id,178 current,179 datetime,180 store_path: PathBuf::new(),181 location: GenerationStorage::Machine,182 })183}184// TODO: Move command helpers away with connectivity refactor185impl ConfigHost {186 pub async fn list_generations(&self, profile: &str) -> Result<Vec<Generation>> {187 let mut cmd = self.cmd("nix-env").await?;188 cmd.comparg("--profile", format!("/nix/var/nix/profiles/{profile}"))189 .arg("--list-generations")190 .env("TZ", "UTC");191 // Sudo is required because --list-generations tries to acquire profile lock192 let data = cmd.sudo().run_string().await?;193 let mut generations = data194 .split('\n')195 .map(|e| e.trim())196 .filter(|&l| !l.is_empty())197 .filter_map(|g| {198 let generation = parse_generation_line(g);199 if generation.is_none() {200 warn!("bad generation: {g}");201 };202 generation203 })204 .collect::<Vec<_>>();205 for ele in generations.iter_mut() {206 let mut cmd = self.cmd("readlink").await?;207 cmd.arg("--")208 .arg(format!("/nix/var/nix/profiles/{profile}-{}-link", ele.id));209 let path = cmd.run_string().await?;210 ele.store_path = PathBuf::from(path.trim_end_matches("\n"));211 }212213 Ok(generations)214 }215216 pub fn set_session_destination(&self, dest: String) {217 self.session_destination218 .set(dest)219 .expect("session destination is already set")220 }221 pub fn set_deploy_kind(&self, kind: DeployKind) {222 self.deploy_kind223 .set(kind)224 .expect("deploy kind is already set");225 }226 pub fn set_legacy_ssh_store(&self, legacy: bool) {227 self.legacy_ssh_store228 .set(legacy)229 .expect("legacy ssh store is already set")230 }231 pub async fn deploy_kind(&self) -> Result<DeployKind> {232 if let Some(kind) = self.deploy_kind.get() {233 return Ok(*kind);234 }235 let is_fleet_managed = match self.file_exists("/etc/FLEET_HOST").await {236 Ok(v) => v,237 Err(e) => {238 bail!("failed to query remote system kind: {e}");239 }240 };241 if !is_fleet_managed {242 bail!(243 "{}",244 indoc::indoc! {"245 host is not marked as managed by fleet246 if you're not trying to lustrate/install system from scratch,247 you should either248 1. manually create /etc/FLEET_HOST file on the target host,249 2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet250 3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos251 "}252 );253 }254 // TOCTOU is possible255 let _ = self.deploy_kind.set(DeployKind::Fleet);256 Ok(*self.deploy_kind.get().expect("deploy kind is just set"))257 }258 pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {259 // Prefer sudo, as run0 has some gotchas with polkit260 // and too many repeating prompts.261 if (self.find_in_path("sudo").await).is_ok() {262 return Ok(EscalationStrategy::Sudo);263 }264 if (self.find_in_path("run0").await).is_ok() {265 return Ok(EscalationStrategy::Run0);266 }267 Ok(EscalationStrategy::Su)268 }269 async fn open_session(&self) -> Result<Arc<openssh::Session>> {270 assert!(!self.local, "do not open ssh connection to local session");271 // FIXME: TOCTOU272 if let Some(session) = &self.session.get() {273 return Ok((*session).clone());274 };275 let mut session = SessionBuilder::default();276 session.control_persist(ControlPersist::ClosedAfterInitialConnection);277278 let dest = self.session_destination.get().unwrap_or(&self.name);279 let session = session280 .connect(&dest)281 .await282 .map_err(|e| anyhow!("ssh error while connecting to {}: {e:#?}", self.name))?;283 let session = Arc::new(session);284 self.session.set(session.clone()).expect("TOCTOU happened");285 Ok(session)286 }287 pub async fn mktemp_dir(&self) -> Result<String> {288 let mut cmd = self.cmd("mktemp").await?;289 cmd.arg("-d");290 let path = cmd.run_string().await?;291 Ok(path.trim_end().to_owned())292 }293 pub async fn file_exists(&self, path: impl AsRef<OsStr>) -> Result<bool> {294 let mut cmd = self.cmd("sh").await?;295 cmd.arg("-c")296 .arg("test -e \"$1\" && echo true || echo false")297 .arg("_")298 .arg(path);299 cmd.run_value().await300 }301 pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {302 let mut cmd = self.cmd("cat").await?;303 cmd.arg(path);304 cmd.run_bytes().await305 }306 pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {307 let mut cmd = self.cmd("cat").await?;308 cmd.arg(path);309 cmd.run_string().await310 }311 pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {312 let mut cmd = self.cmd("ls").await?;313 cmd.arg(path);314 let out = cmd.run_string().await?;315 let mut lines = out.split('\n');316 if let Some(last) = lines.next_back() {317 ensure!(last.is_empty(), "output of ls should end with newline");318 }319 Ok(lines.map(ToOwned::to_owned).collect())320 }321 #[allow(dead_code)]322 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {323 let text = self.read_file_text(path).await?;324 Ok(serde_json::from_str(&text)?)325 }326 pub async fn read_env(&self, env: &str) -> Result<String> {327 let mut cmd = self.cmd("printenv").await?;328 cmd.arg(env);329 cmd.run_string().await330 }331 pub async fn find_in_path(&self, command: &str) -> Result<String> {332 // // `which` is not a part of coreutils, and it might not exist on machine.333 // let path = self.read_env("PATH").await?;334 // // Assuming delimiter is :, we don't work with windows host, this check will be much335 // // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)336 // for ele in path.split(':') {337 // let test_path = format!("{ele}/{cmd}");338 // test -x etc339 // }340 // let mut cmd = self.cmd("printenv").await?;341 // cmd.arg(env);342 // Ok(cmd.run_string().await?)343 // Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.344 let mut cmd = self345 .cmd_escalation(346 // Not used347 EscalationStrategy::Su,348 "which",349 )350 .await?;351 cmd.arg(command);352 cmd.run_string().await353 }354 pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>355 where356 <D as FromStr>::Err: Display,357 {358 let text = self.read_file_text(path).await?;359 D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))360 }361 pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {362 self.cmd_escalation(self.escalation_strategy().await?, cmd)363 .await364 }365 pub async fn cmd_escalation(366 &self,367 escalation: EscalationStrategy,368 cmd: impl AsRef<OsStr>,369 ) -> Result<MyCommand> {370 if self.local {371 Ok(MyCommand::new(escalation, cmd))372 } else {373 let session = self.open_session().await?;374 Ok(MyCommand::new_on(escalation, cmd, session))375 }376 }377 pub async fn nix_cmd(&self) -> Result<MyCommand> {378 let mut nix = self.cmd("nix").await?;379 nix.args([380 "--extra-experimental-features",381 "nix-command",382 "--extra-experimental-features",383 "flakes",384 ]);385 Ok(nix)386 }387388 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {389 ensure!(data.encrypted, "secret is not encrypted");390 let mut cmd = self.cmd("fleet-install-secrets").await?;391 cmd.arg("decrypt").eqarg("--secret", data.to_string());392 let encoded = cmd393 .sudo()394 .run_string()395 .await396 .context("failed to call remote host for decrypt")?;397 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;398 ensure!(!data.encrypted, "secret came out encrypted");399 Ok(data.data)400 }401 pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {402 ensure!(data.encrypted, "secret is not encrypted");403 let mut cmd = self.cmd("fleet-install-secrets").await?;404 cmd.arg("reencrypt").eqarg("--secret", data.to_string());405 for target in targets {406 let key = self.config.key(&target).await?;407 cmd.eqarg("--targets", key);408 }409 let encoded = cmd410 .sudo()411 .run_string()412 .await413 .context("failed to call remote host for decrypt")?;414 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;415 ensure!(data.encrypted, "secret came out not encrypted");416 Ok(data)417 }418 /// Returns path for futureproofing, as path might change i.e on conversion to CA419 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {420 if self.local {421 // Path is located locally, thus already trusted.422 return Ok(path.to_owned());423 }424 let mut nix = MyCommand::new(425 // Not used426 EscalationStrategy::Su,427 "nix",428 );429 nix.arg("copy").arg("--substitute-on-destination");430431 let proto = if self.legacy_ssh_store.get().cloned().unwrap_or(false) {432 "ssh"433 } else {434 "ssh-ng"435 };436437 match self.deploy_kind().await? {438 DeployKind::Fleet | DeployKind::UpgradeToFleet | DeployKind::NixosLustrate => {439 nix.comparg("--to", format!("{proto}://{}", self.name));440 }441 DeployKind::NixosInstall => {442 nix443 // Signature checking makes no sense with remote-store store argument set, as we're not even interacting with remote nix daemon444 .arg("--no-check-sigs")445 .comparg(446 "--to",447 format!("{proto}://root@{}?remote-store=/mnt", self.name),448 );449 }450 }451 nix.arg(path);452 nix.run_nix().await.context("nix copy")?;453 Ok(path.to_owned())454 }455 pub async fn systemctl_stop(&self, name: &str) -> Result<()> {456 let mut cmd = self.cmd("systemctl").await?;457 cmd.arg("stop").arg(name);458 cmd.sudo().run().await459 }460 pub async fn systemctl_start(&self, name: &str) -> Result<()> {461 let mut cmd = self.cmd("systemctl").await?;462 cmd.arg("start").arg(name);463 cmd.sudo().run().await464 }465466 pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {467 let mut cmd = self.cmd("rm").await?;468 cmd.arg("-f").arg(path);469 if sudo {470 cmd = cmd.sudo()471 }472 cmd.run().await473 }474}475impl ConfigHost {476 // TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,477 // assuming getting tags always returns the same value.478 pub async fn tags(&self) -> Result<Vec<String>> {479 if let Some(v) = self.groups.get() {480 return Ok(v.clone());481 }482 let Some(host_config) = &self.host_config else {483 return Ok(vec![]);484 };485 let tags: Vec<String> = nix_go_json!(host_config.tags);486487 let _ = self.groups.set(tags.clone());488489 Ok(tags)490 }491 pub async fn nixos_config(&self) -> Result<Value> {492 if let Some(v) = self.nixos_config.get() {493 return Ok(v.clone());494 }495 let Some(host_config) = &self.host_config else {496 bail!("local host has no nixos_config");497 };498 let nixos_config = nix_go!(host_config.nixos.config);499 assert_warn("nixos config evaluation", &nixos_config).await?;500501 let _ = self.nixos_config.set(nixos_config.clone());502503 Ok(nixos_config)504 }505 pub fn nixos_unchecked_config(&self) -> Result<Value> {506 if let Some(v) = self.nixos_unchecked_config.get() {507 return Ok(v.clone());508 }509 let Some(host_config) = &self.host_config else {510 bail!("local host has no nixos_config");511 };512 let nixos_config = nix_go!(host_config.nixos_unchecked.config);513514 let _ = self.nixos_unchecked_config.set(nixos_config.clone());515516 Ok(nixos_config)517 }518519 pub fn list_defined_secrets(&self) -> Result<Vec<String>> {520 let nixos = self.nixos_unchecked_config()?;521 let secrets = nix_go!(nixos.secrets);522 secrets.list_fields()523 }524 pub fn secret_definition(&self, name: &str) -> Result<HostSecretDefinition> {525 let nixos = self.nixos_unchecked_config()?;526 Ok(HostSecretDefinition(527 self.name.clone(),528 nix_go!(nixos.secrets[{ name }]),529 ))530 }531532 /// Packages for this host, resolved with nixpkgs overlays533 pub async fn pkgs(&self) -> Result<Value> {534 if let Some(value) = &self.pkgs_override {535 return Ok(value.clone());536 }537 let Some(host_config) = &self.host_config else {538 bail!("local host has no host_config");539 };540 // TODO: Should nixos.options be cached?541 Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))542 }543}544545impl Config {546 pub async fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {547 let config = &self.config_field;548 let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);549 Ok(tagged)550 }551 pub async fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {552 let mut out = BTreeSet::new();553 for owner in owners {554 if let Some(tag) = owner.strip_prefix('@') {555 let hosts = self.tagged_hostnames(tag).await?;556 out.extend(hosts);557 } else {558 out.insert(owner);559 }560 }561 Ok(out)562 }563 pub fn local_host(&self) -> ConfigHost {564 ConfigHost {565 config: self.clone(),566 name: "<virtual localhost>".to_owned(),567 host_config: None,568 nixos_config: OnceCell::new(),569 nixos_unchecked_config: OnceCell::new(),570 groups: {571 let cell = OnceCell::new();572 let _ = cell.set(vec![]);573 cell574 },575 pkgs_override: Some(self.default_pkgs.clone()),576577 local: true,578 session: OnceLock::new(),579 deploy_kind: OnceCell::new(),580 session_destination: OnceCell::new(),581 legacy_ssh_store: OnceCell::new(),582 }583 }584585 pub async fn host(&self, name: &str) -> Result<ConfigHost> {586 let config = &self.config_field;587 let host_config = nix_go!(config.hosts[{ name }]);588589 Ok(ConfigHost {590 config: self.clone(),591 name: name.to_owned(),592 host_config: Some(host_config),593 nixos_config: OnceCell::new(),594 nixos_unchecked_config: OnceCell::new(),595 groups: OnceCell::new(),596 pkgs_override: None,597598 // TODO: Remove with connectivit refactor599 local: self.localhost == name,600 session: OnceLock::new(),601 deploy_kind: OnceCell::new(),602 session_destination: OnceCell::new(),603 legacy_ssh_store: OnceCell::new(),604 })605 }606 pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {607 let config = &self.config_field;608 let names = nix_go!(config.hosts).list_fields()?;609 let mut out = vec![];610 for name in names {611 out.push(self.host(&name).await?);612 }613 Ok(out)614 }615 // TODO: Replace usages with .host().nixos_config616 pub async fn system_config(&self, host: &str) -> Result<Value> {617 let fleet_field = &self.config_field;618 Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))619 }620621 /// Shared secrets configured in fleet.nix or in flake622 pub async fn list_configured_shared(&self) -> Result<Vec<String>> {623 let config_field = &self.config_field;624 nix_go!(config_field.sharedSecrets).list_fields()625 }626 /// Shared secrets configured in fleet.nix627 pub fn list_shared(&self) -> Vec<String> {628 let data = self.data();629 data.shared_secrets.keys().cloned().collect()630 }631 pub fn has_shared(&self, name: &str) -> bool {632 let data = self.data();633 data.shared_secrets.contains_key(name)634 }635 pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {636 let mut data = self.data_mut();637 data.shared_secrets.insert(name.to_owned(), shared);638 }639 pub fn remove_shared(&self, secret: &str) {640 let mut data = self.data_mut();641 data.shared_secrets.remove(secret);642 }643644 pub fn list_secrets(&self, host: &str) -> Vec<String> {645 let data = self.data();646 let mut out = data647 .host_secrets648 .get(host)649 .map(|s| s.keys().cloned().collect::<Vec<String>>())650 .unwrap_or_default();651652 for (name, shared) in data.shared_secrets.iter() {653 if shared.owners.contains(host) {654 out.push(name.clone());655 }656 }657658 out659 }660661 pub fn has_secret(&self, host: &str, secret: &str) -> bool {662 let data = self.data();663 let Some(host_secrets) = data.host_secrets.get(host) else {664 return false;665 };666 host_secrets.contains_key(secret)667 }668 pub fn insert_secret(&self, host: &str, secret: String, value: FleetHostSecret) {669 let mut data = self.data_mut();670 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();671 host_secrets.insert(secret, value);672 }673 pub fn remove_secret(&self, host: &str, secret: &str) {674 let mut data = self.data_mut();675 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();676 host_secrets.remove(secret);677 }678679 pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetHostSecret> {680 let data = self.data();681 if let Some(host_secrets) = data.host_secrets.get(host) {682 if let Some(secret) = host_secrets.get(secret) {683 return Ok(secret.clone());684 }685 };686 let Some(shared) = data.shared_secrets.get(secret) else {687 bail!("machine {host} has no secret {secret}");688 };689 if !shared.owners.contains(host) {690 bail!("shared secret {secret} is not owned by {host}");691 };692 Ok(FleetHostSecret {693 managed: shared.managed,694 secret: shared.secret.clone(),695 })696 }697 pub fn shared_secret(&self, secret: &str) -> Result<Option<FleetSharedSecret>> {698 let data = self.data();699 Ok(data.shared_secrets.get(secret).cloned())700 }701 pub fn shared_secret_definition(&self, secret: &str) -> Result<SharedSecretDefinition> {702 let config_field = &self.config_field;703 Ok(SharedSecretDefinition(nix_go!(704 config_field.sharedSecrets[{ secret }]705 )))706 }707708 // TODO: Should this be something modifiable from other processes?709 // E.g terraform provider might want to update FleetData (e.g secrets),710 // and current implementation assumes only one process holds current fleet.nix711 // Given that it is no longer needs to be a file for nix evaluation,712 // maybe it can be a .nix file for persistence, but accessible only713 // thru some shared state controller? Might it be stored in terraform714 // state provider?715 pub fn data(&'_ self) -> MutexGuard<'_, FleetData> {716 self.data.lock().unwrap()717 }718 pub fn data_mut(&'_ self) -> MutexGuard<'_, FleetData> {719 self.data.lock().unwrap()720 }721 pub fn save(&self) -> Result<()> {722 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.")?;723 let data = nixlike::serialize(&self.data() as &FleetData)?;724 tempfile.write_all(725 format!(726 "# This file contains fleet state and shouldn't be edited by hand\n\n{data}\n\n# vim: ts=2 et nowrap\n"727 )728 .as_bytes(),729 )?;730 let mut fleet_data_path = self.directory.clone();731 fleet_data_path.push("fleet.nix");732 tempfile.persist(fleet_data_path)?;733 Ok(())734 }735}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 =