//! Watches Kubernetes TLS Secrets and loads certificates. //! //! Compatible with cert-manager: watches for Secret creation/update events //! and parses `tls.crt` and `tls.key` into the ConfigStore for TLS termination. //! //! Key convention: //! - `tls-secret:` — the raw cert, cross-referenced by reconciler //! via the `tls-host:` mapping written by the ingress watcher. //! - After reconciliation, the reconciler copies certs to `tls:` for //! direct SNI lookup by the proxy. use futures::StreamExt; use futures::pin_mut; use gingress_proxy::config::{ConfigStore, TlsCert}; use kube::ResourceExt; use kube::runtime::watcher::{self, Event}; use std::sync::Arc; /// Watch Secrets of type `kubernetes.io/tls` and update the ConfigStore. /// /// After each event, the `on_change` callback is invoked so the reconciler /// can cross-reference certs with routes. pub async fn watch_secrets( client: Arc, store: Arc, _namespace: Option, on_change: Arc, ) { let api = kube::Api::::all(client.as_ref().clone()); let config = watcher::Config { field_selector: Some("type=kubernetes.io/tls".to_string()), ..Default::default() }; let secret_watcher = watcher::watcher(api, config); pin_mut!(secret_watcher); while let Some(event) = secret_watcher.next().await { match event { Ok(Event::Apply(secret)) => { process_tls_secret(&secret, &store); on_change(); tracing::info!( name = %secret.name_any(), namespace = %secret.namespace().unwrap_or_default(), "TLS Secret applied" ); } Ok(Event::Init) => { store.remove_prefix("tls-secret:"); tracing::info!("Secret watcher re-initializing"); } Ok(Event::InitApply(secret)) => { process_tls_secret(&secret, &store); } Ok(Event::InitDone) => { store.signal_reload(); on_change(); tracing::info!("Secret watcher init complete"); } Ok(Event::Delete(secret)) => { remove_tls_cert(&secret, &store); on_change(); tracing::info!( name = %secret.name_any(), "TLS Secret deleted, cert removed" ); } Err(e) => { tracing::error!("Secret watcher error: {}", e); } } } } /// Parse a TLS secret and store the certificate. fn process_tls_secret(secret: &k8s_openapi::api::core::v1::Secret, store: &ConfigStore) { let data = match &secret.data { Some(d) => d, None => return, }; let cert_pem = match data .get("tls.crt") .and_then(|v| std::str::from_utf8(&v.0).ok()) { Some(v) => v.to_string(), None => { tracing::warn!(name = %secret.name_any(), "TLS Secret missing tls.crt"); return; } }; let key_pem = match data .get("tls.key") .and_then(|v| std::str::from_utf8(&v.0).ok()) { Some(v) => v.to_string(), None => { tracing::warn!(name = %secret.name_any(), "TLS Secret missing tls.key"); return; } }; let secret_name = secret.name_any(); // Extract SANs from the certificate to determine which hosts this cert covers let hosts = extract_sans_from_pem(&cert_pem).unwrap_or_else(|| vec![secret_name.clone()]); let tls_cert = TlsCert { host: hosts.first().cloned().unwrap_or(secret_name.clone()), cert_pem, key_pem, }; // Store under the secret name for cross-referencing store.set(&format!("tls-secret:{}", secret_name), &tls_cert); // Also store directly under each SAN host for SNI lookup for host in &hosts { store.set(&format!("tls:{}", host), &tls_cert); } store.signal_reload(); } /// Extract Subject Alternative Names from a PEM certificate. fn extract_sans_from_pem(pem_data: &str) -> Option> { use x509_parser::prelude::*; let mut reader = std::io::BufReader::new(pem_data.as_bytes()); let certs: Vec<_> = rustls_pemfile::certs(&mut reader) .collect::, _>>() .ok()?; let cert_der = certs.first()?; let (_, cert) = X509Certificate::from_der(cert_der).ok()?; let mut hosts: Vec = Vec::new(); if let Ok(Some(san)) = cert.subject_alternative_name() { for name in &san.value.general_names { if let GeneralName::DNSName(dns) = name { hosts.push(dns.to_string()); } } } // Fallback: use CN if hosts.is_empty() { if let Some(cn) = cert.subject().iter_common_name().next() { hosts.push(cn.as_str().unwrap_or_default().to_string()); } } if hosts.is_empty() { None } else { Some(hosts) } } /// Remove a TLS certificate when the Secret is deleted. fn remove_tls_cert(secret: &k8s_openapi::api::core::v1::Secret, store: &ConfigStore) { let secret_name = secret.name_any(); store.remove(&format!("tls-secret:{}", secret_name)); // Also clean up tls-host mapping store.remove(&format!("tls-host:{}", secret_name)); store.signal_reload(); }