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.rsdiffbeforeafterboth--- a/crates/fleet-base/src/host.rs
+++ b/crates/fleet-base/src/host.rs
@@ -1,5 +1,6 @@
use std::{
cell::OnceCell,
+ collections::BTreeSet,
ffi::{OsStr, OsString},
fmt::Display,
io::Write,
@@ -312,6 +313,23 @@
}
impl Config {
+ pub async fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {
+ let config = &self.config_field;
+ let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);
+ Ok(tagged)
+ }
+ pub async fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {
+ let mut out = BTreeSet::new();
+ for owner in owners {
+ if let Some(tag) = owner.strip_prefix('@') {
+ let hosts = self.tagged_hostnames(tag).await?;
+ out.extend(hosts);
+ } else {
+ out.insert(owner);
+ }
+ }
+ Ok(out)
+ }
pub fn local_host(&self) -> ConfigHost {
ConfigHost {
config: self.clone(),
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.nixdiffbeforeafterboth1{2 lib,3 fleetLib,4 config,5 pkgs,6 ...7}: let8 inherit (builtins) hashString;9 inherit (lib.stringsWithDeps) stringAfter;10 inherit (lib.options) mkOption literalExpression;11 inherit (lib.lists) optional;12 inherit (lib.attrsets) mapAttrs;13 inherit (lib.modules) mkIf;14 inherit (lib.types) submodule str attrsOf nullOr unspecified lazyAttrsOf;15 inherit (fleetLib.strings) decodeRawSecret;1617 sysConfig = config;18 secretPartType = secretName:19 submodule ({config, ...}: let20 partName = config._module.args.name;21 in {22 options = {23 raw = mkOption {24 type = str;25 internal = true;26 description = "Encoded & Encrypted secret part data, passed from fleet.nix";27 };28 hash = mkOption {29 type = str;30 description = "Hash of secret in encoded format";31 };32 path = mkOption {33 type = str;34 description = "Path to secret part, incorporating data hash (thus it will be updated on secret change)";35 };36 stablePath = mkOption {37 type = str;38 description = "Path to secret part, incorporating data hash (thus it will be updated on secret change)";39 };40 data = mkOption {41 type = str;42 description = "Secret public data (only available for plaintext)";43 };4445 expectedGenerationData = mkOption {46 type = unspecified;47 description = "Data that gets embedded into secret part";48 default = null;49 };50 generationData = mkOption {51 type = unspecified;52 description = "Data that is embedded into secret part";53 default = null;54 };55 };56 config = {57 hash = hashString "sha1" config.raw;58 data = decodeRawSecret config.raw;59 path = "/run/secrets/${secretName}/${config.hash}-${partName}";60 stablePath = "/run/secrets/${secretName}/${partName}";61 };62 });63 secretType = submodule ({config, ...}: let64 secretName = config._module.args.name;65 in {66 freeformType = lazyAttrsOf (secretPartType secretName);67 options = {68 shared = mkOption {69 description = "Is this secret owned by this machine, or propagated from shared secrets";70 default = false;71 };7273 generator = mkOption {74 type = nullOr unspecified;75 description = "Derivation to evaluate for secret generation";76 default = null;77 };78 mode = mkOption {79 type = str;80 description = "Secret mode";81 default = "0440";82 };83 owner = mkOption {84 type = str;85 description = "Owner of the secret";86 default = "root";87 };88 group = mkOption {89 type = str;90 description = "Group of the secret";91 default = sysConfig.users.users.${config.owner}.group;92 defaultText = literalExpression "config.users.users.$${owner}.group";93 };94 };95 });96 processPart = part: {97 inherit (part) raw path stablePath;98 };99 processSecret = secret:100 {101 inherit (secret) group mode owner;102 }103 // (mapAttrs (_: processPart) (removeAttrs secret [104 "shared"105 "generator"106 "mode"107 "group"108 "owner"109 ]));110 secretsFile = pkgs.writeTextFile {111 name = "secrets.json";112 text =113 builtins.toJSON (mapAttrs (_: processSecret)114 config.secrets);115 };116 useSysusers = (config.systemd ? sysusers && config.systemd.sysusers.enable) || (config ? userborn && config.userborn.enable);117in {118 options = {119 secrets = mkOption {120 type = attrsOf secretType;121 default = {};122 description = "Host-local secrets";123 };124 };125 config = {126 environment.systemPackages = [pkgs.fleet-install-secrets];127128 systemd.services.fleet-install-secrets = mkIf useSysusers {129 wantedBy = ["sysinit.target"];130 after = ["systemd-sysusers.service"];131 restartTriggers = [132 secretsFile133 ];134 aliases = [135 "sops-install-secrets"136 "agenix-install-secrets"137 ];138139 unitConfig.DefaultDependencies = false;140141 serviceConfig = {142 Type = "oneshot";143 RemainAfterExit = true;144 ExecStart = "${pkgs.fleet-install-secrets}/bin/fleet-install-secrets install ${secretsFile}";145 };146 };147 system.activationScripts.decryptSecrets =148 mkIf (!useSysusers)149 (150 stringAfter (151 [152 # secrets are owned by user/group, thus we need to refer to those153 "users"154 "groups"155 "specialfs"156 ]157 # nixos-impermanence compatibility: secrets are encrypted by host-key,158 # but with impermanence we expect that the host-key is installed by159 # persist-file activation script.160 ++ (optional (config.system.activationScripts ? "persist-files") "persist-files")161 ) ''162 1>&2 echo "setting up secrets"163 ${pkgs.fleet-install-secrets}/bin/fleet-install-secrets install ${secretsFile}164 ''165 );166 };167}1{2 lib,3 fleetLib,4 config,5 pkgs,6 ...7}: let8 inherit (builtins) hashString;9 inherit (lib.stringsWithDeps) stringAfter;10 inherit (lib.options) mkOption literalExpression;11 inherit (lib.lists) optional;12 inherit (lib.attrsets) mapAttrs;13 inherit (lib.modules) mkIf;14 inherit (lib.types) submodule str attrsOf nullOr unspecified lazyAttrsOf;15 inherit (fleetLib.strings) decodeRawSecret;1617 sysConfig = config;18 secretPartType = secretName:19 submodule ({config, ...}: let20 partName = config._module.args.name;21 in {22 options = {23 raw = mkOption {24 type = str;25 internal = true;26 description = "Encoded & Encrypted secret part data, passed from fleet.nix";27 };28 hash = mkOption {29 type = str;30 description = "Hash of secret in encoded format";31 };32 path = mkOption {33 type = str;34 description = "Path to secret part, incorporating data hash (thus it will be updated on secret change)";35 };36 stablePath = mkOption {37 type = str;38 description = "Path to secret part, incorporating data hash (thus it will be updated on secret change)";39 };40 data = mkOption {41 type = str;42 description = "Secret public data (only available for plaintext)";43 };44 };45 config = {46 hash = hashString "sha1" config.raw;47 data = decodeRawSecret config.raw;48 path = "/run/secrets/${secretName}/${config.hash}-${partName}";49 stablePath = "/run/secrets/${secretName}/${partName}";50 };51 });52 secretType = submodule ({config, ...}: let53 secretName = config._module.args.name;54 in {55 freeformType = lazyAttrsOf (secretPartType secretName);56 options = {57 shared = mkOption {58 description = "Is this secret owned by this machine, or propagated from shared secrets";59 default = false;60 };6162 generator = mkOption {63 type = nullOr unspecified;64 description = "Derivation to evaluate for secret generation";65 default = null;66 };67 mode = mkOption {68 type = str;69 description = "Secret mode";70 default = "0440";71 };72 owner = mkOption {73 type = str;74 description = "Owner of the secret";75 default = "root";76 };77 group = mkOption {78 type = str;79 description = "Group of the secret";80 default = sysConfig.users.users.${config.owner}.group;81 defaultText = literalExpression "config.users.users.$${owner}.group";82 };83 expectedGenerationData = mkOption {84 type = unspecified;85 description = "Data that gets embedded into secret part";86 default = null;87 };88 };89 });90 processPart = part: {91 inherit (part) raw path stablePath;92 };93 processSecret = secret:94 {95 inherit (secret) group mode owner;96 }97 // (mapAttrs (_: processPart) (removeAttrs secret [98 "shared"99 "generator"100 "mode"101 "group"102 "owner"103 ]));104 secretsFile = pkgs.writeTextFile {105 name = "secrets.json";106 text =107 builtins.toJSON (mapAttrs (_: processSecret)108 config.secrets);109 };110 useSysusers = (config.systemd ? sysusers && config.systemd.sysusers.enable) || (config ? userborn && config.userborn.enable);111in {112 options = {113 secrets = mkOption {114 type = attrsOf secretType;115 default = {};116 description = "Host-local secrets";117 };118 };119 config = {120 environment.systemPackages = [pkgs.fleet-install-secrets];121122 systemd.services.fleet-install-secrets = mkIf useSysusers {123 wantedBy = ["sysinit.target"];124 after = ["systemd-sysusers.service"];125 restartTriggers = [126 secretsFile127 ];128 aliases = [129 "sops-install-secrets"130 "agenix-install-secrets"131 ];132133 unitConfig.DefaultDependencies = false;134135 serviceConfig = {136 Type = "oneshot";137 RemainAfterExit = true;138 ExecStart = "${pkgs.fleet-install-secrets}/bin/fleet-install-secrets install ${secretsFile}";139 };140 };141 system.activationScripts.decryptSecrets =142 mkIf (!useSysusers)143 (144 stringAfter (145 [146 # secrets are owned by user/group, thus we need to refer to those147 "users"148 "groups"149 "specialfs"150 ]151 # nixos-impermanence compatibility: secrets are encrypted by host-key,152 # but with impermanence we expect that the host-key is installed by153 # persist-file activation script.154 ++ (optional (config.system.activationScripts ? "persist-files") "persist-files")155 ) ''156 1>&2 echo "setting up secrets"157 ${pkgs.fleet-install-secrets}/bin/fleet-install-secrets install ${secretsFile}158 ''159 );160 };161}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 {