Skip to main content

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