use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use instant_acme::{
Account, AccountCredentials, AuthorizationStatus, ChallengeType, Identifier, LetsEncrypt,
NewAccount, NewOrder, OrderStatus, RetryPolicy,
};
use tokio::sync::RwLock;
use tracing::{debug, info};
use super::default_orca_dir;
#[derive(Clone)]
pub struct AcmeProvider {
email: String,
cache_dir: PathBuf,
challenges: Arc<RwLock<HashMap<String, String>>>,
}
impl AcmeProvider {
pub fn new(
email: String,
cache_dir: PathBuf,
challenges: Arc<RwLock<HashMap<String, String>>>,
) -> Self {
Self {
email,
cache_dir,
challenges,
}
}
pub async fn provision_cert(&self, domain: &str) -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
info!(domain, "Starting ACME certificate provisioning");
let account = self.load_or_create_account().await?;
let identifiers = vec![Identifier::Dns(domain.to_string())];
let mut order = account.new_order(&NewOrder::new(&identifiers)).await?;
debug!(domain, "ACME order created");
self.handle_authorizations(&mut order).await?;
let status = order.poll_ready(&RetryPolicy::default()).await?;
self.challenges.write().await.clear();
if status != OrderStatus::Ready {
anyhow::bail!("Order not ready after challenges: {status:?}");
}
info!(domain, "ACME order ready, finalizing");
let key_pem = order.finalize().await?;
let cert_pem = order.poll_certificate(&RetryPolicy::default()).await?;
self.save_cert(domain, cert_pem.as_bytes(), key_pem.as_bytes())
.await?;
info!(domain, "Certificate provisioned and cached");
Ok((cert_pem.into_bytes(), key_pem.into_bytes()))
}
async fn handle_authorizations(&self, order: &mut instant_acme::Order) -> anyhow::Result<()> {
let mut authorizations = order.authorizations();
while let Some(result) = authorizations.next().await {
let mut authz = result?;
if authz.status == AuthorizationStatus::Valid {
debug!("Authorization already valid");
continue;
}
let mut challenge = authz
.challenge(ChallengeType::Http01)
.ok_or_else(|| anyhow::anyhow!("No HTTP-01 challenge offered"))?;
let token = challenge.token.clone();
let key_auth = challenge.key_authorization().as_str().to_string();
debug!(token = %token, "Serving HTTP-01 challenge");
self.challenges
.write()
.await
.insert(token.clone(), key_auth);
challenge.set_ready().await?;
}
Ok(())
}
async fn load_or_create_account(&self) -> anyhow::Result<Account> {
let account_path = self.account_cache_path();
if account_path.exists() {
debug!("Loading cached ACME account");
let json = tokio::fs::read_to_string(&account_path).await?;
let creds: AccountCredentials = serde_json::from_str(&json)?;
let account = Account::builder()?.from_credentials(creds).await?;
return Ok(account);
}
info!(email = %self.email, "Creating new ACME account");
let contact = format!("mailto:{}", self.email);
let (account, credentials) = Account::builder()?
.create(
&NewAccount {
contact: &[&contact],
terms_of_service_agreed: true,
only_return_existing: false,
},
LetsEncrypt::Production.url().to_owned(),
None,
)
.await?;
if let Some(parent) = account_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let json = serde_json::to_string_pretty(&credentials)?;
tokio::fs::write(&account_path, json).await?;
info!("ACME account cached at {}", account_path.display());
Ok(account)
}
async fn save_cert(&self, domain: &str, cert_pem: &[u8], key_pem: &[u8]) -> anyhow::Result<()> {
tokio::fs::create_dir_all(&self.cache_dir).await?;
let cert_path = self.cache_dir.join(format!("{domain}.cert.pem"));
let key_path = self.cache_dir.join(format!("{domain}.key.pem"));
tokio::fs::write(&cert_path, cert_pem).await?;
tokio::fs::write(&key_path, key_pem).await?;
debug!(domain, "Saved cert to {}", cert_path.display());
Ok(())
}
fn account_cache_path(&self) -> PathBuf {
default_orca_dir().join("acme-account.json")
}
pub async fn ensure_cert(&self, domain: &str) -> anyhow::Result<tokio_rustls::TlsAcceptor> {
let cert_path = self.cache_dir.join(format!("{domain}.cert.pem"));
let key_path = self.cache_dir.join(format!("{domain}.key.pem"));
if cert_path.exists()
&& key_path.exists()
&& let Ok(days) = super::certs::check_cert_expiry(&cert_path)
{
if days >= super::RENEWAL_THRESHOLD_DAYS {
debug!(domain, days_remaining = days, "Using cached cert");
return self.build_acceptor(&cert_path, &key_path);
}
info!(domain, days_remaining = days, "Cert expiring, renewing");
}
let (cert_pem, key_pem) = self.provision_cert(domain).await?;
self.build_acceptor_from_pem(&cert_pem, &key_pem)
}
fn build_acceptor(
&self,
cert_path: &std::path::Path,
key_path: &std::path::Path,
) -> anyhow::Result<tokio_rustls::TlsAcceptor> {
let (certs, key) = super::certs::load_pem_certs(cert_path, key_path)?;
let config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)?;
Ok(tokio_rustls::TlsAcceptor::from(Arc::new(config)))
}
fn build_acceptor_from_pem(
&self,
cert_pem: &[u8],
key_pem: &[u8],
) -> anyhow::Result<tokio_rustls::TlsAcceptor> {
let certs = 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 config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)?;
Ok(tokio_rustls::TlsAcceptor::from(Arc::new(config)))
}
}