276 lines
8.9 KiB
Rust
276 lines
8.9 KiB
Rust
//! ACME (Let's Encrypt) integration using instant-acme.
|
|
//!
|
|
//! This module handles HTTP-01 challenge creation and certificate provisioning.
|
|
//! Account credentials are ephemeral — the consumer owns all persistence.
|
|
|
|
use instant_acme::{
|
|
Account, NewAccount, NewOrder, Identifier, ChallengeType, OrderStatus,
|
|
AccountCredentials,
|
|
};
|
|
use rcgen::{CertificateParams, KeyPair};
|
|
use thiserror::Error;
|
|
use tracing::{debug, info};
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum AcmeError {
|
|
#[error("ACME account creation failed: {0}")]
|
|
AccountCreation(String),
|
|
#[error("ACME order failed: {0}")]
|
|
OrderFailed(String),
|
|
#[error("Challenge failed: {0}")]
|
|
ChallengeFailed(String),
|
|
#[error("Certificate finalization failed: {0}")]
|
|
FinalizationFailed(String),
|
|
#[error("No HTTP-01 challenge found")]
|
|
NoHttp01Challenge,
|
|
#[error("Timeout waiting for order: {0}")]
|
|
Timeout(String),
|
|
}
|
|
|
|
/// Pending HTTP-01 challenge that needs to be served.
|
|
pub struct PendingChallenge {
|
|
pub token: String,
|
|
pub key_authorization: String,
|
|
pub domain: String,
|
|
}
|
|
|
|
/// ACME client wrapper around instant-acme.
|
|
pub struct AcmeClient {
|
|
use_production: bool,
|
|
email: String,
|
|
}
|
|
|
|
impl AcmeClient {
|
|
pub fn new(email: String, use_production: bool) -> Self {
|
|
Self {
|
|
use_production,
|
|
email,
|
|
}
|
|
}
|
|
|
|
/// Create a new ACME account (ephemeral — not persisted).
|
|
async fn get_or_create_account(&self) -> Result<Account, AcmeError> {
|
|
let directory_url = self.directory_url();
|
|
|
|
let contact = format!("mailto:{}", self.email);
|
|
let (account, _credentials) = Account::create(
|
|
&NewAccount {
|
|
contact: &[&contact],
|
|
terms_of_service_agreed: true,
|
|
only_return_existing: false,
|
|
},
|
|
directory_url,
|
|
None,
|
|
)
|
|
.await
|
|
.map_err(|e| AcmeError::AccountCreation(e.to_string()))?;
|
|
|
|
debug!("ACME account created");
|
|
|
|
Ok(account)
|
|
}
|
|
|
|
/// Request a certificate for a domain using the HTTP-01 challenge.
|
|
///
|
|
/// Returns (cert_chain_pem, private_key_pem) on success.
|
|
///
|
|
/// The caller must serve the HTTP-01 challenge at:
|
|
/// `http://<domain>/.well-known/acme-challenge/<token>`
|
|
///
|
|
/// The `challenge_handler` closure is called with a `PendingChallenge`
|
|
/// and must arrange for the challenge response to be served. It should
|
|
/// return once the challenge is ready to be validated.
|
|
pub async fn provision<F, Fut>(
|
|
&self,
|
|
domain: &str,
|
|
challenge_handler: F,
|
|
) -> Result<(String, String), AcmeError>
|
|
where
|
|
F: FnOnce(PendingChallenge) -> Fut,
|
|
Fut: std::future::Future<Output = Result<(), AcmeError>>,
|
|
{
|
|
info!("Starting ACME provisioning for {} via {}", domain, self.directory_url());
|
|
|
|
// 1. Get or create ACME account
|
|
let account = self.get_or_create_account().await?;
|
|
|
|
// 2. Create order
|
|
let identifier = Identifier::Dns(domain.to_string());
|
|
let mut order = account
|
|
.new_order(&NewOrder {
|
|
identifiers: &[identifier],
|
|
})
|
|
.await
|
|
.map_err(|e| AcmeError::OrderFailed(e.to_string()))?;
|
|
|
|
debug!("ACME order created");
|
|
|
|
// 3. Get authorizations and find HTTP-01 challenge
|
|
let authorizations = order
|
|
.authorizations()
|
|
.await
|
|
.map_err(|e| AcmeError::OrderFailed(e.to_string()))?;
|
|
|
|
// Find the HTTP-01 challenge
|
|
let (challenge_token, challenge_url) = authorizations
|
|
.iter()
|
|
.flat_map(|auth| auth.challenges.iter())
|
|
.find(|c| c.r#type == ChallengeType::Http01)
|
|
.map(|c| {
|
|
let key_auth = order.key_authorization(c);
|
|
(
|
|
PendingChallenge {
|
|
token: c.token.clone(),
|
|
key_authorization: key_auth.as_str().to_string(),
|
|
domain: domain.to_string(),
|
|
},
|
|
c.url.clone(),
|
|
)
|
|
})
|
|
.ok_or(AcmeError::NoHttp01Challenge)?;
|
|
|
|
// Call the handler to set up challenge serving
|
|
challenge_handler(challenge_token).await?;
|
|
|
|
// 4. Notify ACME server that challenge is ready
|
|
order
|
|
.set_challenge_ready(&challenge_url)
|
|
.await
|
|
.map_err(|e| AcmeError::ChallengeFailed(e.to_string()))?;
|
|
|
|
debug!("Challenge marked as ready, waiting for validation...");
|
|
|
|
// 5. Poll for order to become ready
|
|
let mut attempts = 0;
|
|
let state = loop {
|
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
|
let state = order
|
|
.refresh()
|
|
.await
|
|
.map_err(|e| AcmeError::OrderFailed(e.to_string()))?;
|
|
|
|
match state.status {
|
|
OrderStatus::Ready | OrderStatus::Valid => break state.status,
|
|
OrderStatus::Invalid => {
|
|
return Err(AcmeError::ChallengeFailed(
|
|
"Order became invalid (challenge failed)".to_string(),
|
|
));
|
|
}
|
|
_ => {
|
|
attempts += 1;
|
|
if attempts > 30 {
|
|
return Err(AcmeError::Timeout(
|
|
"Order did not become ready within 60 seconds".to_string(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
debug!("Order ready, finalizing...");
|
|
|
|
// 6. Generate CSR and finalize
|
|
let key_pair = KeyPair::generate().map_err(|e| {
|
|
AcmeError::FinalizationFailed(format!("Key generation failed: {}", e))
|
|
})?;
|
|
|
|
let mut params = CertificateParams::new(vec![domain.to_string()]).map_err(|e| {
|
|
AcmeError::FinalizationFailed(format!("CSR params failed: {}", e))
|
|
})?;
|
|
params.distinguished_name.push(rcgen::DnType::CommonName, domain);
|
|
|
|
let csr = params.serialize_request(&key_pair).map_err(|e| {
|
|
AcmeError::FinalizationFailed(format!("CSR serialization failed: {}", e))
|
|
})?;
|
|
|
|
if state == OrderStatus::Ready {
|
|
order
|
|
.finalize(csr.der())
|
|
.await
|
|
.map_err(|e| AcmeError::FinalizationFailed(e.to_string()))?;
|
|
}
|
|
|
|
// 7. Wait for certificate to be issued
|
|
let mut attempts = 0;
|
|
loop {
|
|
let state = order
|
|
.refresh()
|
|
.await
|
|
.map_err(|e| AcmeError::OrderFailed(e.to_string()))?;
|
|
if state.status == OrderStatus::Valid {
|
|
break;
|
|
}
|
|
if state.status == OrderStatus::Invalid {
|
|
return Err(AcmeError::FinalizationFailed(
|
|
"Order became invalid during finalization".to_string(),
|
|
));
|
|
}
|
|
attempts += 1;
|
|
if attempts > 15 {
|
|
return Err(AcmeError::Timeout(
|
|
"Certificate not issued within 30 seconds".to_string(),
|
|
));
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
|
}
|
|
|
|
// 8. Download certificate
|
|
let cert_chain_pem = order
|
|
.certificate()
|
|
.await
|
|
.map_err(|e| AcmeError::FinalizationFailed(e.to_string()))?
|
|
.ok_or_else(|| {
|
|
AcmeError::FinalizationFailed("No certificate returned".to_string())
|
|
})?;
|
|
|
|
let private_key_pem = key_pair.serialize_pem();
|
|
|
|
info!("Certificate provisioned successfully for {}", domain);
|
|
|
|
Ok((cert_chain_pem, private_key_pem))
|
|
}
|
|
|
|
/// Restore an ACME account from stored credentials.
|
|
pub async fn restore_account(
|
|
&self,
|
|
credentials: AccountCredentials,
|
|
) -> Result<Account, AcmeError> {
|
|
Account::from_credentials(credentials)
|
|
.await
|
|
.map_err(|e| AcmeError::AccountCreation(e.to_string()))
|
|
}
|
|
|
|
/// Get the ACME directory URL based on production/staging.
|
|
pub fn directory_url(&self) -> &str {
|
|
if self.use_production {
|
|
"https://acme-v02.api.letsencrypt.org/directory"
|
|
} else {
|
|
"https://acme-staging-v02.api.letsencrypt.org/directory"
|
|
}
|
|
}
|
|
|
|
/// Whether this client is configured for production.
|
|
pub fn is_production(&self) -> bool {
|
|
self.use_production
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_directory_url_staging() {
|
|
let client = AcmeClient::new("test@example.com".to_string(), false);
|
|
assert!(client.directory_url().contains("staging"));
|
|
assert!(!client.is_production());
|
|
}
|
|
|
|
#[test]
|
|
fn test_directory_url_production() {
|
|
let client = AcmeClient::new("test@example.com".to_string(), true);
|
|
assert!(!client.directory_url().contains("staging"));
|
|
assert!(client.is_production());
|
|
}
|
|
}
|