diff --git a/Cargo.lock b/Cargo.lock index 1cdd132..2d22c33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1599,6 +1599,7 @@ dependencies = [ "ssh-key", "thiserror", "tokio", + "tokio-util", "tracing", "tracing-opentelemetry", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 2898c1a..9015c63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ mimalloc.version = "^0.1" mimalloc.features = ["secure"] tokio.version = "^1" tokio.features = ["macros", "rt-multi-thread"] +tokio-util.version = '^0.7' axum.version = "^0.8" ## Tracing tracing.version = "^0.1" diff --git a/kuyaseg-controller/Cargo.toml b/kuyaseg-controller/Cargo.toml index 0e6e153..5435d05 100644 --- a/kuyaseg-controller/Cargo.toml +++ b/kuyaseg-controller/Cargo.toml @@ -18,6 +18,7 @@ path = "src/bin/generate_crds.rs" ## Rt mimalloc.workspace = true tokio.workspace = true +tokio-util.workspace = true axum.workspace = true ## Tracing tracing.workspace = true diff --git a/kuyaseg-controller/src/bin/generate_crds.rs b/kuyaseg-controller/src/bin/generate_crds.rs index e71fdf5..4390cf7 100644 --- a/kuyaseg-controller/src/bin/generate_crds.rs +++ b/kuyaseg-controller/src/bin/generate_crds.rs @@ -1 +1,14 @@ -fn main() {} \ No newline at end of file +use kube::CustomResourceExt; + +fn main() { + print!( + "{}", + serde_yaml::to_string(&kuyaseg_controller::resource::generated::secret::v1alpha1::RandomSecret::crd()) + .expect("RandomSecret v1alpha1 CRD") + ); + print!( + "{}", + serde_yaml::to_string(&kuyaseg_controller::resource::generated::secret::v1alpha1::DeterministicSecret::crd()) + .expect("DeterministicSecret v1alpha1 CRD") + ) +} diff --git a/kuyaseg-controller/src/resource/generated/mod.rs b/kuyaseg-controller/src/resource/generated/mod.rs new file mode 100644 index 0000000..899b535 --- /dev/null +++ b/kuyaseg-controller/src/resource/generated/mod.rs @@ -0,0 +1,2 @@ +pub mod secret; +pub mod ssh; diff --git a/kuyaseg-controller/src/resource/generated/secret/mod.rs b/kuyaseg-controller/src/resource/generated/secret/mod.rs new file mode 100644 index 0000000..cfb12e3 --- /dev/null +++ b/kuyaseg-controller/src/resource/generated/secret/mod.rs @@ -0,0 +1,577 @@ +use std::collections::{ + BTreeMap, + HashMap, +}; + +use anyhow::Context; +use kube::{ + Api, + Resource, +}; +use schemars::JsonSchema; +use serde::de::DeserializeOwned; +use serde::{ + Deserialize, + Serialize, +}; +use serde_json::json; +use zeroize::Zeroize; + +pub mod v1alpha1; + +use crate::utils::zeroizers::{ + self, + zeroize_btree, + zeroize_hashmap, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct GeneratedSecretSpec { + #[serde(default)] + pub keys: Vec, + #[serde(default)] + pub template: GeneratedSecretTemplate, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct GeneratedSecretStatus { + /// Stored all generated keys for matching v1/Secret resource, hashed by key name. + /// The matcher value is (version, shape hash) + /// + /// Shape hash is obtained with Blake3 hash. + pub generated: BTreeMap, +} + +impl GeneratedSecretStatus { + /// Determine whether the key from spec was already generated, based on: + /// - Key name + /// - Key version + /// - Key shape hash + fn was_generated(&self, spec: &GeneratedSecretKey) -> bool { + let Some(stored) = self.generated.get(&spec.key) else { + return false; + }; + + (stored.0 == spec.version) + && aws_lc_rs::constant_time::verify_slices_are_equal( + stored.1.as_bytes(), + spec.shape.stable_hash().as_bytes(), + ) + .is_ok() + } +} + +impl GeneratedSecretSpec { + /// Reconcile generated secret from specification, following the specific type `T` generation strategy. + /// This method propagates upstream. + /// + /// This method family assumes that upstream resource has `GeneratedSecretStatus` status. + /// + /// I wrote this because - hell no - I won't write this code twice for both Random and Deterministic secrets! + async fn reconcile_spec(&self, name: &str, namespace: &str, client: kube::Client) -> anyhow::Result<()> + where + T: HasSecretGenerator + + Resource + + DeserializeOwned, + { + // discover target secret & branch for update/create + let target_api = Api::::namespaced(client.clone(), namespace); + let maybe_secret = scopeguard::guard( + { + target_api + .get_opt(name) + .await + .inspect_err(|e| { + tracing::error!( + debug=?e, + critical = true, + reason = "SecretDiscoveryFailed", + action = "Reconcile", + "Failed to discover target Secret" + ) + }) + .context("Discover target secret")? + }, + |mut v| { + v.as_mut().map(zeroizers::zeroize_k8s_secret); + }, + ); + + match maybe_secret.as_ref() { + Some(yep) => self + .update_from_spec::(name, namespace, client, yep) + .await + .context("Update existing Secret")?, + None => self + .create_new_from_spec::(name, namespace, client) + .await + .context("Update existing Secret")?, + } + + Ok(()) + } + + async fn create_new_from_spec(&self, name: &str, namespace: &str, client: kube::Client) -> anyhow::Result<()> + where + T: HasSecretGenerator + + Resource + + DeserializeOwned, + { + let self_api = Api::::namespaced(client.clone(), namespace); + let target_api = Api::::namespaced(client.clone(), namespace); + + let generated = self + .keys + .iter() + .map(|key| (key.key.clone(), (key.version, key.shape.stable_hash()))) + .collect::>(); + + // first we try with dry-run, no need to guard as no data here + let partial_target = self.template.to_safe_partial_secret(Some(false), Some("Opaque".into())); + let patch = kube::api::Patch::Apply(json!({ + "apiVersion": T::api_version(&()), + "kind": T::kind(&()), + "status": GeneratedSecretStatus { + generated + } + })); + + // dry-run + { + target_api + .create( + &kube::api::PostParams { + dry_run: true, + field_manager: Some(env!("CARGO_PKG_NAME").into()), + }, + &partial_target, + ) + .await + .inspect_err(|e| { + tracing::error!( + debug=?e, + reason = "SecretCreationFailed", + action = "CreateNew", + "Failed to create Secret resource" + ) + }) + .context("Dry-run create Secret")?; + + self_api + .patch_status( + name, + &kube::api::PatchParams { + dry_run: true, + force: true, + field_manager: Some(env!("CARGO_PKG_NAME").into()), + field_validation: Some(kube::api::ValidationDirective::Strict), + }, + &patch, + ) + .await.inspect_err(|e| tracing::error!(debug=?e, reason="SelfUpdateFailed", action = "CreateNew", "Failed to update self status")) + .context("Dry-run update status")?; + } + + // desperate run + { + let target = scopeguard::guard( + k8s_openapi::api::core::v1::Secret { + data: Some(T::generate_data(self).context("Create new Secret")?), + ..partial_target + }, + |mut v| zeroizers::zeroize_k8s_secret(&mut v), + ); + + target_api + .create( + &kube::api::PostParams { + dry_run: false, + field_manager: Some(env!("CARGO_PKG_NAME").into()), + }, + &target, + ) + .await + .inspect_err(|e| { + tracing::error!( + debug=?e, + reason = "SecretCreationFailed", + action = "CreateNew", + "Failed to create Secret resource" + ) + }) + .context("Create Secret")?; + + // TODO: delete created secret if this fails or not..? + self_api + .patch_status( + name, + &kube::api::PatchParams { + dry_run: false, + force: true, + field_manager: Some(env!("CARGO_PKG_NAME").into()), + field_validation: Some(kube::api::ValidationDirective::Strict), + }, + &patch, + ) + .await.inspect_err(|e| tracing::error!(debug=?e, reason="SelfUpdateFailed", action = "CreateNew", "Failed to update self status")) + .context("Update status")?; + } + + Ok(()) + } + + async fn update_from_spec( + &self, + name: &str, + namespace: &str, + client: kube::Client, + old_ref: &k8s_openapi::api::core::v1::Secret, + ) -> anyhow::Result<()> + where + T: HasSecretGenerator + + Resource + + DeserializeOwned, + { + todo!("WIP") + } +} + +pub(super) trait HasSecretGenerator { + fn generate_data(spec: &GeneratedSecretSpec) -> anyhow::Result>; +} + +#[cfg_attr(any(debug_assertions, test), derive(Debug, Eq, PartialEq))] +#[derive(Default, Clone, Serialize, Deserialize, JsonSchema)] +pub struct GeneratedSecretTemplate { + /// Add custom metadata to generated secret + #[serde(default)] + pub metadata: GeneratedSecretMetadata, + /// Base64 (standard+pad) encoded data + #[serde(default)] + pub data: HashMap, + /// Verbatim string data, which will be incoded as bytes into secret + #[serde(rename = "stringData")] + #[serde(default)] + pub string_data: BTreeMap, +} + +impl GeneratedSecretTemplate { + /// Does not retain data. + pub fn to_safe_partial_secret( + &self, + immutable: Option, + type_: Option, + ) -> k8s_openapi::api::core::v1::Secret { + k8s_openapi::api::core::v1::Secret { + type_, + immutable, + data: None, + metadata: self.metadata.clone().into_partial_metadata(), + string_data: None, + } + } +} + +impl Zeroize for GeneratedSecretTemplate { + fn zeroize(&mut self) { + zeroize_btree(&mut self.string_data); + zeroize_hashmap(&mut self.data); + } +} + +impl Drop for GeneratedSecretTemplate { + fn drop(&mut self) { + self.zeroize(); + } +} + +#[cfg_attr(any(debug_assertions, test), derive(Debug, Eq, PartialEq))] +#[derive(Default, Clone, Serialize, Deserialize, JsonSchema)] +pub struct GeneratedSecretMetadata { + /// Add annotations to generated secret + #[serde(default)] + pub annotations: BTreeMap, + /// Add labels to generated secret + #[serde(default)] + pub labels: BTreeMap, +} + +impl GeneratedSecretMetadata { + pub fn into_partial_metadata(self) -> kube::api::ObjectMeta { + kube::api::ObjectMeta { + annotations: Some(self.annotations), + labels: Some(self.labels), + ..Default::default() + } + } +} + +#[cfg_attr(any(debug_assertions, test), derive(Debug, PartialEq, Eq))] +#[derive(Clone, Serialize, Deserialize, JsonSchema)] +pub struct GeneratedSecretKey { + /// The name of the key to write generated value into + pub key: String, + /// The version of generated value. Changing this will force new value to be generated + pub version: usize, + /// The type/kind specification for the generated value. May be `password` or `bytes`. Contains subfields + pub shape: GeneratedSecretShape, +} + +#[cfg_attr(any(debug_assertions, test), derive(Debug, PartialEq, Eq))] +#[derive(Clone, Serialize, Deserialize, JsonSchema)] +pub enum GeneratedSecretShape { + Password { + /// The length of password in characters. Defaults to 32 + #[serde(default = "GeneratedSecretShape::default_pwd_length")] + length: usize, + /// The string of characters generator allowed to use. Defaults to alphanumeric lowercase+uppercase + #[serde(default = "GeneratedSecretShape::default_pwd_dict")] + dictionary: String, + }, + Bytes { + /// The size of secret in bytes. Defaults to 64 + #[serde(default = "GeneratedSecretShape::default_bytes_size")] + size: usize, + }, +} + +impl GeneratedSecretShape { + /// Base64 encoded stable (blake3) hash + pub fn stable_hash(&self) -> String { + use base64::prelude::*; + + let hash = { + let mut hasher = blake3::Hasher::new(); + match self { + Self::Bytes { size } => { + hasher.update(&size.to_le_bytes()); + } + Self::Password { length, dictionary } => { + hasher.update(&length.to_le_bytes()); + hasher.update(dictionary.as_bytes()); + } + } + hasher.finalize() + }; + + BASE64_STANDARD.encode(hash.as_bytes()) + } + + pub fn default_pwd_length() -> usize { + 32 + } + + pub fn default_pwd_dict() -> String { + String::from("abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ") + } + + pub fn default_bytes_size() -> usize { + 64 + } +} + +impl Zeroize for GeneratedSecretShape { + fn zeroize(&mut self) { + match self { + Self::Bytes { size } => { + size.zeroize(); + } + Self::Password { length, dictionary } => { + length.zeroize(); + dictionary.zeroize(); + } + } + } +} + +impl Drop for GeneratedSecretShape { + fn drop(&mut self) { + self.zeroize(); + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn deser_template_metadata() { + // Full sample + let sample = " + annotations: + my.custom-annotation: value + labels: + some-custom.label: value + "; + let expect = GeneratedSecretMetadata { + annotations: BTreeMap::from_iter([("my.custom-annotation".into(), "value".into())]), + labels: BTreeMap::from_iter([("some-custom.label".into(), "value".into())]), + }; + let parsed: GeneratedSecretMetadata = serde_yaml::from_str(sample).unwrap(); + assert_eq!(parsed, expect, "Full sample didn't match"); + + // Empty annotations & labels + let sample = " + annotations: {} + labels: {} + "; + let expect = GeneratedSecretMetadata { + annotations: BTreeMap::new(), + labels: BTreeMap::new(), + }; + let parsed: GeneratedSecretMetadata = serde_yaml::from_str(sample).unwrap(); + assert_eq!(parsed, expect, "Empty annotations&labels didn't match"); + + // Empty + let sample = ""; + let expect = GeneratedSecretMetadata::default(); + let parsed: GeneratedSecretMetadata = serde_yaml::from_str(sample).unwrap(); + assert_eq!(parsed, expect, "Empty didn't match"); + } + + #[test] + fn deser_template() { + // full sample + let sample = " + data: + key0: abcde + stringData: + key1: fghij + metadata: + annotations: + my.custom-annotation: value + labels: + some-custom.label: value + "; + let expect = GeneratedSecretTemplate { + data: HashMap::from_iter([("key0".into(), "abcde".into())]), + string_data: BTreeMap::from_iter([("key1".into(), "fghij".into())]), + metadata: GeneratedSecretMetadata { + annotations: BTreeMap::from_iter([("my.custom-annotation".into(), "value".into())]), + labels: BTreeMap::from_iter([("some-custom.label".into(), "value".into())]), + }, + }; + let parsed: GeneratedSecretTemplate = serde_yaml::from_str(sample).unwrap(); + assert_eq!(parsed, expect, "Full sample didn't match"); + + // Empty metadata + let sample = " + data: + key0: abcde + stringData: + key1: fghij + metadata: {} + "; + let expect = GeneratedSecretTemplate { + data: HashMap::from_iter([("key0".into(), "abcde".into())]), + string_data: BTreeMap::from_iter([("key1".into(), "fghij".into())]), + metadata: GeneratedSecretMetadata::default(), + }; + let parsed: GeneratedSecretTemplate = serde_yaml::from_str(sample).unwrap(); + assert_eq!(parsed, expect, "Empty metadata didn't match"); + + // No metadata + let sample = " + data: + key0: abcde + stringData: + key1: fghij + "; + let expect = GeneratedSecretTemplate { + data: HashMap::from_iter([("key0".into(), "abcde".into())]), + string_data: BTreeMap::from_iter([("key1".into(), "fghij".into())]), + metadata: GeneratedSecretMetadata::default(), + }; + let parsed: GeneratedSecretTemplate = serde_yaml::from_str(sample).unwrap(); + assert_eq!(parsed, expect, "No metadata didn't match"); + + // Empty data & stringdata + let sample = " + data: {} + stringData: {} + "; + let expect = GeneratedSecretTemplate { + data: HashMap::new(), + string_data: BTreeMap::new(), + metadata: GeneratedSecretMetadata::default(), + }; + let parsed: GeneratedSecretTemplate = serde_yaml::from_str(sample).unwrap(); + assert_eq!(parsed, expect, "Empty data&stringData didn't match"); + + // Empty + let sample = ""; + let expect = GeneratedSecretTemplate::default(); + let parsed: GeneratedSecretTemplate = serde_yaml::from_str(sample).unwrap(); + assert_eq!(parsed, expect, "Empty didn't match"); + } + + #[test] + fn deser_key_spec() { + // Full password + let sample = " + key: aabbcc + version: 12 + shape: !Password + length: 123 + dictionary: abcdefg_1 + "; + let expect = GeneratedSecretKey { + key: "aabbcc".into(), + version: 12, + shape: GeneratedSecretShape::Password { + length: 123, + dictionary: "abcdefg_1".into(), + }, + }; + let parsed: GeneratedSecretKey = serde_yaml::from_str(sample).unwrap(); + assert_eq!(expect, parsed, "Full password mismatch"); + + // Empty password + let sample = " + key: aabbcc + version: 12 + shape: !Password + "; + let expect = GeneratedSecretKey { + key: "aabbcc".into(), + version: 12, + shape: GeneratedSecretShape::Password { + length: GeneratedSecretShape::default_pwd_length(), + dictionary: GeneratedSecretShape::default_pwd_dict(), + }, + }; + let parsed: GeneratedSecretKey = serde_yaml::from_str(sample).unwrap(); + assert_eq!(expect, parsed, "Empty password mismatch"); + + // Full bytes + let sample = " + key: aabbcc + version: 12 + shape: !Bytes + size: 1234 + "; + let expect = GeneratedSecretKey { + key: "aabbcc".into(), + version: 12, + shape: GeneratedSecretShape::Bytes { size: 1234 }, + }; + let parsed: GeneratedSecretKey = serde_yaml::from_str(sample).unwrap(); + assert_eq!(expect, parsed, "Full bytes mismatch"); + + // Empty bytes + let sample = " + key: aabbcc + version: 12 + shape: !Bytes + "; + let expect = GeneratedSecretKey { + key: "aabbcc".into(), + version: 12, + shape: GeneratedSecretShape::Bytes { + size: GeneratedSecretShape::default_bytes_size(), + }, + }; + let parsed: GeneratedSecretKey = serde_yaml::from_str(sample).unwrap(); + assert_eq!(expect, parsed, "Empty bytes mismatch"); + } +} diff --git a/kuyaseg-controller/src/resource/generated/secret/v1alpha1.rs b/kuyaseg-controller/src/resource/generated/secret/v1alpha1.rs new file mode 100644 index 0000000..5df999b --- /dev/null +++ b/kuyaseg-controller/src/resource/generated/secret/v1alpha1.rs @@ -0,0 +1,182 @@ +use std::collections::BTreeMap; + +use anyhow::Context; +use k8s_openapi::ByteString; +use kube::runtime::controller::Action; +use kube::{ + Client, + CustomResource, + Resource, + ResourceExt, +}; +use rand::prelude::SeedableRng; +use rand_hc::Hc128Rng; +use schemars::JsonSchema; +use serde::{ + Deserialize, + Serialize, +}; + +use crate::generator; +use crate::resource::generated::secret::{ + GeneratedSecretShape, + GeneratedSecretSpec, + GeneratedSecretStatus, + HasSecretGenerator, +}; + +/// Spec to generate random v1/Secret resource. +/// The resource is generated with the same name in the same namespace as this CR is deployed. +/// +/// The generated Secret data will change: +/// - On next reconcilation when: +/// - Generated v1/Secret was deleted (all regen) +/// - Key spec was changed (per-key regen) +/// +/// Altering the `template` *will not* change generated values +#[derive(Debug, Clone, Serialize, Deserialize, CustomResource, JsonSchema)] +#[kube( + namespaced, + kind = "RandomSecret", + group = "generator.kuyaseg.spushk.ru", + version = "v1alpha1", + status = "GeneratedSecretStatus" +)] +#[kube( + singular = "randomsecret", + plural = "randomsecrets", + shortname = "randsec", + category = "all", + category = "generators", + category = "secret-generators" +)] +#[serde(transparent)] +pub struct RandomSecretSpec(GeneratedSecretSpec); + +impl RandomSecret { + pub async fn reconcile(&self, client: Client) -> anyhow::Result { + let kind = Self::kind(&()); + let api = Self::api_version(&()); + let name = &self.name_any(); + let _span_nons = + tracing::info_span!("RandomSecret.reconcile", kind = kind.as_ref(), api = api.as_ref(), name).entered(); + + let Some(namespace) = self.namespace() else { + tracing::error!( + reason = "RandomSecretNeedNamespace", + action = "Reconcile", + "RandomSecret must have namespace" + ); + return Err(anyhow::Error::msg("RandomSecret needed namespace")); + }; + let _span = tracing::info_span!("RandomSecret.reconcile.ns", ns = namespace).entered(); + + self.spec.0.reconcile_spec::(name, &namespace, client).await.inspect_err(|e| tracing::error!(debug=?e, reason="RandomSecretReconcilationFailed", action = "Reconcile", "Failed to reconcile RandomSecret {namespace}{name}")).context("Reconcile")?; + + Ok(Action::await_change()) + } +} + +impl HasSecretGenerator for RandomSecret { + fn generate_data(spec: &GeneratedSecretSpec) -> anyhow::Result> { + let mut rng = generator::AwsRng::new(); + let mut data = BTreeMap::new(); + + for key in spec.keys.iter() { + match &key.shape { + GeneratedSecretShape::Bytes { size } => { + data.insert(key.key.clone(), ByteString(generator::bytes(&mut rng, *size))); + } + GeneratedSecretShape::Password { length, dictionary } => { + data.insert(key.key.clone(), ByteString(generator::str_bytes(&mut rng, *length, dictionary.chars()).inspect_err(|e| tracing::error!(debug=?e, reason="PasswordGenFailed", action="Generate", "Failed to generate new random password")).context("Generate password")?)); + } + } + } + + Ok(data) + } +} + +/// Spec to generate DETERMINISTIC v1/Secret resource. +/// The resource is generated with the same name in the same namespace as this CR is deployed. +/// +/// The generated Secret data will change on next reconcilation when key spec was changed (per-key regen) +/// +/// Altering the `template` *will not* change generated values. +/// Removing generated v1/Secret *will not* change generated values. +#[derive(Debug, Clone, Serialize, Deserialize, CustomResource, JsonSchema)] +#[kube( + namespaced, + kind = "DeterministicSecret", + group = "generator.kuyaseg.spushk.ru", + version = "v1alpha1", + status = "GeneratedSecretStatus" +)] +#[kube( + singular = "deterministicsecret", + plural = "deterministicsecrets", + shortname = "detsec", + category = "all", + category = "generators", + category = "secret-generators" +)] +#[serde(transparent)] +pub struct DeterministicSecretSpec(GeneratedSecretSpec); + +impl DeterministicSecret { + pub async fn reconcile(&self, client: Client) -> anyhow::Result { + let kind = Self::kind(&()); + let api = Self::api_version(&()); + let name = &self.name_any(); + let _span_nons = tracing::info_span!( + "DeterministicSecret.reconcile", + kind = kind.as_ref(), + api = api.as_ref(), + name + ) + .entered(); + + let Some(namespace) = self.namespace() else { + tracing::error!( + reason = "DeterministicSecretNeedNamespace", + action = "Reconcile", + "DeterministicSecret must have namespace" + ); + return Err(anyhow::Error::msg("DeterministicSecret needed namespace")); + }; + let _span = tracing::info_span!("DeterministicSecret.reconcile.ns", ns = namespace).entered(); + + self.spec.0.reconcile_spec::(name, &namespace, client).await.inspect_err(|e| tracing::error!(debug=?e, reason="DeterministicSecretReconcilationFailed", action = "Reconcile", "Failed to reconcile DeterministicSecret {namespace}{name}")).context("Reconcile")?; + + Ok(Action::await_change()) + } +} + +impl HasSecretGenerator for DeterministicSecret { + fn generate_data(spec: &GeneratedSecretSpec) -> anyhow::Result> { + let seed = std::env::var("KUYASEG_SEED").inspect_err(|e| tracing::error!(debug=?e, reason="DeterministicSecretNeedSeed", "Refuse to generate deterministic secret without seet. Set with KUYASEG_SEED env")).context("Get external seed")?; + let mut data = BTreeMap::new(); + + for key in spec.keys.iter() { + let mut rng = { + let mut hasher = blake3::Hasher::new(); + hasher.update(seed.as_bytes()); + hasher.update(key.key.as_bytes()); + hasher.update(&key.version.to_le_bytes()); + hasher.update(key.shape.stable_hash().as_bytes()); + Hc128Rng::from_seed(hasher.finalize().into()) + }; + + match &key.shape { + GeneratedSecretShape::Bytes { size } => { + data.insert(key.key.clone(), ByteString(generator::bytes(&mut rng, *size))); + } + GeneratedSecretShape::Password { length, dictionary } => { + data.insert(key.key.clone(), ByteString(generator::str_bytes(&mut rng, *length, dictionary.chars()).inspect_err(|e| tracing::error!(debug=?e, reason="PasswordGenFailed", action="Generate", "Failed to generate new random password")).context("Generate password")?)); + } + } + } + + Ok(data) + } +} diff --git a/kuyaseg-controller/src/resource/generated/ssh/mod.rs b/kuyaseg-controller/src/resource/generated/ssh/mod.rs new file mode 100644 index 0000000..e51933e --- /dev/null +++ b/kuyaseg-controller/src/resource/generated/ssh/mod.rs @@ -0,0 +1 @@ +pub type GeneratedSshSecretTemplate = super::secret::GeneratedSecretTemplate; diff --git a/kuyaseg-controller/src/resource/mod.rs b/kuyaseg-controller/src/resource/mod.rs new file mode 100644 index 0000000..743aa41 --- /dev/null +++ b/kuyaseg-controller/src/resource/mod.rs @@ -0,0 +1 @@ +pub mod generated;