difftreelog
feat secret regeneration
in: trunk
6 files changed
cmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth--- a/cmds/fleet/src/cmds/secrets/mod.rs
+++ b/cmds/fleet/src/cmds/secrets/mod.rs
@@ -130,13 +130,28 @@
},
}
+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, batch))]
-async fn update_owner_set(
+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,
prefer_identities: &[String],
batch: Option<NixBuildBatch>,
) -> Result<FleetSharedSecret> {
@@ -145,12 +160,18 @@
let set = original_set.iter().collect::<BTreeSet<_>>();
let expected_set = expected_owners.iter().collect::<BTreeSet<_>>();
- if set == expected_set {
+ 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 set.difference(&expected_set).next().is_some() {
+ 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)
@@ -161,9 +182,16 @@
};
if should_regenerate {
- info!("secret is owner-dependent, will regenerate");
- let generated =
- generate_shared(config, secret_name, field, expected_owners.to_vec(), batch).await?;
+ info!("secret needs to be regenerated");
+ let generated = generate_shared(
+ config,
+ secret_name,
+ field,
+ expected_owners.to_vec(),
+ expected_generation_data,
+ batch,
+ )
+ .await?;
Ok(generated)
} else {
drop(batch);
@@ -216,7 +244,8 @@
_display_name: &str,
secret: Value,
default_generator: Value,
- owners: &[String],
+ expected_owners: &[String],
+ expected_generation_data: serde_json::Value,
batch: Option<NixBuildBatch>,
) -> Result<FleetSecret> {
let generator = nix_go!(secret.generator);
@@ -232,7 +261,7 @@
let mk_secret_generators = nix_go!(on_pkgs.mkSecretGenerators);
let mut recipients = Vec::new();
- for owner in owners {
+ for owner in expected_owners {
let key = config.key(owner).await?;
recipients.push(key);
}
@@ -288,15 +317,15 @@
created_at,
expires_at,
parts,
- // TODO: Fill with expected
- generation_data: serde_json::Value::Null,
+ generation_data: expected_generation_data,
})
}
async fn generate(
config: &Config,
display_name: &str,
secret: Value,
- owners: &[String],
+ expected_owners: &[String],
+ expected_generation_data: serde_json::Value,
batch: Option<NixBuildBatch>,
) -> Result<FleetSecret> {
let generator = nix_go!(secret.generator);
@@ -335,13 +364,21 @@
display_name,
secret,
default_generator,
- owners,
+ expected_owners,
+ expected_generation_data,
batch,
)
.await
}
GeneratorKind::Pure => {
- generate_pure(config, display_name, secret, default_generator, owners).await
+ generate_pure(
+ config,
+ display_name,
+ secret,
+ default_generator,
+ expected_owners,
+ )
+ .await
}
}
}
@@ -350,11 +387,20 @@
display_name: &str,
secret: Value,
expected_owners: Vec<String>,
+ expected_generation_data: serde_json::Value,
batch: Option<NixBuildBatch>,
) -> Result<FleetSharedSecret> {
// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);
Ok(FleetSharedSecret {
- secret: generate(config, display_name, secret, &expected_owners, batch).await?,
+ secret: generate(
+ config,
+ display_name,
+ secret,
+ &expected_owners,
+ expected_generation_data,
+ batch,
+ )
+ .await?,
owners: expected_owners,
})
}
@@ -615,13 +661,15 @@
let config_field = &config.config_field;
let field = nix_go!(config_field.sharedSecrets[{ name }]);
+ let expected_generation_data = nix_go_json!(field.expectedGenerationData);
- let updated = update_owner_set(
+ let updated = maybe_regenerate_shared_secret(
&name,
config,
secret,
field,
&target_machines,
+ expected_generation_data,
&prefer_identities,
None,
)
@@ -630,7 +678,9 @@
}
Secret::Regenerate { prefer_identities } => {
info!("checking for secrets to regenerate");
+ let stored_shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();
{
+ // Generate missing shared
let shared_batch = None;
let _span = info_span!("shared").entered();
let expected_shared_set = config
@@ -638,14 +688,15 @@
.await?
.into_iter()
.collect::<HashSet<_>>();
- let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();
- for missing in expected_shared_set.difference(&shared_set) {
+ 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 {
- // TODO: Might still need to regenerate
+ // Can't generate this missing secret, as it has no defined owners.
continue;
};
info!("generating secret: {missing}");
@@ -654,6 +705,7 @@
missing,
secret,
expected_owners,
+ expected_generation_data,
shared_batch.clone(),
)
.in_current_span()
@@ -681,11 +733,13 @@
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);
let generated = match generate(
config,
missing,
secret,
&[host.name.clone()],
+ expected_generation_data,
hosts_batch.clone(),
)
.in_current_span()
@@ -699,9 +753,35 @@
};
config.insert_secret(&host.name, missing.to_string(), 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) {
+ let generated = match generate(
+ config,
+ &name,
+ secret,
+ &[host.name.clone()],
+ expected_generation_data,
+ hosts_batch.clone(),
+ )
+ .in_current_span()
+ .await
+ {
+ Ok(v) => v,
+ Err(e) => {
+ error!("{e:?}");
+ continue;
+ }
+ };
+ config.insert_secret(&host.name, name.to_string(), generated)
+ }
+ }
}
let mut to_remove = Vec::new();
- for name in &config.list_shared() {
+ for name in &stored_shared_set {
info!("updating secret: {name}");
let data = config.shared_secret(name)?;
let config_field = &config.config_field;
@@ -714,14 +794,16 @@
}
let secret = nix_go!(config_field.sharedSecrets[{ name }]);
+ let expected_generation_data = nix_go_json!(secret.expectedGenerationData);
config.replace_shared(
name.to_owned(),
- update_owner_set(
+ maybe_regenerate_shared_secret(
name,
config,
data,
secret,
&expected_owners,
+ expected_generation_data,
&prefer_identities,
None,
)
crates/fleet-base/src/host.rsdiffbeforeafterboth1use std::{2 cell::OnceCell,3 ffi::{OsStr, OsString},4 fmt::Display,5 io::Write,6 ops::Deref,7 path::PathBuf,8 str::FromStr,9 sync::{Arc, Mutex, MutexGuard, OnceLock},10};1112use anyhow::{anyhow, bail, ensure, Context, Result};13use fleet_shared::SecretData;14use nix_eval::{nix_go, nix_go_json, util::assert_warn, NixSession, Value};15use openssh::SessionBuilder;16use serde::de::DeserializeOwned;17use tempfile::NamedTempFile;1819use crate::{20 command::MyCommand,21 fleetdata::{FleetData, FleetSecret, FleetSharedSecret},22};2324pub struct FleetConfigInternals {25 pub local_system: String,26 pub directory: PathBuf,27 pub data: Mutex<FleetData>,28 pub nix_args: Vec<OsString>,29 /// fleet_config.config30 pub config_field: Value,31 // TODO: Remove with connectivity refactor32 pub localhost: String,3334 /// import nixpkgs {system = local};35 pub default_pkgs: Value,3637 pub nix_session: NixSession,38}3940// TODO: Make field not pub41#[derive(Clone)]42pub struct Config(pub Arc<FleetConfigInternals>);4344impl Deref for Config {45 type Target = FleetConfigInternals;4647 fn deref(&self) -> &Self::Target {48 &self.049 }50}5152#[derive(Clone, Copy, Debug)]53pub enum EscalationStrategy {54 Sudo,55 Run0,56 Su,57}5859pub struct ConfigHost {60 config: Config,61 pub name: String,62 groups: OnceCell<Vec<String>>,6364 pub host_config: Option<Value>,65 pub nixos_config: OnceCell<Value>,66 pub pkgs_override: Option<Value>,6768 // TODO: Move command helpers away with connectivity refactor69 pub local: bool,70 pub session: OnceLock<Arc<openssh::Session>>,71}72// TODO: Move command helpers away with connectivity refactor73impl ConfigHost {74 pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {75 // Prefer sudo, as run0 has some gotchas with polkit76 // and too many repeating prompts.77 if (self.find_in_path("sudo").await).is_ok() {78 return Ok(EscalationStrategy::Sudo);79 }80 if (self.find_in_path("run0").await).is_ok() {81 return Ok(EscalationStrategy::Run0);82 }83 Ok(EscalationStrategy::Su)84 }85 async fn open_session(&self) -> Result<Arc<openssh::Session>> {86 assert!(!self.local, "do not open ssh connection to local session");87 // FIXME: TOCTOU88 if let Some(session) = &self.session.get() {89 return Ok((*session).clone());90 };91 let session = SessionBuilder::default();92 let session = session93 .connect(&self.name)94 .await95 .map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;96 let session = Arc::new(session);97 self.session.set(session.clone()).expect("TOCTOU happened");98 Ok(session)99 }100 pub async fn mktemp_dir(&self) -> Result<String> {101 let mut cmd = self.cmd("mktemp").await?;102 cmd.arg("-d");103 let path = cmd.run_string().await?;104 Ok(path.trim_end().to_owned())105 }106 pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {107 let mut cmd = self.cmd("cat").await?;108 cmd.arg(path);109 cmd.run_bytes().await110 }111 pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {112 let mut cmd = self.cmd("cat").await?;113 cmd.arg(path);114 cmd.run_string().await115 }116 pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {117 let mut cmd = self.cmd("ls").await?;118 cmd.arg(path);119 let out = cmd.run_string().await?;120 let mut lines = out.split('\n');121 if let Some(last) = lines.next_back() {122 ensure!(last.is_empty(), "output of ls should end with newline");123 }124 Ok(lines.map(ToOwned::to_owned).collect())125 }126 #[allow(dead_code)]127 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {128 let text = self.read_file_text(path).await?;129 Ok(serde_json::from_str(&text)?)130 }131 pub async fn read_env(&self, env: &str) -> Result<String> {132 let mut cmd = self.cmd("printenv").await?;133 cmd.arg(env);134 cmd.run_string().await135 }136 pub async fn find_in_path(&self, command: &str) -> Result<String> {137 // // `which` is not a part of coreutils, and it might not exist on machine.138 // let path = self.read_env("PATH").await?;139 // // Assuming delimiter is :, we don't work with windows host, this check will be much140 // // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)141 // for ele in path.split(':') {142 // let test_path = format!("{ele}/{cmd}");143 // test -x etc144 // }145 // let mut cmd = self.cmd("printenv").await?;146 // cmd.arg(env);147 // Ok(cmd.run_string().await?)148 // Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.149 let mut cmd = self150 .cmd_escalation(151 // Not used152 EscalationStrategy::Su,153 "which",154 )155 .await?;156 cmd.arg(command);157 cmd.run_string().await158 }159 pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>160 where161 <D as FromStr>::Err: Display,162 {163 let text = self.read_file_text(path).await?;164 D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))165 }166 pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {167 self.cmd_escalation(self.escalation_strategy().await?, cmd)168 .await169 }170 pub async fn cmd_escalation(171 &self,172 escalation: EscalationStrategy,173 cmd: impl AsRef<OsStr>,174 ) -> Result<MyCommand> {175 if self.local {176 Ok(MyCommand::new(escalation, cmd))177 } else {178 let session = self.open_session().await?;179 Ok(MyCommand::new_on(escalation, cmd, session))180 }181 }182183 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {184 ensure!(data.encrypted, "secret is not encrypted");185 let mut cmd = self.cmd("fleet-install-secrets").await?;186 cmd.arg("decrypt").eqarg("--secret", data.to_string());187 let encoded = cmd188 .sudo()189 .run_string()190 .await191 .context("failed to call remote host for decrypt")?;192 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;193 ensure!(!data.encrypted, "secret came out encrypted");194 Ok(data.data)195 }196 pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {197 ensure!(data.encrypted, "secret is not encrypted");198 let mut cmd = self.cmd("fleet-install-secrets").await?;199 cmd.arg("reencrypt").eqarg("--secret", data.to_string());200 for target in targets {201 let key = self.config.key(&target).await?;202 cmd.eqarg("--targets", key);203 }204 let encoded = cmd205 .sudo()206 .run_string()207 .await208 .context("failed to call remote host for decrypt")?;209 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;210 ensure!(data.encrypted, "secret came out not encrypted");211 Ok(data)212 }213 /// Returns path for futureproofing, as path might change i.e on conversion to CA214 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {215 if self.local {216 // Path is located locally, thus already trusted.217 return Ok(path.to_owned());218 }219 let mut nix = MyCommand::new(220 // Not used221 EscalationStrategy::Su,222 "nix",223 );224 nix.arg("copy")225 .arg("--substitute-on-destination")226 .comparg("--to", format!("ssh-ng://{}", self.name))227 .arg(path);228 nix.run_nix().await.context("nix copy")?;229 Ok(path.to_owned())230 }231 pub async fn systemctl_stop(&self, name: &str) -> Result<()> {232 let mut cmd = self.cmd("systemctl").await?;233 cmd.arg("stop").arg(name);234 cmd.sudo().run().await235 }236 pub async fn systemctl_start(&self, name: &str) -> Result<()> {237 let mut cmd = self.cmd("systemctl").await?;238 cmd.arg("start").arg(name);239 cmd.sudo().run().await240 }241242 pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {243 let mut cmd = self.cmd("rm").await?;244 cmd.arg("-f").arg(path);245 if sudo {246 cmd = cmd.sudo()247 }248 cmd.run().await249 }250}251impl ConfigHost {252 // TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,253 // assuming getting tags always returns the same value.254 pub async fn tags(&self) -> Result<Vec<String>> {255 if let Some(v) = self.groups.get() {256 return Ok(v.clone());257 }258 let Some(host_config) = &self.host_config else {259 return Ok(vec![]);260 };261 let tags: Vec<String> = nix_go_json!(host_config.tags);262263 let _ = self.groups.set(tags.clone());264265 Ok(tags)266 }267 pub async fn nixos_config(&self) -> Result<Value> {268 if let Some(v) = self.nixos_config.get() {269 return Ok(v.clone());270 }271 let Some(host_config) = &self.host_config else {272 bail!("local host has no nixos_config");273 };274 let nixos_config = nix_go!(host_config.nixos.config);275 assert_warn("nixos config evaluation", &nixos_config).await?;276277 let _ = self.nixos_config.set(nixos_config.clone());278279 Ok(nixos_config)280 }281282 pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {283 let nixos = self.nixos_config().await?;284 let secrets = nix_go!(nixos.secrets);285 let mut out = Vec::new();286 for name in secrets.list_fields().await? {287 let secret = nix_go!(secrets[{ name }]);288 let is_shared: bool = nix_go_json!(secret.shared);289 if is_shared {290 continue;291 }292 out.push(name);293 }294 Ok(out)295 }296 pub async fn secret_field(&self, name: &str) -> Result<Value> {297 let nixos = self.nixos_config().await?;298 Ok(nix_go!(nixos.secrets[{ name }]))299 }300301 /// Packages for this host, resolved with nixpkgs overlays302 pub async fn pkgs(&self) -> Result<Value> {303 if let Some(value) = &self.pkgs_override {304 return Ok(value.clone());305 }306 let Some(host_config) = &self.host_config else {307 bail!("local host has no host_config");308 };309 // TODO: Should nixos.options be cached?310 Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))311 }312}313314impl Config {315 pub fn local_host(&self) -> ConfigHost {316 ConfigHost {317 config: self.clone(),318 name: "<virtual localhost>".to_owned(),319 host_config: None,320 nixos_config: OnceCell::new(),321 groups: {322 let cell = OnceCell::new();323 let _ = cell.set(vec![]);324 cell325 },326 pkgs_override: Some(self.default_pkgs.clone()),327328 local: true,329 session: OnceLock::new(),330 }331 }332333 pub async fn host(&self, name: &str) -> Result<ConfigHost> {334 let config = &self.config_field;335 let host_config = nix_go!(config.hosts[{ name }]);336337 Ok(ConfigHost {338 config: self.clone(),339 name: name.to_owned(),340 host_config: Some(host_config),341 nixos_config: OnceCell::new(),342 groups: OnceCell::new(),343 pkgs_override: None,344345 // TODO: Remove with connectivit refactor346 local: self.localhost == name,347 session: OnceLock::new(),348 })349 }350 pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {351 let config = &self.config_field;352 let names = nix_go!(config.hosts).list_fields().await?;353 let mut out = vec![];354 for name in names {355 out.push(self.host(&name).await?);356 }357 Ok(out)358 }359 // TODO: Replace usages with .host().nixos_config360 pub async fn system_config(&self, host: &str) -> Result<Value> {361 let fleet_field = &self.config_field;362 Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))363 }364365 /// Shared secrets configured in fleet.nix or in flake366 pub async fn list_configured_shared(&self) -> Result<Vec<String>> {367 let config_field = &self.config_field;368 Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)369 }370 /// Shared secrets configured in fleet.nix371 pub fn list_shared(&self) -> Vec<String> {372 let data = self.data();373 data.shared_secrets.keys().cloned().collect()374 }375 pub fn has_shared(&self, name: &str) -> bool {376 let data = self.data();377 data.shared_secrets.contains_key(name)378 }379 pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {380 let mut data = self.data_mut();381 data.shared_secrets.insert(name.to_owned(), shared);382 }383 pub fn remove_shared(&self, secret: &str) {384 let mut data = self.data_mut();385 data.shared_secrets.remove(secret);386 }387388 pub fn list_secrets(&self, host: &str) -> Vec<String> {389 let data = self.data();390 let Some(secrets) = data.host_secrets.get(host) else {391 return Vec::new();392 };393 secrets.keys().cloned().collect()394 }395396 pub fn has_secret(&self, host: &str, secret: &str) -> bool {397 let data = self.data();398 let Some(host_secrets) = data.host_secrets.get(host) else {399 return false;400 };401 host_secrets.contains_key(secret)402 }403 pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {404 let mut data = self.data_mut();405 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();406 host_secrets.insert(secret, value);407 }408409 pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {410 let data = self.data();411 let Some(host_secrets) = data.host_secrets.get(host) else {412 bail!("no secrets for machine {host}");413 };414 let Some(secret) = host_secrets.get(secret) else {415 bail!("machine {host} has no secret {secret}");416 };417 Ok(secret.clone())418 }419 pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {420 let data = self.data();421 let Some(secret) = data.shared_secrets.get(secret) else {422 bail!("no shared secret {secret}");423 };424 Ok(secret.clone())425 }426 pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {427 let config_field = &self.config_field;428 Ok(nix_go_json!(429 config_field.sharedSecrets[{ secret }].expectedOwners430 ))431 }432433 // TODO: Should this be something modifiable from other processes?434 // E.g terraform provider might want to update FleetData (e.g secrets),435 // and current implementation assumes only one process holds current fleet.nix436 // Given that it is no longer needs to be a file for nix evaluation,437 // maybe it can be a .nix file for persistence, but accessible only438 // thru some shared state controller? Might it be stored in terraform439 // state provider?440 pub fn data(&self) -> MutexGuard<FleetData> {441 self.data.lock().unwrap()442 }443 pub fn data_mut(&self) -> MutexGuard<FleetData> {444 self.data.lock().unwrap()445 }446 pub fn save(&self) -> Result<()> {447 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.")?;448 let data = nixlike::serialize(&self.data() as &FleetData)?;449 tempfile.write_all(450 format!(451 "# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",452 data453 )454 .as_bytes(),455 )?;456 let mut fleet_data_path = self.directory.clone();457 fleet_data_path.push("fleet.nix");458 tempfile.persist(fleet_data_path)?;459 Ok(())460 }461}1use 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::{anyhow, bail, ensure, Context, Result};14use fleet_shared::SecretData;15use nix_eval::{nix_go, nix_go_json, util::assert_warn, NixSession, Value};16use openssh::SessionBuilder;17use serde::de::DeserializeOwned;18use tempfile::NamedTempFile;1920use crate::{21 command::MyCommand,22 fleetdata::{FleetData, FleetSecret, FleetSharedSecret},23};2425pub struct FleetConfigInternals {26 pub local_system: String,27 pub directory: PathBuf,28 pub data: Mutex<FleetData>,29 pub nix_args: Vec<OsString>,30 /// fleet_config.config31 pub config_field: Value,32 // TODO: Remove with connectivity refactor33 pub localhost: String,3435 /// import nixpkgs {system = local};36 pub default_pkgs: Value,3738 pub nix_session: NixSession,39}4041// TODO: Make field not pub42#[derive(Clone)]43pub struct Config(pub Arc<FleetConfigInternals>);4445impl Deref for Config {46 type Target = FleetConfigInternals;4748 fn deref(&self) -> &Self::Target {49 &self.050 }51}5253#[derive(Clone, Copy, Debug)]54pub enum EscalationStrategy {55 Sudo,56 Run0,57 Su,58}5960pub struct ConfigHost {61 config: Config,62 pub name: String,63 groups: OnceCell<Vec<String>>,6465 pub host_config: Option<Value>,66 pub nixos_config: OnceCell<Value>,67 pub pkgs_override: Option<Value>,6869 // TODO: Move command helpers away with connectivity refactor70 pub local: bool,71 pub session: OnceLock<Arc<openssh::Session>>,72}73// TODO: Move command helpers away with connectivity refactor74impl ConfigHost {75 pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {76 // Prefer sudo, as run0 has some gotchas with polkit77 // and too many repeating prompts.78 if (self.find_in_path("sudo").await).is_ok() {79 return Ok(EscalationStrategy::Sudo);80 }81 if (self.find_in_path("run0").await).is_ok() {82 return Ok(EscalationStrategy::Run0);83 }84 Ok(EscalationStrategy::Su)85 }86 async fn open_session(&self) -> Result<Arc<openssh::Session>> {87 assert!(!self.local, "do not open ssh connection to local session");88 // FIXME: TOCTOU89 if let Some(session) = &self.session.get() {90 return Ok((*session).clone());91 };92 let session = SessionBuilder::default();93 let session = session94 .connect(&self.name)95 .await96 .map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;97 let session = Arc::new(session);98 self.session.set(session.clone()).expect("TOCTOU happened");99 Ok(session)100 }101 pub async fn mktemp_dir(&self) -> Result<String> {102 let mut cmd = self.cmd("mktemp").await?;103 cmd.arg("-d");104 let path = cmd.run_string().await?;105 Ok(path.trim_end().to_owned())106 }107 pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {108 let mut cmd = self.cmd("cat").await?;109 cmd.arg(path);110 cmd.run_bytes().await111 }112 pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {113 let mut cmd = self.cmd("cat").await?;114 cmd.arg(path);115 cmd.run_string().await116 }117 pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {118 let mut cmd = self.cmd("ls").await?;119 cmd.arg(path);120 let out = cmd.run_string().await?;121 let mut lines = out.split('\n');122 if let Some(last) = lines.next_back() {123 ensure!(last.is_empty(), "output of ls should end with newline");124 }125 Ok(lines.map(ToOwned::to_owned).collect())126 }127 #[allow(dead_code)]128 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {129 let text = self.read_file_text(path).await?;130 Ok(serde_json::from_str(&text)?)131 }132 pub async fn read_env(&self, env: &str) -> Result<String> {133 let mut cmd = self.cmd("printenv").await?;134 cmd.arg(env);135 cmd.run_string().await136 }137 pub async fn find_in_path(&self, command: &str) -> Result<String> {138 // // `which` is not a part of coreutils, and it might not exist on machine.139 // let path = self.read_env("PATH").await?;140 // // Assuming delimiter is :, we don't work with windows host, this check will be much141 // // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)142 // for ele in path.split(':') {143 // let test_path = format!("{ele}/{cmd}");144 // test -x etc145 // }146 // let mut cmd = self.cmd("printenv").await?;147 // cmd.arg(env);148 // Ok(cmd.run_string().await?)149 // Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.150 let mut cmd = self151 .cmd_escalation(152 // Not used153 EscalationStrategy::Su,154 "which",155 )156 .await?;157 cmd.arg(command);158 cmd.run_string().await159 }160 pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>161 where162 <D as FromStr>::Err: Display,163 {164 let text = self.read_file_text(path).await?;165 D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))166 }167 pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {168 self.cmd_escalation(self.escalation_strategy().await?, cmd)169 .await170 }171 pub async fn cmd_escalation(172 &self,173 escalation: EscalationStrategy,174 cmd: impl AsRef<OsStr>,175 ) -> Result<MyCommand> {176 if self.local {177 Ok(MyCommand::new(escalation, cmd))178 } else {179 let session = self.open_session().await?;180 Ok(MyCommand::new_on(escalation, cmd, session))181 }182 }183184 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {185 ensure!(data.encrypted, "secret is not encrypted");186 let mut cmd = self.cmd("fleet-install-secrets").await?;187 cmd.arg("decrypt").eqarg("--secret", data.to_string());188 let encoded = cmd189 .sudo()190 .run_string()191 .await192 .context("failed to call remote host for decrypt")?;193 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;194 ensure!(!data.encrypted, "secret came out encrypted");195 Ok(data.data)196 }197 pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {198 ensure!(data.encrypted, "secret is not encrypted");199 let mut cmd = self.cmd("fleet-install-secrets").await?;200 cmd.arg("reencrypt").eqarg("--secret", data.to_string());201 for target in targets {202 let key = self.config.key(&target).await?;203 cmd.eqarg("--targets", key);204 }205 let encoded = cmd206 .sudo()207 .run_string()208 .await209 .context("failed to call remote host for decrypt")?;210 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;211 ensure!(data.encrypted, "secret came out not encrypted");212 Ok(data)213 }214 /// Returns path for futureproofing, as path might change i.e on conversion to CA215 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {216 if self.local {217 // Path is located locally, thus already trusted.218 return Ok(path.to_owned());219 }220 let mut nix = MyCommand::new(221 // Not used222 EscalationStrategy::Su,223 "nix",224 );225 nix.arg("copy")226 .arg("--substitute-on-destination")227 .comparg("--to", format!("ssh-ng://{}", self.name))228 .arg(path);229 nix.run_nix().await.context("nix copy")?;230 Ok(path.to_owned())231 }232 pub async fn systemctl_stop(&self, name: &str) -> Result<()> {233 let mut cmd = self.cmd("systemctl").await?;234 cmd.arg("stop").arg(name);235 cmd.sudo().run().await236 }237 pub async fn systemctl_start(&self, name: &str) -> Result<()> {238 let mut cmd = self.cmd("systemctl").await?;239 cmd.arg("start").arg(name);240 cmd.sudo().run().await241 }242243 pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {244 let mut cmd = self.cmd("rm").await?;245 cmd.arg("-f").arg(path);246 if sudo {247 cmd = cmd.sudo()248 }249 cmd.run().await250 }251}252impl ConfigHost {253 // TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,254 // assuming getting tags always returns the same value.255 pub async fn tags(&self) -> Result<Vec<String>> {256 if let Some(v) = self.groups.get() {257 return Ok(v.clone());258 }259 let Some(host_config) = &self.host_config else {260 return Ok(vec![]);261 };262 let tags: Vec<String> = nix_go_json!(host_config.tags);263264 let _ = self.groups.set(tags.clone());265266 Ok(tags)267 }268 pub async fn nixos_config(&self) -> Result<Value> {269 if let Some(v) = self.nixos_config.get() {270 return Ok(v.clone());271 }272 let Some(host_config) = &self.host_config else {273 bail!("local host has no nixos_config");274 };275 let nixos_config = nix_go!(host_config.nixos.config);276 assert_warn("nixos config evaluation", &nixos_config).await?;277278 let _ = self.nixos_config.set(nixos_config.clone());279280 Ok(nixos_config)281 }282283 pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {284 let nixos = self.nixos_config().await?;285 let secrets = nix_go!(nixos.secrets);286 let mut out = Vec::new();287 for name in secrets.list_fields().await? {288 let secret = nix_go!(secrets[{ name }]);289 let is_shared: bool = nix_go_json!(secret.shared);290 if is_shared {291 continue;292 }293 out.push(name);294 }295 Ok(out)296 }297 pub async fn secret_field(&self, name: &str) -> Result<Value> {298 let nixos = self.nixos_config().await?;299 Ok(nix_go!(nixos.secrets[{ name }]))300 }301302 /// Packages for this host, resolved with nixpkgs overlays303 pub async fn pkgs(&self) -> Result<Value> {304 if let Some(value) = &self.pkgs_override {305 return Ok(value.clone());306 }307 let Some(host_config) = &self.host_config else {308 bail!("local host has no host_config");309 };310 // TODO: Should nixos.options be cached?311 Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))312 }313}314315impl Config {316 pub async fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {317 let config = &self.config_field;318 let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);319 Ok(tagged)320 }321 pub async fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {322 let mut out = BTreeSet::new();323 for owner in owners {324 if let Some(tag) = owner.strip_prefix('@') {325 let hosts = self.tagged_hostnames(tag).await?;326 out.extend(hosts);327 } else {328 out.insert(owner);329 }330 }331 Ok(out)332 }333 pub fn local_host(&self) -> ConfigHost {334 ConfigHost {335 config: self.clone(),336 name: "<virtual localhost>".to_owned(),337 host_config: None,338 nixos_config: OnceCell::new(),339 groups: {340 let cell = OnceCell::new();341 let _ = cell.set(vec![]);342 cell343 },344 pkgs_override: Some(self.default_pkgs.clone()),345346 local: true,347 session: OnceLock::new(),348 }349 }350351 pub async fn host(&self, name: &str) -> Result<ConfigHost> {352 let config = &self.config_field;353 let host_config = nix_go!(config.hosts[{ name }]);354355 Ok(ConfigHost {356 config: self.clone(),357 name: name.to_owned(),358 host_config: Some(host_config),359 nixos_config: OnceCell::new(),360 groups: OnceCell::new(),361 pkgs_override: None,362363 // TODO: Remove with connectivit refactor364 local: self.localhost == name,365 session: OnceLock::new(),366 })367 }368 pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {369 let config = &self.config_field;370 let names = nix_go!(config.hosts).list_fields().await?;371 let mut out = vec![];372 for name in names {373 out.push(self.host(&name).await?);374 }375 Ok(out)376 }377 // TODO: Replace usages with .host().nixos_config378 pub async fn system_config(&self, host: &str) -> Result<Value> {379 let fleet_field = &self.config_field;380 Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))381 }382383 /// Shared secrets configured in fleet.nix or in flake384 pub async fn list_configured_shared(&self) -> Result<Vec<String>> {385 let config_field = &self.config_field;386 Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)387 }388 /// Shared secrets configured in fleet.nix389 pub fn list_shared(&self) -> Vec<String> {390 let data = self.data();391 data.shared_secrets.keys().cloned().collect()392 }393 pub fn has_shared(&self, name: &str) -> bool {394 let data = self.data();395 data.shared_secrets.contains_key(name)396 }397 pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {398 let mut data = self.data_mut();399 data.shared_secrets.insert(name.to_owned(), shared);400 }401 pub fn remove_shared(&self, secret: &str) {402 let mut data = self.data_mut();403 data.shared_secrets.remove(secret);404 }405406 pub fn list_secrets(&self, host: &str) -> Vec<String> {407 let data = self.data();408 let Some(secrets) = data.host_secrets.get(host) else {409 return Vec::new();410 };411 secrets.keys().cloned().collect()412 }413414 pub fn has_secret(&self, host: &str, secret: &str) -> bool {415 let data = self.data();416 let Some(host_secrets) = data.host_secrets.get(host) else {417 return false;418 };419 host_secrets.contains_key(secret)420 }421 pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {422 let mut data = self.data_mut();423 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();424 host_secrets.insert(secret, value);425 }426427 pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {428 let data = self.data();429 let Some(host_secrets) = data.host_secrets.get(host) else {430 bail!("no secrets for machine {host}");431 };432 let Some(secret) = host_secrets.get(secret) else {433 bail!("machine {host} has no secret {secret}");434 };435 Ok(secret.clone())436 }437 pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {438 let data = self.data();439 let Some(secret) = data.shared_secrets.get(secret) else {440 bail!("no shared secret {secret}");441 };442 Ok(secret.clone())443 }444 pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {445 let config_field = &self.config_field;446 Ok(nix_go_json!(447 config_field.sharedSecrets[{ secret }].expectedOwners448 ))449 }450451 // TODO: Should this be something modifiable from other processes?452 // E.g terraform provider might want to update FleetData (e.g secrets),453 // and current implementation assumes only one process holds current fleet.nix454 // Given that it is no longer needs to be a file for nix evaluation,455 // maybe it can be a .nix file for persistence, but accessible only456 // thru some shared state controller? Might it be stored in terraform457 // state provider?458 pub fn data(&self) -> MutexGuard<FleetData> {459 self.data.lock().unwrap()460 }461 pub fn data_mut(&self) -> MutexGuard<FleetData> {462 self.data.lock().unwrap()463 }464 pub fn save(&self) -> Result<()> {465 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.")?;466 let data = nixlike::serialize(&self.data() as &FleetData)?;467 tempfile.write_all(468 format!(469 "# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",470 data471 )472 .as_bytes(),473 )?;474 let mut fleet_data_path = self.directory.clone();475 fleet_data_path.push("fleet.nix");476 tempfile.persist(fleet_data_path)?;477 Ok(())478 }479}crates/fleet-base/src/keys.rsdiffbeforeafterboth--- a/crates/fleet-base/src/keys.rs
+++ b/crates/fleet-base/src/keys.rs
@@ -45,6 +45,7 @@
}
pub async fn recipients(&self, hosts: Vec<String>) -> Result<Vec<impl Recipient>> {
+ let hosts = self.expand_owner_set(hosts).await?;
futures::stream::iter(hosts.iter())
.then(|m| self.recipient(m.as_ref()))
.try_collect::<Vec<_>>()
modules/nixos/secrets.nixdiffbeforeafterboth--- a/modules/nixos/secrets.nix
+++ b/modules/nixos/secrets.nix
@@ -41,17 +41,6 @@
type = str;
description = "Secret public data (only available for plaintext)";
};
-
- expectedGenerationData = mkOption {
- type = unspecified;
- description = "Data that gets embedded into secret part";
- default = null;
- };
- generationData = mkOption {
- type = unspecified;
- description = "Data that is embedded into secret part";
- default = null;
- };
};
config = {
hash = hashString "sha1" config.raw;
@@ -91,6 +80,11 @@
default = sysConfig.users.users.${config.owner}.group;
defaultText = literalExpression "config.users.users.$${owner}.group";
};
+ expectedGenerationData = mkOption {
+ type = unspecified;
+ description = "Data that gets embedded into secret part";
+ default = null;
+ };
};
});
processPart = part: {
modules/secrets-data.nixdiffbeforeafterboth--- a/modules/secrets-data.nix
+++ b/modules/secrets-data.nix
@@ -6,7 +6,7 @@
}: let
inherit (fleetLib.options) mkDataOption;
inherit (lib.options) mkOption;
- inherit (lib.types) nullOr listOf str attrsOf submodule bool;
+ inherit (lib.types) nullOr listOf str attrsOf submodule bool unspecified;
inherit (lib.attrsets) mapAttrsToList mapAttrs filterAttrs genAttrs;
inherit (lib.lists) sort unique concatLists;
inherit (lib.strings) toJSON;
@@ -46,6 +46,11 @@
'';
default = [];
};
+ generationData = mkOption {
+ type = unspecified;
+ description = "Data that is embedded into secret part";
+ default = null;
+ };
};
};
@@ -67,6 +72,11 @@
description = "On which date this secret will expire, someone should regenerate this secret before it expires.";
default = false;
};
+ generationData = mkOption {
+ type = unspecified;
+ description = "Data that is embedded into secret part";
+ default = null;
+ };
};
};
in {
@@ -93,12 +103,19 @@
});
config = {
assertions =
- mapAttrsToList
- (name: secret: {
- assertion = secret.expectedOwners == null || sort (a: b: a < b) config.data.sharedSecrets.${name}.owners == sort (a: b: a < b) secret.expectedOwners;
- message = "Shared secret ${name} is expected to be encrypted for ${toJSON secret.expectedOwners}, but it is encrypted for ${toJSON config.data.sharedSecrets.${name}.owners}. Run fleet secrets regenerate to fix";
- })
- config.sharedSecrets;
+ (mapAttrsToList
+ (name: secret: {
+ assertion = secret.expectedOwners == null || sort (a: b: a < b) config.data.sharedSecrets.${name}.owners == sort (a: b: a < b) secret.expectedOwners;
+ message = "Shared secret ${name} is expected to be encrypted for ${toJSON secret.expectedOwners}, but it is encrypted for ${toJSON config.data.sharedSecrets.${name}.owners}. Run fleet secrets regenerate to fix";
+ })
+ config.sharedSecrets)
+ ++ (mapAttrsToList
+ (name: secret: {
+ # TODO: Same aassertion should be in host secrets
+ assertion = config.data.sharedSecrets.${name}.generationData == secret.expectedGenerationData;
+ message = "Shared secret ${name} has unexpected generation data ${toJSON secret.expectedGenerationData} != ${toJSON config.data.sharedSecrets.${name}.expectedGenerationData}. Run fleet secrets regenerate to fix";
+ })
+ config.sharedSecrets);
sharedSecrets =
mapAttrs (_: _: {}) config.data.sharedSecrets;
};
modules/secrets.nixdiffbeforeafterboth--- a/modules/secrets.nix
+++ b/modules/secrets.nix
@@ -45,6 +45,11 @@
description = "Derivation to evaluate for secret generation";
default = null;
};
+ expectedGenerationData = mkOption {
+ type = unspecified;
+ description = "Data that gets embedded into secret part";
+ default = null;
+ };
};
};
in {