pub(crate) mod certs;
mod provider;
pub mod renewal;
mod resolver;
pub use provider::AcmeProvider;
pub use resolver::DynCertResolver;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio_rustls::TlsAcceptor;
use tracing::{info, warn};
const RENEWAL_THRESHOLD_DAYS: i64 = 30;
#[derive(Clone)]
pub struct AcmeManager {
pub acme_email: String,
pub cache_dir: PathBuf,
challenges: Arc<RwLock<HashMap<String, String>>>,
domains: Arc<RwLock<HashSet<String>>>,
provision_lock: Arc<tokio::sync::Semaphore>,
}
impl AcmeManager {
pub fn new(email: impl Into<String>, cache_dir: impl Into<PathBuf>) -> Self {
Self {
acme_email: email.into(),
cache_dir: cache_dir.into(),
challenges: Arc::new(RwLock::new(HashMap::new())),
domains: Arc::new(RwLock::new(HashSet::new())),
provision_lock: Arc::new(tokio::sync::Semaphore::new(1)),
}
}
pub fn with_default_cache(email: impl Into<String>) -> Self {
let cache_dir = default_orca_dir().join("certs");
Self::new(email, cache_dir)
}
pub async fn add_domain(&self, domain: impl Into<String>) {
let domain = domain.into();
info!(domain = %domain, "Registered domain for ACME");
self.domains.write().await.insert(domain);
}
pub async fn set_challenge(&self, token: String, authorization: String) {
self.challenges.write().await.insert(token, authorization);
}
pub async fn get_challenge_response(&self, token: &str) -> Option<String> {
self.challenges.read().await.get(token).cloned()
}
pub async fn clear_challenge(&self, token: &str) {
self.challenges.write().await.remove(token);
}
pub fn load_cached_certs(
&self,
domain: &str,
) -> Option<(
Vec<rustls::pki_types::CertificateDer<'static>>,
rustls::pki_types::PrivateKeyDer<'static>,
)> {
let cert_path = self.cert_path(domain);
let key_path = self.key_path(domain);
if !cert_path.exists() || !key_path.exists() {
return None;
}
match certs::load_pem_certs(&cert_path, &key_path) {
Ok(pair) => Some(pair),
Err(e) => {
warn!(domain, error = %e, "Failed to load cached certs");
None
}
}
}
pub fn needs_renewal(&self, domain: &str) -> bool {
let cert_path = self.cert_path(domain);
if !cert_path.exists() {
return true;
}
match certs::check_cert_expiry(&cert_path) {
Ok(days) if days >= RENEWAL_THRESHOLD_DAYS => false,
Ok(days) => {
info!(domain, days_remaining = days, "Certificate expiring soon");
true
}
Err(e) => {
warn!(domain, error = %e, "Cannot check cert expiry");
true
}
}
}
pub fn tls_acceptor_for(&self, domain: &str) -> anyhow::Result<Option<TlsAcceptor>> {
let Some((certs, key)) = self.load_cached_certs(domain) else {
return Ok(None);
};
if self.needs_renewal(domain) {
warn!(domain, "Cert expiring soon — will auto-renew");
}
let config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)?;
Ok(Some(TlsAcceptor::from(Arc::new(config))))
}
pub async fn ensure_cert_for_resolver(
&self,
domain: &str,
resolver: &DynCertResolver,
) -> anyhow::Result<()> {
if resolver.has_cert(domain) && !self.needs_renewal(domain) {
return Ok(());
}
let _permit = self
.provision_lock
.acquire()
.await
.map_err(|e| anyhow::anyhow!("ACME provision lock closed: {e}"))?;
if resolver.has_cert(domain) && !self.needs_renewal(domain) {
return Ok(());
}
let provider = self.provider();
let cert_path = self.cert_path(domain);
let key_path = self.key_path(domain);
let (cert_pem, key_pem) =
if cert_path.exists() && key_path.exists() && !self.needs_renewal(domain) {
info!(domain, "Loading cached cert for hot provisioning");
(std::fs::read(&cert_path)?, std::fs::read(&key_path)?)
} else {
info!(domain, "Hot-provisioning TLS certificate");
provider.provision_cert(domain).await?
};
let certified_key = Self::build_certified_key(&cert_pem, &key_pem)?;
resolver.add_cert(domain, Arc::new(certified_key));
info!(domain, "Certificate ready (hot-provisioned)");
Ok(())
}
fn build_certified_key(
cert_pem: &[u8],
key_pem: &[u8],
) -> anyhow::Result<rustls::sign::CertifiedKey> {
let certs: Vec<_> =
rustls_pemfile::certs(&mut &cert_pem[..]).collect::<Result<Vec<_>, _>>()?;
let key = rustls_pemfile::private_key(&mut &key_pem[..])?
.ok_or_else(|| anyhow::anyhow!("no private key in PEM data"))?;
let signing_key = rustls::crypto::aws_lc_rs::sign::any_supported_type(&key)?;
Ok(rustls::sign::CertifiedKey::new(certs, signing_key))
}
pub fn cert_path(&self, domain: &str) -> PathBuf {
self.cache_dir.join(format!("{domain}.cert.pem"))
}
pub fn key_path(&self, domain: &str) -> PathBuf {
self.cache_dir.join(format!("{domain}.key.pem"))
}
pub async fn domains(&self) -> Vec<String> {
self.domains.read().await.iter().cloned().collect()
}
pub fn provider(&self) -> AcmeProvider {
AcmeProvider::new(
self.acme_email.clone(),
self.cache_dir.clone(),
self.challenges.clone(),
)
}
}
pub(crate) fn default_orca_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".orca")
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_challenge_lifecycle() {
let mgr = AcmeManager::new("test@example.com", "/tmp/orca-test-certs");
assert!(mgr.get_challenge_response("tok1").await.is_none());
mgr.set_challenge("tok1".into(), "auth1".into()).await;
assert_eq!(mgr.get_challenge_response("tok1").await.unwrap(), "auth1");
mgr.clear_challenge("tok1").await;
assert!(mgr.get_challenge_response("tok1").await.is_none());
}
#[tokio::test]
async fn test_domain_registration() {
let mgr = AcmeManager::new("test@example.com", "/tmp/orca-test-certs");
mgr.add_domain("example.com").await;
assert!(mgr.domains().await.contains(&"example.com".to_string()));
}
#[test]
fn test_cert_paths() {
let mgr = AcmeManager::new("test@example.com", "/tmp/certs");
assert_eq!(
mgr.cert_path("example.com"),
PathBuf::from("/tmp/certs/example.com.cert.pem")
);
assert_eq!(
mgr.key_path("example.com"),
PathBuf::from("/tmp/certs/example.com.key.pem")
);
}
#[test]
fn test_missing_certs_needs_renewal() {
let mgr = AcmeManager::new("test@example.com", "/tmp/nonexistent-certs");
assert!(mgr.needs_renewal("example.com"));
}
}