use boatramp_acme::acme::IssuedCert;
use boatramp_core::cert::{ensure_cert, CertStore, StoredCert};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Cert(#[from] boatramp_core::cert::CertError),
}
type Result<T> = std::result::Result<T, Error>;
pub const RENEW_BEFORE_SECS: u64 = 30 * 24 * 3600;
pub const ASSUMED_LIFETIME_SECS: u64 = 90 * 24 * 3600;
pub fn issued_to_stored(issued: &IssuedCert, now_unix: u64) -> StoredCert {
StoredCert::new(
issued.certificate_pem.clone(),
issued.private_key_pem.clone(),
now_unix.saturating_add(ASSUMED_LIFETIME_SECS),
)
}
pub fn stored_to_issued(stored: &StoredCert) -> IssuedCert {
IssuedCert {
certificate_pem: stored.chain_pem.clone(),
private_key_pem: stored.key_pem.clone(),
}
}
pub async fn refresh_entries<F, Fut, E>(
store: &dyn CertStore,
domains: &[String],
is_leader: bool,
now_unix: u64,
mut issue: F,
) -> Result<Vec<(String, IssuedCert)>>
where
F: FnMut(String) -> Fut,
Fut: std::future::Future<Output = std::result::Result<StoredCert, E>>,
E: std::fmt::Display,
{
let mut entries = Vec::with_capacity(domains.len());
for domain in domains {
let stored = ensure_cert(
store,
domain,
is_leader,
now_unix,
RENEW_BEFORE_SECS,
|| issue(domain.clone()),
)
.await?;
if let Some(stored) = stored {
entries.push((domain.clone(), stored_to_issued(&stored)));
} else {
tracing::debug!(%domain, "cluster-tls: cert not yet available (awaiting leader)");
}
}
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
use boatramp_core::cert::KvCertStore;
use boatramp_core::kv::MemoryKv;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
fn fixture_cert() -> StoredCert {
let c = rcgen::generate_simple_self_signed(vec!["test.boatramp.local".to_string()])
.expect("self-signed cert");
StoredCert::new(c.cert.pem(), c.key_pair.serialize_pem(), 4_000_000_000)
}
fn store() -> KvCertStore {
KvCertStore::new(Arc::new(MemoryKv::new()))
}
fn ensure_crypto() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
}
#[tokio::test]
async fn follower_serves_replicated_cert_without_issuing() {
ensure_crypto();
let s = store();
s.put("blog.example.com", &fixture_cert()).await.unwrap();
let calls = AtomicUsize::new(0);
let entries = refresh_entries(&s, &["blog.example.com".into()], false, 1000, |_d| {
calls.fetch_add(1, Ordering::SeqCst);
std::future::ready(Err::<StoredCert, String>(
"a follower must not call the CA".into(),
))
})
.await
.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].0, "blog.example.com");
crate::acme_dns::build_server_config(entries).expect("rustls config from stored cert");
}
#[tokio::test]
async fn leader_issues_then_bridges_to_serving_config() {
ensure_crypto();
let s = store();
let issued = AtomicUsize::new(0);
let entries = refresh_entries(&s, &["blog.example.com".into()], true, 1000, |_d| {
issued.fetch_add(1, Ordering::SeqCst);
std::future::ready(Ok::<_, String>(fixture_cert()))
})
.await
.unwrap();
assert_eq!(issued.load(Ordering::SeqCst), 1, "leader issued once");
assert_eq!(entries.len(), 1);
assert!(s.get("blog.example.com").await.unwrap().is_some());
crate::acme_dns::build_server_config(entries).expect("rustls config");
}
#[tokio::test]
async fn follower_skips_domain_until_leader_issues() {
let s = store();
let entries = refresh_entries(&s, &["pending.example.com".into()], false, 1000, |_d| {
std::future::ready(Err::<StoredCert, String>("no CA".into()))
})
.await
.unwrap();
assert!(entries.is_empty());
}
}