kaniop_operator/kanidm/controller/
context.rs

1use crate::controller::context::BackoffContext;
2use crate::kanidm::reconcile::secret::REPLICA_SECRET_KEY;
3use crate::metrics::ControllerMetrics;
4use crate::{controller::context::Context as KaniopContext, kanidm::crd::Kanidm};
5use kaniop_k8s_util::error::{Error, Result};
6
7use std::collections::HashMap;
8use std::sync::Arc;
9use std::time::Duration;
10
11use base64::{Engine as _, engine::general_purpose::URL_SAFE};
12use k8s_openapi::api::apps::v1::StatefulSet;
13use k8s_openapi::api::core::v1::{Secret, Service};
14use k8s_openapi::api::networking::v1::Ingress;
15use kube::runtime::reflector::{ObjectRef, Store};
16use openssl::asn1::Asn1Time;
17use openssl::x509::X509;
18use tokio::sync::RwLock;
19use tracing::trace;
20
21#[derive(Clone)]
22pub struct Context {
23    pub kaniop_ctx: KaniopContext<Kanidm>,
24    /// Shared store
25    pub stores: Arc<Stores>,
26    repl_cert_exp_cache: Arc<RwLock<ReplicaCertExpiration>>,
27    repl_cert_host_cache: Arc<RwLock<ReplicaCertHost>>,
28}
29
30impl Context {
31    pub fn new(kaniop_ctx: KaniopContext<Kanidm>, stores: Stores) -> Self {
32        Context {
33            kaniop_ctx,
34            stores: Arc::new(stores),
35            repl_cert_exp_cache: Arc::default(),
36            repl_cert_host_cache: Arc::default(),
37        }
38    }
39
40    pub async fn get_repl_cert_exp(&self, secret_ref: &ObjectRef<Secret>) -> Option<i64> {
41        trace!(msg = format!("getting replica certificate expiration for {secret_ref}"));
42        self.repl_cert_exp_cache
43            .read()
44            .await
45            .0
46            .get(secret_ref)
47            .cloned()
48    }
49
50    pub async fn insert_repl_cert_exp(&self, secret: &Secret) -> Result<()> {
51        trace!(
52            msg = format!(
53                "inserting replica certificate expiration for {:?}",
54                &ObjectRef::from(secret)
55            )
56        );
57        match &secret.data {
58            None => Err(Error::MissingData("secret data empty".to_string())),
59            Some(data) => match data.get(REPLICA_SECRET_KEY) {
60                None => Err(Error::MissingData(format!(
61                    "secret data missing key {REPLICA_SECRET_KEY}"
62                ))),
63                Some(cert_b64url) => {
64                    let (expiration, host) = parse_cert_expiration_and_host(
65                        String::from_utf8_lossy(&cert_b64url.0).as_ref(),
66                    )?;
67                    let obj_ref = ObjectRef::from(secret);
68                    self.repl_cert_exp_cache
69                        .write()
70                        .await
71                        .0
72                        .insert(obj_ref.clone(), expiration);
73                    self.repl_cert_host_cache
74                        .write()
75                        .await
76                        .0
77                        .insert(obj_ref, host);
78                    Ok(())
79                }
80            },
81        }
82    }
83
84    #[inline]
85    pub async fn remove_repl_cert_exp(&self, secret_ref: &ObjectRef<Secret>) {
86        trace!(msg = format!("removing replica certificate expiration for {secret_ref}",));
87        self.repl_cert_exp_cache.write().await.0.remove(secret_ref);
88    }
89
90    pub async fn get_repl_cert_host(&self, secret_ref: &ObjectRef<Secret>) -> Option<String> {
91        trace!(msg = format!("getting replica certificate host for {secret_ref}"));
92        self.repl_cert_host_cache
93            .read()
94            .await
95            .0
96            .get(secret_ref)
97            .cloned()
98    }
99
100    #[inline]
101    pub async fn remove_repl_cert_host(&self, secret_ref: &ObjectRef<Secret>) {
102        trace!(msg = format!("removing replica certificate host for {secret_ref}",));
103        self.repl_cert_host_cache.write().await.0.remove(secret_ref);
104    }
105}
106
107impl BackoffContext<Kanidm> for Context {
108    fn metrics(&self) -> &Arc<ControllerMetrics> {
109        self.kaniop_ctx.metrics()
110    }
111    async fn get_backoff(&self, obj_ref: ObjectRef<Kanidm>) -> Duration {
112        self.kaniop_ctx.get_backoff(obj_ref).await
113    }
114
115    async fn reset_backoff(&self, obj_ref: ObjectRef<Kanidm>) {
116        self.kaniop_ctx.reset_backoff(obj_ref).await
117    }
118}
119
120pub struct Stores {
121    pub stateful_set_store: Store<StatefulSet>,
122    pub service_store: Store<Service>,
123    pub ingress_store: Store<Ingress>,
124    pub secret_store: Store<Secret>,
125}
126
127#[derive(Default)]
128struct ReplicaCertExpiration(HashMap<ObjectRef<Secret>, i64>);
129
130#[derive(Default)]
131struct ReplicaCertHost(HashMap<ObjectRef<Secret>, String>);
132
133fn parse_cert_expiration_and_host(cert_b64url: &str) -> Result<(i64, String)> {
134    let der_bytes = URL_SAFE
135        .decode(cert_b64url)
136        .map_err(|e| Error::ParseError(format!("invalid base64url encoding: {e}")))?;
137
138    let cert = X509::from_der(&der_bytes)
139        .map_err(|e| Error::ParseError(format!("failed to parse DER certificate: {e}")))?;
140    let not_after = cert.not_after();
141    trace!(msg = format!("certificate not after: {not_after}"));
142
143    let epoch = Asn1Time::from_unix(0).unwrap();
144    let duration = epoch.diff(not_after).unwrap();
145    let timestamp = duration.days as i64 * 86400 + duration.secs as i64;
146
147    let san = cert
148        .subject_alt_names()
149        .ok_or_else(|| Error::ParseError("no SAN extension".to_string()))?;
150    let host = san
151        .iter()
152        .find_map(|name| {
153            if let Some(dns) = name.dnsname() {
154                Some(dns.to_string())
155            } else if let Some(ip_bytes) = name.ipaddress() {
156                if ip_bytes.len() == 4 {
157                    let ip = std::net::Ipv4Addr::from(<[u8; 4]>::try_from(ip_bytes).unwrap());
158                    Some(ip.to_string())
159                } else if ip_bytes.len() == 16 {
160                    let ip = std::net::Ipv6Addr::from(<[u8; 16]>::try_from(ip_bytes).unwrap());
161                    Some(ip.to_string())
162                } else {
163                    None
164                }
165            } else {
166                None
167            }
168        })
169        .ok_or_else(|| Error::ParseError("no DNS or IP in SAN".to_string()))?;
170    Ok((timestamp, host))
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn test_get_cert_expiration_valid_cert() {
179        let cert_b64url = "MIIB_DCCAaGgAwIBAgIBATAKBggqhkjOPQQDAjBMMRswGQYDVQQKDBJLYW5pZG0gUmVwbGljYXRpb24xLTArBgNVBAMMJDJiYTgzMTZhLWViYWEtNGJjMS04NDkzLTVmODZmYWZhZTU5NDAeFw0yNDExMDYxOTEzMjdaFw0yODExMDYxOTEzMjdaMEwxGzAZBgNVBAoMEkthbmlkbSBSZXBsaWNhdGlvbjEtMCsGA1UEAwwkMmJhODMxNmEtZWJhYS00YmMxLTg0OTMtNWY4NmZhZmFlNTk0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEuXp1hNNZerxDQbCh7rAGW6uM0CPECNd3IvbSh7qH34MkO_plwwDVKFbzcTG8HJE2ouIJlJYN8P4wf6qmrRQMAKN0MHIwDAYDVR0TAQH_BAIwADAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMB0GA1UdDgQWBBTaOaPuXmtLDTJVv--VYBiQr9gHCTAUBgNVHREEDTALgglsb2NhbGhvc3QwCgYIKoZIzj0EAwIDSQAwRgIhAIZD_J4LyR7D0kg41GRg_TcRxm5mEVhM6WL9BO3XmfUsAiEA7Wpbkvd0b1e-Sg8AS9jP-CpBpmTnC7oEChkyhUYKyFc=";
180        let (expiration, host) = parse_cert_expiration_and_host(cert_b64url).unwrap();
181        assert_eq!(expiration, 1857150807);
182        assert_eq!(host, "localhost");
183    }
184}