chore!: minor fixes here and there & move CRDs into submods & introduce serializer helpers
This commit is contained in:
parent
e7f6ddf741
commit
3ac37809b7
16 changed files with 811 additions and 1543 deletions
1028
Cargo.lock
generated
1028
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
16
Cargo.toml
16
Cargo.toml
|
|
@ -22,15 +22,12 @@ tokio-util.version = '^0.7'
|
||||||
axum.version = "^0.8"
|
axum.version = "^0.8"
|
||||||
## Tracing
|
## Tracing
|
||||||
tracing.version = "^0.1"
|
tracing.version = "^0.1"
|
||||||
tracing.features = ["max_level_trace", "release_max_level_info"]
|
tracing.features = ["max_level_debug", "release_max_level_info"]
|
||||||
tracing-subscriber.version = "^0.3"
|
tracing-subscriber.version = "^0.3"
|
||||||
tracing-opentelemetry.version = "^0.32"
|
tracing-opentelemetry.version = "^0.33"
|
||||||
opentelemetry.version = "^0.31"
|
opentelemetry.version = "^0.32"
|
||||||
opentelemetry.features = ["trace"]
|
opentelemetry_sdk.version = "^0.32"
|
||||||
opentelemetry-otlp.version = "^0.31"
|
prometheus-client.version = "^0.24"
|
||||||
opentelemetry-otlp.features = ["grpc-tonic", "reqwest-rustls"]
|
|
||||||
opentelemetry_sdk.version = "^0.31"
|
|
||||||
opentelemetry_sdk.features = ["rt-tokio"]
|
|
||||||
## Utils
|
## Utils
|
||||||
jiff.version = "^0.2"
|
jiff.version = "^0.2"
|
||||||
scopeguard.version = "^1"
|
scopeguard.version = "^1"
|
||||||
|
|
@ -42,6 +39,8 @@ schemars.version = "^1"
|
||||||
base64.version = "^0.22"
|
base64.version = "^0.22"
|
||||||
serde.version = "^1"
|
serde.version = "^1"
|
||||||
serde.features = ["derive"]
|
serde.features = ["derive"]
|
||||||
|
serde_with.version = "3"
|
||||||
|
serde_with.features = ["hex", "schemars_1"]
|
||||||
serde_json.version = "^1"
|
serde_json.version = "^1"
|
||||||
serde_yaml.version = "^0.9"
|
serde_yaml.version = "^0.9"
|
||||||
## k8s
|
## k8s
|
||||||
|
|
@ -49,7 +48,6 @@ kube.version = "^3"
|
||||||
kube.features = ["derive"]
|
kube.features = ["derive"]
|
||||||
k8s-openapi.version = "^0.27"
|
k8s-openapi.version = "^0.27"
|
||||||
k8s-openapi.features = ["latest", "schemars"]
|
k8s-openapi.features = ["latest", "schemars"]
|
||||||
prometheus-client.version = "^0.24"
|
|
||||||
## Codegen
|
## Codegen
|
||||||
thiserror.version = "^2"
|
thiserror.version = "^2"
|
||||||
anyhow.version = "^1"
|
anyhow.version = "^1"
|
||||||
|
|
|
||||||
29
README.md
29
README.md
|
|
@ -90,7 +90,8 @@ Both resources inherit namespace and name (as prefix) from `RandomSsh` resource
|
||||||
|
|
||||||
> ⚠️ This feature exists primarily for testing purposes, where security is less of a concern then repeatability.
|
> ⚠️ This feature exists primarily for testing purposes, where security is less of a concern then repeatability.
|
||||||
>
|
>
|
||||||
> While there are use-cases for predictable secrets in production, you should consider [ESO](https://external-secrets.io)
|
> While there are use-cases for predictable secrets in production (e.g. DB credentials in some cases),
|
||||||
|
> you should consider [ESO](https://external-secrets.io)
|
||||||
> with secure external storage to share secrets between clusters and keep secrets stable.
|
> with secure external storage to share secrets between clusters and keep secrets stable.
|
||||||
>
|
>
|
||||||
> If the seed secret gets compromised - every past and future generated secret can be easliy computed.
|
> If the seed secret gets compromised - every past and future generated secret can be easliy computed.
|
||||||
|
|
@ -126,6 +127,24 @@ Secret values generated by deterministic generator depend on:
|
||||||
|
|
||||||
Thus, if seed secret remains stable and DeterministicSecret specification does not change - generated keys remain stable and predictable
|
Thus, if seed secret remains stable and DeterministicSecret specification does not change - generated keys remain stable and predictable
|
||||||
|
|
||||||
|
To set the `seed` for the controller, deploy a `v1/Secret` and target the controller to it with `KUYASEG_DETERMINISTIC_SEED` env variable.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: controller-seed
|
||||||
|
namespace: testing
|
||||||
|
stringData:
|
||||||
|
my_seed: aabbcc123
|
||||||
|
```
|
||||||
|
|
||||||
|
`KUYASEG_DETERMINISTIC_SEED=controller-seed/my_seed`
|
||||||
|
|
||||||
|
When the later part `/my_seed` is omitted, controller will expect the `seed` key to contain seed value.
|
||||||
|
|
||||||
|
When the seed *value* changes, all known secrets are reconciled.
|
||||||
|
|
||||||
## Feature comparison
|
## Feature comparison
|
||||||
|
|
||||||
We know there are plenty of decent, mature and well-adopted solutions like the one by [mittwald](https://github.com/mittwald/kubernetes-secret-generator) and even [ESO](https://external-secrets.io) which can also generate secrets.
|
We know there are plenty of decent, mature and well-adopted solutions like the one by [mittwald](https://github.com/mittwald/kubernetes-secret-generator) and even [ESO](https://external-secrets.io) which can also generate secrets.
|
||||||
|
|
@ -156,11 +175,11 @@ To produce random secrets (`RandomSecret`, `RandomSshSecret`), operator uses [aw
|
||||||
|
|
||||||
To produce deterministic secrets (`DeterministicSecret`, `DeterministicSsh`), operator uses [Hc128](https://docs.rs/rand_hc/latest/rand_hc/) CSPRNG.
|
To produce deterministic secrets (`DeterministicSecret`, `DeterministicSsh`), operator uses [Hc128](https://docs.rs/rand_hc/latest/rand_hc/) CSPRNG.
|
||||||
|
|
||||||
### Secret persistence & memory
|
### Secret persistence
|
||||||
|
|
||||||
Random secrets defined by `RandomSecret` and `RandomSsh` are unstable. Deletion of generated `Secret` will lead to new random value on next reconcilation.
|
Random secrets defined by `RandomSecret` and `RandomSsh` are unstable: deletion of generated `Secret` will produce new random value on next reconcilation.
|
||||||
|
|
||||||
Deterministic secrets defined by `DeterministicSecret` and `DeterministicSsh` are stable as long as the base `seed` remains unchanged.
|
Deterministic secrets defined by `DeterministicSecret` and `DeterministicSsh` are stable as long as the base `seed` and spec/version remain unchanged.
|
||||||
Deletion of generated `Secret` resources will lead to the same pseudo-random value put into the secret on next reconcilation as long as spec remains the same.
|
Deletion of generated `Secret` resources will lead to the same pseudo-random value put into the secret on next reconcilation.
|
||||||
|
|
||||||
Best effort is taken to ensure that secrets do not remain in memory longer then needed and/or after crush.
|
Best effort is taken to ensure that secrets do not remain in memory longer then needed and/or after crush.
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ publish.workspace = true
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
doc = false
|
doc = false
|
||||||
name = "generate_crds"
|
name = "crdgen"
|
||||||
path = "src/bin/generate_crds.rs"
|
path = "src/bin/crdgen.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
## Rt
|
## Rt
|
||||||
|
|
@ -26,7 +26,6 @@ tracing-subscriber.workspace = true
|
||||||
tracing-subscriber.features = ["env-filter"]
|
tracing-subscriber.features = ["env-filter"]
|
||||||
tracing-opentelemetry.workspace = true
|
tracing-opentelemetry.workspace = true
|
||||||
opentelemetry.workspace = true
|
opentelemetry.workspace = true
|
||||||
opentelemetry-otlp.workspace = true
|
|
||||||
opentelemetry_sdk.workspace = true
|
opentelemetry_sdk.workspace = true
|
||||||
prometheus-client.workspace = true
|
prometheus-client.workspace = true
|
||||||
## Utils
|
## Utils
|
||||||
|
|
@ -39,6 +38,7 @@ itertools.workspace = true
|
||||||
schemars.workspace = true
|
schemars.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
serde_with.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
serde_yaml.workspace = true
|
serde_yaml.workspace = true
|
||||||
## k8s
|
## k8s
|
||||||
|
|
@ -55,4 +55,5 @@ ssh-key.workspace = true
|
||||||
ssh-key.features = ["ed25519"]
|
ssh-key.features = ["ed25519"]
|
||||||
rand_hc.workspace = true
|
rand_hc.workspace = true
|
||||||
blake3.workspace = true
|
blake3.workspace = true
|
||||||
|
blake3.features = ["serde"]
|
||||||
zeroize.workspace = true
|
zeroize.workspace = true
|
||||||
|
|
|
||||||
3
kuyaseg-controller/src/bin/crdgen.rs
Normal file
3
kuyaseg-controller/src/bin/crdgen.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
use kube::CustomResourceExt;
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -240,8 +240,6 @@ mod test {
|
||||||
verify_ssh_pair(&pair);
|
verify_ssh_pair(&pair);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ====== Deterministic (Hc128Rng) ======
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deterministic_bytes() {
|
fn deterministic_bytes() {
|
||||||
// we believe that generation is the same as for random, so our concern here is only value stability
|
// we believe that generation is the same as for random, so our concern here is only value stability
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ pub struct ReportingInit {
|
||||||
pub pro_registry: Option<prometheus_client::registry::Registry>,
|
pub pro_registry: Option<prometheus_client::registry::Registry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// For opentelemetry TraceId to be present, tracing_opentelemetry::layer must also be set
|
||||||
pub fn init(k8s_client: kube::Client, ct: CancellationToken) -> anyhow::Result<ReportingInit> {
|
pub fn init(k8s_client: kube::Client, ct: CancellationToken) -> anyhow::Result<ReportingInit> {
|
||||||
let config = ReportingConfig::builder()
|
let config = ReportingConfig::builder()
|
||||||
.env()
|
.env()
|
||||||
|
|
|
||||||
|
|
@ -1,609 +1 @@
|
||||||
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;
|
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<GeneratedSecretKey>,
|
|
||||||
#[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<String, (usize, String)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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) && (stored.1 == spec.shape.stable_hash())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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<T>(
|
|
||||||
&self,
|
|
||||||
name: &str,
|
|
||||||
namespace: &str,
|
|
||||||
client: kube::Client,
|
|
||||||
uid: Option<&str>,
|
|
||||||
) -> anyhow::Result<()>
|
|
||||||
where
|
|
||||||
T: HasSecretGenerator
|
|
||||||
+ Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
|
|
||||||
+ DeserializeOwned,
|
|
||||||
{
|
|
||||||
// discover target secret & branch for update/create
|
|
||||||
let target_api = Api::<k8s_openapi::api::core::v1::Secret>::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::<T>(name, namespace, client, yep)
|
|
||||||
.await
|
|
||||||
.context("Update existing Secret")?,
|
|
||||||
None => self
|
|
||||||
.create_new_from_spec::<T>(name, namespace, client, uid)
|
|
||||||
.await
|
|
||||||
.context("Create new Secret")?,
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_new_from_spec<T>(
|
|
||||||
&self,
|
|
||||||
name: &str,
|
|
||||||
namespace: &str,
|
|
||||||
client: kube::Client,
|
|
||||||
uid: Option<&str>,
|
|
||||||
) -> anyhow::Result<()>
|
|
||||||
where
|
|
||||||
T: HasSecretGenerator
|
|
||||||
+ Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
|
|
||||||
+ DeserializeOwned,
|
|
||||||
{
|
|
||||||
use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference;
|
|
||||||
|
|
||||||
let self_api = Api::<T>::namespaced(client.clone(), namespace);
|
|
||||||
let target_api = Api::<k8s_openapi::api::core::v1::Secret>::namespaced(client.clone(), namespace);
|
|
||||||
|
|
||||||
let generated = self
|
|
||||||
.keys
|
|
||||||
.iter()
|
|
||||||
.map(|key| (key.key.clone(), (key.version, key.shape.stable_hash())))
|
|
||||||
.collect::<BTreeMap<_, _>>();
|
|
||||||
|
|
||||||
// first we try with dry-run, no need to guard as no data here
|
|
||||||
let owner_ref = uid.map(|uid| OwnerReference {
|
|
||||||
api_version: T::api_version(&()).to_string(),
|
|
||||||
kind: T::kind(&()).to_string(),
|
|
||||||
name: name.to_string(),
|
|
||||||
uid: uid.to_string(),
|
|
||||||
controller: Some(true),
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
let partial_target = {
|
|
||||||
let mut secret = self.template.to_safe_partial_secret(Some(false), Some("Opaque".into()));
|
|
||||||
secret.metadata.owner_references = owner_ref.as_ref().map(|oref| vec![oref.clone()]);
|
|
||||||
secret
|
|
||||||
};
|
|
||||||
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")?;
|
|
||||||
|
|
||||||
if let Err(e) = 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")
|
|
||||||
{
|
|
||||||
tracing::error!(
|
|
||||||
reason = "SecretOrphanCleanup",
|
|
||||||
action = "CreateNew",
|
|
||||||
"Deleting orphan secret after failed status update"
|
|
||||||
);
|
|
||||||
target_api.delete(name, &kube::api::DeleteParams::default()).await.context("Orphan secret on fail")?;
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_from_spec<T>(
|
|
||||||
&self,
|
|
||||||
name: &str,
|
|
||||||
namespace: &str,
|
|
||||||
client: kube::Client,
|
|
||||||
old_ref: &k8s_openapi::api::core::v1::Secret,
|
|
||||||
) -> anyhow::Result<()>
|
|
||||||
where
|
|
||||||
T: HasSecretGenerator
|
|
||||||
+ Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
|
|
||||||
+ DeserializeOwned,
|
|
||||||
{
|
|
||||||
todo!("WIP")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) trait HasSecretGenerator {
|
|
||||||
fn generate_data(spec: &GeneratedSecretSpec) -> anyhow::Result<BTreeMap<String, k8s_openapi::ByteString>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<String, String>,
|
|
||||||
/// Verbatim string data, which will be incoded as bytes into secret
|
|
||||||
#[serde(rename = "stringData")]
|
|
||||||
#[serde(default)]
|
|
||||||
pub string_data: BTreeMap<String, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GeneratedSecretTemplate {
|
|
||||||
/// Does not retain data.
|
|
||||||
pub fn to_safe_partial_secret(
|
|
||||||
&self,
|
|
||||||
immutable: Option<bool>,
|
|
||||||
type_: Option<String>,
|
|
||||||
) -> 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<String, String>,
|
|
||||||
/// Add labels to generated secret
|
|
||||||
#[serde(default)]
|
|
||||||
pub labels: BTreeMap<String, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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(b"bytes");
|
|
||||||
hasher.update(&size.to_le_bytes());
|
|
||||||
}
|
|
||||||
Self::Password { length, dictionary } => {
|
|
||||||
hasher.update(b"password");
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
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<Action> {
|
|
||||||
let kind = Self::kind(&());
|
|
||||||
let api = Self::api_version(&());
|
|
||||||
let name = &self.name_any();
|
|
||||||
let _span_nons = tracing::info_span!(
|
|
||||||
"RandomSecret.reconcile",
|
|
||||||
k8s_kind = kind.as_ref(),
|
|
||||||
k8s_api = api.as_ref(),
|
|
||||||
k8s_name = name,
|
|
||||||
k8s_uid = self.uid(),
|
|
||||||
)
|
|
||||||
.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", k8s_ns = namespace).entered();
|
|
||||||
|
|
||||||
self.spec.0.reconcile_spec::<Self>(name, &namespace, client, self.uid().as_deref()).await.inspect_err(|e| tracing::error!(debug=?e, reason="RandomSecretReconciliationFailed", action = "Reconcile", "Failed to reconcile RandomSecret {namespace}{name}")).context("Reconcile")?;
|
|
||||||
|
|
||||||
// TODO: verify if all desired events fire
|
|
||||||
Ok(Action::await_change())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HasSecretGenerator for RandomSecret {
|
|
||||||
fn generate_data(spec: &GeneratedSecretSpec) -> anyhow::Result<BTreeMap<String, ByteString>> {
|
|
||||||
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<Action> {
|
|
||||||
let kind = Self::kind(&());
|
|
||||||
let api = Self::api_version(&());
|
|
||||||
let name = &self.name_any();
|
|
||||||
let _span_nons = tracing::info_span!(
|
|
||||||
"DeterministicSecret.reconcile",
|
|
||||||
k8s_kind = kind.as_ref(),
|
|
||||||
k8s_api = api.as_ref(),
|
|
||||||
k8s_name = name,
|
|
||||||
k8s_uid = self.uid(),
|
|
||||||
)
|
|
||||||
.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", k8s_ns = namespace).entered();
|
|
||||||
|
|
||||||
self.spec.0.reconcile_spec::<Self>(name, &namespace, client, self.uid().as_deref()).await.inspect_err(|e| tracing::error!(debug=?e, reason="DeterministicSecretReconciliationFailed", action = "Reconcile", "Failed to reconcile DeterministicSecret {namespace}{name}")).context("Reconcile")?;
|
|
||||||
|
|
||||||
// TODO: verify if all desired events fire
|
|
||||||
Ok(Action::await_change())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HasSecretGenerator for DeterministicSecret {
|
|
||||||
fn generate_data(spec: &GeneratedSecretSpec) -> anyhow::Result<BTreeMap<String, ByteString>> {
|
|
||||||
let seed = std::env::var("KUYASEG_SEED").inspect_err(|e| tracing::error!(debug=?e, reason="DeterministicSecretNeedSeed", "Refuse to generate deterministic secret without seed. 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
354
kuyaseg-controller/src/resource/generated/secret/v1alpha1/mod.rs
Normal file
354
kuyaseg-controller/src/resource/generated/secret/v1alpha1/mod.rs
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
use std::collections::{
|
||||||
|
BTreeMap,
|
||||||
|
HashMap,
|
||||||
|
};
|
||||||
|
|
||||||
|
use k8s_openapi::ByteString;
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{
|
||||||
|
Deserialize,
|
||||||
|
Serialize,
|
||||||
|
};
|
||||||
|
use serde_with::serde_as;
|
||||||
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
|
use crate::utils::{
|
||||||
|
serializers,
|
||||||
|
zeroizers,
|
||||||
|
};
|
||||||
|
|
||||||
|
mod pseudorandom;
|
||||||
|
pub use pseudorandom::*;
|
||||||
|
|
||||||
|
mod random;
|
||||||
|
pub use random::*;
|
||||||
|
|
||||||
|
#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct SecretStatus {
|
||||||
|
pub key_tracker: HashMap<String, KeyStatus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde_as]
|
||||||
|
pub struct KeyStatus {
|
||||||
|
/// Version is tracked separately, for change must set NE status
|
||||||
|
pub version: usize,
|
||||||
|
#[schemars(schema_with = "String::json_schema")]
|
||||||
|
#[serde_as(as = "serde_with::hex::Hex<serde_with::formats::Uppercase>")]
|
||||||
|
pub spec_hash: blake3::Hash,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[cfg_attr(test, derive(PartialEq))]
|
||||||
|
pub struct KeySpec {
|
||||||
|
/// Name of the key to put generated data into
|
||||||
|
pub key: String,
|
||||||
|
/// Tracks the key generation version within the same shape.
|
||||||
|
/// Increasing this number will force value regeneration on next reconcilation
|
||||||
|
pub version: usize,
|
||||||
|
/// Determines the shape of generated data
|
||||||
|
pub shape: KeyShape,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[cfg_attr(test, derive(PartialEq))]
|
||||||
|
pub enum KeyShape {
|
||||||
|
/// Generate arbitrary bytes
|
||||||
|
Bytes {
|
||||||
|
/// Size of generated sequence in bytes.
|
||||||
|
///
|
||||||
|
/// Defaults to 64
|
||||||
|
#[serde(default = "KeyShape::default_bytes_size")]
|
||||||
|
size: usize,
|
||||||
|
},
|
||||||
|
/// Generate string password
|
||||||
|
Password {
|
||||||
|
/// The length of password in characters.
|
||||||
|
///
|
||||||
|
/// Defaults to 24
|
||||||
|
#[serde(default = "KeyShape::default_pwd_length")]
|
||||||
|
length: usize,
|
||||||
|
/// The string of characters allowed to be present in password.
|
||||||
|
/// When dictionary length is > 4, each character in generated password is guaranteed to repeat
|
||||||
|
/// with period at least 3.
|
||||||
|
///
|
||||||
|
/// Defaults to lowercase+uppercase alphanumeric and `-`
|
||||||
|
#[serde(default = "KeyShape::default_pwd_dict")]
|
||||||
|
dictionary: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyShape {
|
||||||
|
/// BLAKE3 hash, incorporating shape and according properties
|
||||||
|
pub fn stable_hash(&self) -> blake3::Hash {
|
||||||
|
let mut hasher = blake3::Hasher::new();
|
||||||
|
match self {
|
||||||
|
Self::Bytes { size } => {
|
||||||
|
hasher.update(b"bytes");
|
||||||
|
hasher.update(&size.to_le_bytes());
|
||||||
|
}
|
||||||
|
Self::Password { length, dictionary } => {
|
||||||
|
hasher.update(b"password");
|
||||||
|
hasher.update(&length.to_le_bytes());
|
||||||
|
hasher.update(dictionary.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasher.finalize()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_bytes_size() -> usize {
|
||||||
|
64
|
||||||
|
}
|
||||||
|
pub fn default_pwd_length() -> usize {
|
||||||
|
24
|
||||||
|
}
|
||||||
|
pub fn default_pwd_dict() -> String {
|
||||||
|
String::from("abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Zeroize for KeyShape {
|
||||||
|
#[inline]
|
||||||
|
fn zeroize(&mut self) {
|
||||||
|
match self {
|
||||||
|
Self::Bytes { size } => size.zeroize(),
|
||||||
|
Self::Password { length, dictionary } => {
|
||||||
|
length.zeroize();
|
||||||
|
dictionary.zeroize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for KeyShape {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.zeroize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[cfg_attr(test, derive(PartialEq))]
|
||||||
|
pub struct SecretTemplate {
|
||||||
|
/// Define created Secret resource metadata
|
||||||
|
#[serde(default)]
|
||||||
|
pub metadata: SecretMetadata,
|
||||||
|
/// Include static data into created Secret
|
||||||
|
#[serde(default)]
|
||||||
|
#[schemars(schema_with = "BTreeMap::<String, String>::json_schema")]
|
||||||
|
pub data: BTreeMap<String, ByteString>,
|
||||||
|
/// Include static string data into created Secret
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(rename = "stringData")]
|
||||||
|
#[serde(with = "serializers::btreemap_to_bytestring_inplace")]
|
||||||
|
#[schemars(schema_with = "BTreeMap::<String, String>::json_schema")]
|
||||||
|
pub string_data: BTreeMap<String, ByteString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Zeroize for SecretTemplate {
|
||||||
|
#[inline]
|
||||||
|
fn zeroize(&mut self) {
|
||||||
|
zeroizers::zeroize_btree_of_bytestring(&mut self.data);
|
||||||
|
zeroizers::zeroize_btree_of_bytestring(&mut self.string_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SecretTemplate {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.zeroize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[cfg_attr(test, derive(PartialEq))]
|
||||||
|
pub struct SecretMetadata {
|
||||||
|
#[serde(default)]
|
||||||
|
pub annotations: BTreeMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub labels: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 = SecretMetadata {
|
||||||
|
annotations: BTreeMap::from_iter([("my.custom-annotation".into(), "value".into())]),
|
||||||
|
labels: BTreeMap::from_iter([("some-custom.label".into(), "value".into())]),
|
||||||
|
};
|
||||||
|
let parsed: SecretMetadata = serde_yaml::from_str(sample).unwrap();
|
||||||
|
assert_eq!(parsed, expect, "Full sample didn't match");
|
||||||
|
|
||||||
|
// Empty annotations & labels
|
||||||
|
let sample = "
|
||||||
|
annotations: {}
|
||||||
|
labels: {}
|
||||||
|
";
|
||||||
|
let expect = SecretMetadata {
|
||||||
|
annotations: BTreeMap::new(),
|
||||||
|
labels: BTreeMap::new(),
|
||||||
|
};
|
||||||
|
let parsed: SecretMetadata = serde_yaml::from_str(sample).unwrap();
|
||||||
|
assert_eq!(parsed, expect, "Empty annotations&labels didn't match");
|
||||||
|
|
||||||
|
// Empty
|
||||||
|
let sample = "";
|
||||||
|
let expect = SecretMetadata::default();
|
||||||
|
let parsed: SecretMetadata = serde_yaml::from_str(sample).unwrap();
|
||||||
|
assert_eq!(parsed, expect, "Empty didn't match");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deser_template() {
|
||||||
|
// full sample
|
||||||
|
let sample = "
|
||||||
|
data:
|
||||||
|
key0: YWJjZGU=
|
||||||
|
stringData:
|
||||||
|
key1: fghij
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
my.custom-annotation: value
|
||||||
|
labels:
|
||||||
|
some-custom.label: value
|
||||||
|
";
|
||||||
|
let expect = SecretTemplate {
|
||||||
|
data: BTreeMap::from_iter([("key0".into(), ByteString("abcde".to_string().into_bytes()))]),
|
||||||
|
string_data: BTreeMap::from_iter([("key1".into(), ByteString("fghij".to_string().into_bytes()))]),
|
||||||
|
metadata: SecretMetadata {
|
||||||
|
annotations: BTreeMap::from_iter([("my.custom-annotation".into(), "value".into())]),
|
||||||
|
labels: BTreeMap::from_iter([("some-custom.label".into(), "value".into())]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let parsed: SecretTemplate = serde_yaml::from_str(sample).unwrap();
|
||||||
|
assert_eq!(parsed, expect, "Full sample didn't match");
|
||||||
|
|
||||||
|
// Empty metadata
|
||||||
|
let sample = "
|
||||||
|
data:
|
||||||
|
key0: YWJjZGU=
|
||||||
|
stringData:
|
||||||
|
key1: fghij
|
||||||
|
metadata: {}
|
||||||
|
";
|
||||||
|
let expect = SecretTemplate {
|
||||||
|
data: BTreeMap::from_iter([("key0".into(), ByteString("abcde".to_string().into_bytes()))]),
|
||||||
|
string_data: BTreeMap::from_iter([("key1".into(), ByteString("fghij".to_string().into_bytes()))]),
|
||||||
|
metadata: SecretMetadata::default(),
|
||||||
|
};
|
||||||
|
let parsed: SecretTemplate = serde_yaml::from_str(sample).unwrap();
|
||||||
|
assert_eq!(parsed, expect, "Empty metadata didn't match");
|
||||||
|
|
||||||
|
// No metadata
|
||||||
|
let sample = "
|
||||||
|
data:
|
||||||
|
key0: YWJjZGU=
|
||||||
|
stringData:
|
||||||
|
key1: fghij
|
||||||
|
";
|
||||||
|
let expect = SecretTemplate {
|
||||||
|
data: BTreeMap::from_iter([("key0".into(), ByteString("abcde".to_string().into_bytes()))]),
|
||||||
|
string_data: BTreeMap::from_iter([("key1".into(), ByteString("fghij".to_string().into_bytes()))]),
|
||||||
|
metadata: SecretMetadata::default(),
|
||||||
|
};
|
||||||
|
let parsed: SecretTemplate = serde_yaml::from_str(sample).unwrap();
|
||||||
|
assert_eq!(parsed, expect, "No metadata didn't match");
|
||||||
|
|
||||||
|
// Empty data & stringdata
|
||||||
|
let sample = "
|
||||||
|
data: {}
|
||||||
|
stringData: {}
|
||||||
|
";
|
||||||
|
let expect = SecretTemplate {
|
||||||
|
data: BTreeMap::new(),
|
||||||
|
string_data: BTreeMap::new(),
|
||||||
|
metadata: SecretMetadata::default(),
|
||||||
|
};
|
||||||
|
let parsed: SecretTemplate = serde_yaml::from_str(sample).unwrap();
|
||||||
|
assert_eq!(parsed, expect, "Empty data&stringData didn't match");
|
||||||
|
|
||||||
|
// Empty
|
||||||
|
let sample = "";
|
||||||
|
let expect = SecretTemplate::default();
|
||||||
|
let parsed: SecretTemplate = 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 = KeySpec {
|
||||||
|
key: "aabbcc".into(),
|
||||||
|
version: 12,
|
||||||
|
shape: KeyShape::Password {
|
||||||
|
length: 123,
|
||||||
|
dictionary: "abcdefg_1".into(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let parsed: KeySpec = 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 = KeySpec {
|
||||||
|
key: "aabbcc".into(),
|
||||||
|
version: 12,
|
||||||
|
shape: KeyShape::Password {
|
||||||
|
length: KeyShape::default_pwd_length(),
|
||||||
|
dictionary: KeyShape::default_pwd_dict(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let parsed: KeySpec = 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 = KeySpec {
|
||||||
|
key: "aabbcc".into(),
|
||||||
|
version: 12,
|
||||||
|
shape: KeyShape::Bytes { size: 1234 },
|
||||||
|
};
|
||||||
|
let parsed: KeySpec = 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 = KeySpec {
|
||||||
|
key: "aabbcc".into(),
|
||||||
|
version: 12,
|
||||||
|
shape: KeyShape::Bytes {
|
||||||
|
size: KeyShape::default_bytes_size(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let parsed: KeySpec = serde_yaml::from_str(sample).unwrap();
|
||||||
|
assert_eq!(expect, parsed, "Empty bytes mismatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +1 @@
|
||||||
pub type GeneratedSshSecretTemplate = super::secret::GeneratedSecretTemplate;
|
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
|
pub mod serializers;
|
||||||
pub mod zeroizers;
|
pub mod zeroizers;
|
||||||
|
|
|
||||||
106
kuyaseg-controller/src/utils/serializers.rs
Normal file
106
kuyaseg-controller/src/utils/serializers.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
/// in-place conversion of String into ByteString, verbatim, utf8-lossy
|
||||||
|
///
|
||||||
|
/// e.g. `"abc" -> ByteString("abc".to_string().into_bytes())`
|
||||||
|
pub mod str_to_bytestring_inplace {
|
||||||
|
use k8s_openapi::ByteString;
|
||||||
|
use serde::de::Visitor;
|
||||||
|
use serde::{
|
||||||
|
Deserializer,
|
||||||
|
Serializer,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn serialize<S>(value: &ByteString, se: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
let value = String::from_utf8_lossy(&value.0);
|
||||||
|
se.serialize_str(&value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(de: D) -> Result<ByteString, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
de.deserialize_str(StringBytestringVisitor)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StringBytestringVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for StringBytestringVisitor {
|
||||||
|
type Value = ByteString;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
write!(formatter, "string")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: serde::de::Error,
|
||||||
|
{
|
||||||
|
Ok(ByteString(v.as_bytes().into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// in-place conversion of BTreeMap<K, String> to BTreeMap<K, ByteString>, verbatim, utf8-lossy
|
||||||
|
///
|
||||||
|
///e.g. `{"abc": "def"} -> {"abc": ByteString("def".to_string().into_bytes())}`
|
||||||
|
pub mod btreemap_to_bytestring_inplace {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::marker::PhantomData;
|
||||||
|
|
||||||
|
use k8s_openapi::ByteString;
|
||||||
|
use serde::de::Visitor;
|
||||||
|
use serde::ser::SerializeMap;
|
||||||
|
use serde::{
|
||||||
|
Deserialize,
|
||||||
|
Deserializer,
|
||||||
|
Serialize,
|
||||||
|
Serializer,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn serialize<S, K>(value: &BTreeMap<K, ByteString>, se: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
K: Serialize,
|
||||||
|
{
|
||||||
|
let mut map = se.serialize_map(Some(value.len()))?;
|
||||||
|
value
|
||||||
|
.iter()
|
||||||
|
.try_for_each(|(k, v)| map.serialize_entry(k, &String::from_utf8_lossy(&v.0)))?;
|
||||||
|
map.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, K, D>(de: D) -> Result<BTreeMap<K, ByteString>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
K: Ord + Deserialize<'de>,
|
||||||
|
{
|
||||||
|
de.deserialize_map(BTreeVisitor(PhantomData))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BTreeVisitor<K>(PhantomData<K>);
|
||||||
|
|
||||||
|
impl<'de, K: Ord + Deserialize<'de>> Visitor<'de> for BTreeVisitor<K> {
|
||||||
|
type Value = BTreeMap<K, ByteString>;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
write!(formatter, "map {ty} -> String", ty = std::any::type_name::<K>())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
|
||||||
|
where
|
||||||
|
A: serde::de::MapAccess<'de>,
|
||||||
|
{
|
||||||
|
let mut btree = BTreeMap::new();
|
||||||
|
while let Some((key, value)) = map.next_entry::<K, &str>()? {
|
||||||
|
if btree.contains_key(&key) {
|
||||||
|
return Err(serde::de::Error::custom("Duplicate key"));
|
||||||
|
}
|
||||||
|
btree.insert(key, ByteString(value.as_bytes().into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(btree)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue