use std::collections::HashMap;
use std::path::{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 orca_core::fsutil;
use super::default_account_path;
#[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");
let tokens = self.handle_authorizations(&mut order).await?;
let status = order.poll_ready(&RetryPolicy::default()).await;
{
let mut challenges = self.challenges.write().await;
for token in &tokens {
challenges.remove(token);
}
}
let status = status?;
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<Vec<String>> {
let mut tokens = Vec::new();
let result = self.authorize(order, &mut tokens).await;
if result.is_err() {
let mut challenges = self.challenges.write().await;
for token in &tokens {
challenges.remove(token);
}
}
result.map(|()| tokens)
}
async fn authorize(
&self,
order: &mut instant_acme::Order,
tokens: &mut Vec<String>,
) -> 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);
tokens.push(token);
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 directory = std::env::var("ORCA_ACME_DIRECTORY")
.unwrap_or_else(|_| LetsEncrypt::Production.url().to_owned());
info!(directory = %directory, "Using ACME directory");
let (account, credentials) = Account::builder()?
.create(
&NewAccount {
contact: &[&contact],
terms_of_service_agreed: true,
only_return_existing: false,
},
directory,
None,
)
.await?;
let json = serde_json::to_string_pretty(&credentials)?;
let path = account_path.clone();
tokio::task::spawn_blocking(move || fsutil::write_private(&path, json.as_bytes()))
.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<()> {
let dir = self.cache_dir.clone();
let cert_path = self.cache_dir.join(format!("{domain}.cert.pem"));
let key_path = self.cache_dir.join(format!("{domain}.key.pem"));
let (cert, key) = (cert_pem.to_vec(), key_pem.to_vec());
let saved_cert = cert_path.clone();
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
fsutil::create_private_dir(&dir)?;
fsutil::write_private(&key_path, &key)?;
fsutil::write_private(&cert_path, &cert)
})
.await??;
debug!(domain, "Saved cert to {}", saved_cert.display());
Ok(())
}
fn account_cache_path(&self) -> PathBuf {
default_account_path()
}
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)))
}
}
pub(crate) fn secure_existing_key_material(
cache_dir: &Path,
account: &Path,
) -> std::io::Result<usize> {
let mut tightened = 0;
if account.exists() && fsutil::restrict(account, 0o600)? {
tightened += 1;
}
if !cache_dir.is_dir() {
return Ok(tightened);
}
if fsutil::restrict(cache_dir, 0o700)? {
tightened += 1;
}
for entry in std::fs::read_dir(cache_dir)? {
let path = entry?.path();
let is_key = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with(".key.pem"));
if is_key && path.is_file() && fsutil::restrict(&path, 0o600)? {
tightened += 1;
}
}
Ok(tightened)
}
#[cfg(test)]
#[path = "provider_tests.rs"]
mod tests;