#![allow(dead_code)]
use crate::error::{GatewayError, Result};
use crate::proxy::acme::{AcmeConfig, CertInfo, CertStorage, ChallengeStore, ChallengeType};
use crate::proxy::acme_account::AccountKey;
use crate::proxy::acme_csr::{build_csr, pem_encode};
use crate::proxy::acme_dns;
use crate::proxy::acme_types::{AcmeAuthorization, AcmeDirectory, AcmeOrder};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use ring::rand::SystemRandom;
use ring::signature::{EcdsaKeyPair, ECDSA_P256_SHA256_FIXED_SIGNING};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
pub struct AcmeClient {
pub(crate) config: AcmeConfig,
http: reqwest::Client,
pub(crate) storage: CertStorage,
challenges: Arc<ChallengeStore>,
directory: Option<AcmeDirectory>,
account_key: Option<AccountKey>,
account_url: Option<String>,
}
impl AcmeClient {
pub fn new(config: AcmeConfig, challenges: Arc<ChallengeStore>) -> Result<Self> {
config.validate()?;
let storage = CertStorage::new(&config.storage_path);
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| GatewayError::Other(format!("Failed to create HTTP client: {}", e)))?;
Ok(Self {
config,
http,
storage,
challenges,
directory: None,
account_key: None,
account_url: None,
})
}
pub fn challenges(&self) -> &Arc<ChallengeStore> {
&self.challenges
}
pub fn storage(&self) -> &CertStorage {
&self.storage
}
pub fn ensure_account_key(&mut self) -> Result<()> {
if self.account_key.is_some() {
return Ok(());
}
let key_path = self.config.storage_path.join("account.key");
if key_path.exists() {
let der = std::fs::read(&key_path).map_err(|e| {
GatewayError::Other(format!(
"Failed to read account key {}: {}",
key_path.display(),
e
))
})?;
self.account_key = Some(AccountKey::from_pkcs8(&der)?);
tracing::info!("Loaded existing ACME account key");
} else {
let key = AccountKey::generate()?;
std::fs::create_dir_all(&self.config.storage_path).map_err(|e| {
GatewayError::Other(format!(
"Failed to create ACME storage dir {}: {}",
self.config.storage_path.display(),
e
))
})?;
std::fs::write(&key_path, key.pkcs8_der())
.map_err(|e| GatewayError::Other(format!("Failed to write account key: {}", e)))?;
self.account_key = Some(key);
tracing::info!("Generated new ACME account key");
}
Ok(())
}
pub async fn fetch_directory(&mut self) -> Result<&AcmeDirectory> {
let url = self.config.effective_directory();
let resp = self
.http
.get(url)
.send()
.await
.map_err(|e| GatewayError::Other(format!("ACME directory fetch failed: {}", e)))?;
if !resp.status().is_success() {
return Err(GatewayError::Other(format!(
"ACME directory returned HTTP {}",
resp.status()
)));
}
let dir: AcmeDirectory = resp
.json()
.await
.map_err(|e| GatewayError::Other(format!("ACME directory parse failed: {}", e)))?;
tracing::debug!(
new_account = dir.new_account,
new_order = dir.new_order,
"ACME directory fetched"
);
self.directory = Some(dir);
Ok(self.directory.as_ref().unwrap())
}
pub async fn get_nonce(&self) -> Result<String> {
let dir = self
.directory
.as_ref()
.ok_or_else(|| GatewayError::Other("ACME directory not fetched".to_string()))?;
let resp = self
.http
.head(&dir.new_nonce)
.send()
.await
.map_err(|e| GatewayError::Other(format!("ACME nonce request failed: {}", e)))?;
resp.headers()
.get("replay-nonce")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| GatewayError::Other("No replay-nonce header in response".to_string()))
}
fn build_jws(&self, url: &str, payload: &str, nonce: &str) -> Result<String> {
let key = self
.account_key
.as_ref()
.ok_or_else(|| GatewayError::Other("Account key not loaded".to_string()))?;
let header = if let Some(ref account_url) = self.account_url {
serde_json::json!({
"alg": "ES256",
"kid": account_url,
"nonce": nonce,
"url": url,
})
} else {
serde_json::json!({
"alg": "ES256",
"jwk": key.jwk(),
"nonce": nonce,
"url": url,
})
};
let protected = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes());
let payload_b64 = if payload.is_empty() {
String::new() } else {
URL_SAFE_NO_PAD.encode(payload.as_bytes())
};
let signing_input = format!("{}.{}", protected, payload_b64);
let signature = key.sign(signing_input.as_bytes())?;
let sig_b64 = URL_SAFE_NO_PAD.encode(&signature);
let jws = serde_json::json!({
"protected": protected,
"payload": payload_b64,
"signature": sig_b64,
});
Ok(jws.to_string())
}
async fn acme_post(
&self,
url: &str,
payload: &str,
nonce: &str,
) -> Result<(reqwest::Response, Option<String>)> {
let body = self.build_jws(url, payload, nonce)?;
let resp = self
.http
.post(url)
.header("Content-Type", "application/jose+json")
.body(body)
.send()
.await
.map_err(|e| GatewayError::Other(format!("ACME POST to {} failed: {}", url, e)))?;
let new_nonce = resp
.headers()
.get("replay-nonce")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
Ok((resp, new_nonce))
}
pub async fn register_account(&mut self) -> Result<()> {
self.ensure_account_key()?;
let dir = self
.directory
.as_ref()
.ok_or_else(|| GatewayError::Other("ACME directory not fetched".to_string()))?
.clone();
let nonce = self.get_nonce().await?;
let payload = serde_json::json!({
"termsOfServiceAgreed": true,
"contact": [format!("mailto:{}", self.config.email)],
});
let (resp, _) = self
.acme_post(&dir.new_account, &payload.to_string(), &nonce)
.await?;
let status = resp.status();
if status == 200 || status == 201 {
let account_url = resp
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| {
GatewayError::Other("No Location header in account response".to_string())
})?;
tracing::info!(
account_url = account_url,
status = status.as_u16(),
"ACME account registered"
);
self.account_url = Some(account_url);
Ok(())
} else {
let body = resp.text().await.unwrap_or_default();
Err(GatewayError::Other(format!(
"ACME account registration failed (HTTP {}): {}",
status, body
)))
}
}
pub async fn create_order(&self) -> Result<(AcmeOrder, String)> {
let dir = self
.directory
.as_ref()
.ok_or_else(|| GatewayError::Other("ACME directory not fetched".to_string()))?
.clone();
let identifiers: Vec<serde_json::Value> = self
.config
.domains
.iter()
.map(|d| {
serde_json::json!({
"type": "dns",
"value": d,
})
})
.collect();
let payload = serde_json::json!({
"identifiers": identifiers,
});
let nonce = self.get_nonce().await?;
let (resp, _) = self
.acme_post(&dir.new_order, &payload.to_string(), &nonce)
.await?;
let status = resp.status();
let order_url = resp
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.unwrap_or_default();
if status == 201 || status == 200 {
let order: AcmeOrder = resp
.json()
.await
.map_err(|e| GatewayError::Other(format!("Failed to parse ACME order: {}", e)))?;
tracing::info!(
status = order.status,
authorizations = order.authorizations.len(),
"ACME order created"
);
Ok((order, order_url))
} else {
let body = resp.text().await.unwrap_or_default();
Err(GatewayError::Other(format!(
"ACME order creation failed (HTTP {}): {}",
status, body
)))
}
}
pub async fn solve_http01_challenge(&self, auth_url: &str) -> Result<()> {
let nonce = self.get_nonce().await?;
let (resp, _) = self.acme_post(auth_url, "", &nonce).await?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(GatewayError::Other(format!(
"Failed to fetch authorization {}: {}",
auth_url, body
)));
}
let auth: AcmeAuthorization = resp
.json()
.await
.map_err(|e| GatewayError::Other(format!("Failed to parse authorization: {}", e)))?;
let challenge = auth
.challenges
.iter()
.find(|c| c.challenge_type == "http-01")
.ok_or_else(|| {
GatewayError::Other(format!(
"No HTTP-01 challenge for domain {}",
auth.identifier.value
))
})?;
if challenge.status == "valid" {
tracing::debug!(
domain = auth.identifier.value,
"Challenge already valid, skipping"
);
return Ok(());
}
let key = self
.account_key
.as_ref()
.ok_or_else(|| GatewayError::Other("Account key not loaded".to_string()))?;
let key_auth = format!("{}.{}", challenge.token, key.jwk_thumbprint());
self.challenges.add(challenge.token.clone(), key_auth);
tracing::info!(
domain = auth.identifier.value,
token = challenge.token,
"HTTP-01 challenge token stored, notifying ACME server"
);
let nonce = self.get_nonce().await?;
let (resp, _) = self.acme_post(&challenge.url, "{}", &nonce).await?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(GatewayError::Other(format!(
"Failed to respond to challenge: {}",
body
)));
}
for attempt in 0..30 {
tokio::time::sleep(Duration::from_secs(2)).await;
let nonce = self.get_nonce().await?;
let (resp, _) = self.acme_post(auth_url, "", &nonce).await?;
if !resp.status().is_success() {
continue;
}
let auth: AcmeAuthorization = match resp.json().await {
Ok(a) => a,
Err(_) => continue,
};
match auth.status.as_str() {
"valid" => {
tracing::info!(
domain = auth.identifier.value,
attempts = attempt + 1,
"HTTP-01 challenge validated"
);
self.challenges.remove(&challenge.token);
return Ok(());
}
"invalid" => {
self.challenges.remove(&challenge.token);
return Err(GatewayError::Other(format!(
"Challenge validation failed for domain {}",
auth.identifier.value
)));
}
_ => continue, }
}
self.challenges.remove(&challenge.token);
Err(GatewayError::Other(format!(
"Challenge validation timed out for authorization {}",
auth_url
)))
}
pub async fn solve_dns01_challenge(
&self,
auth_url: &str,
dns_solver: &dyn acme_dns::DnsSolver,
) -> Result<()> {
let nonce = self.get_nonce().await?;
let (resp, _) = self.acme_post(auth_url, "", &nonce).await?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(GatewayError::Other(format!(
"Failed to fetch authorization {}: {}",
auth_url, body
)));
}
let auth: AcmeAuthorization = resp
.json()
.await
.map_err(|e| GatewayError::Other(format!("Failed to parse authorization: {}", e)))?;
let challenge = auth
.challenges
.iter()
.find(|c| c.challenge_type == "dns-01")
.ok_or_else(|| {
GatewayError::Other(format!(
"No DNS-01 challenge for domain {}",
auth.identifier.value
))
})?;
if challenge.status == "valid" {
tracing::debug!(
domain = auth.identifier.value,
"DNS-01 challenge already valid, skipping"
);
return Ok(());
}
let key = self
.account_key
.as_ref()
.ok_or_else(|| GatewayError::Other("Account key not loaded".to_string()))?;
let key_auth = format!("{}.{}", challenge.token, key.jwk_thumbprint());
let digest = ring::digest::digest(&ring::digest::SHA256, key_auth.as_bytes());
let dns_value = URL_SAFE_NO_PAD.encode(digest.as_ref());
let domain = auth
.identifier
.value
.strip_prefix("*.")
.unwrap_or(&auth.identifier.value);
let record_id = dns_solver.create_txt_record(domain, &dns_value).await?;
tracing::info!(
domain = domain,
record_id = record_id,
"DNS-01 TXT record created, waiting for propagation"
);
dns_solver.wait_for_propagation().await;
let nonce = self.get_nonce().await?;
let (resp, _) = self.acme_post(&challenge.url, "{}", &nonce).await?;
if !resp.status().is_success() {
let _ = dns_solver.delete_txt_record(&record_id).await;
let body = resp.text().await.unwrap_or_default();
return Err(GatewayError::Other(format!(
"Failed to respond to DNS-01 challenge: {}",
body
)));
}
let challenge_url = challenge.url.clone();
for attempt in 0..30 {
tokio::time::sleep(Duration::from_secs(2)).await;
let nonce = self.get_nonce().await?;
let (resp, _) = self.acme_post(auth_url, "", &nonce).await?;
if !resp.status().is_success() {
continue;
}
let auth: AcmeAuthorization = match resp.json().await {
Ok(a) => a,
Err(_) => continue,
};
match auth.status.as_str() {
"valid" => {
tracing::info!(
domain = auth.identifier.value,
attempts = attempt + 1,
"DNS-01 challenge validated"
);
if let Err(e) = dns_solver.delete_txt_record(&record_id).await {
tracing::warn!(
record_id = record_id,
error = %e,
"Failed to clean up DNS TXT record"
);
}
return Ok(());
}
"invalid" => {
let _ = dns_solver.delete_txt_record(&record_id).await;
return Err(GatewayError::Other(format!(
"DNS-01 challenge validation failed for domain {}",
auth.identifier.value
)));
}
_ => continue,
}
}
let _ = dns_solver.delete_txt_record(&record_id).await;
Err(GatewayError::Other(format!(
"DNS-01 challenge validation timed out for {}",
challenge_url
)))
}
pub async fn poll_order_ready(&self, order_url: &str) -> Result<AcmeOrder> {
for attempt in 0..30 {
tokio::time::sleep(Duration::from_secs(2)).await;
let nonce = self.get_nonce().await?;
let (resp, _) = self.acme_post(order_url, "", &nonce).await?;
if !resp.status().is_success() {
continue;
}
let order: AcmeOrder = match resp.json().await {
Ok(o) => o,
Err(_) => continue,
};
match order.status.as_str() {
"ready" | "valid" => {
tracing::debug!(
status = order.status,
attempts = attempt + 1,
"Order is ready"
);
return Ok(order);
}
"invalid" => {
return Err(GatewayError::Other("ACME order became invalid".to_string()));
}
_ => continue, }
}
Err(GatewayError::Other(
"Timed out waiting for ACME order to become ready".to_string(),
))
}
pub async fn finalize_order(
&self,
finalize_url: &str,
domains: &[String],
) -> Result<AcmeOrder> {
let rng = SystemRandom::new();
let csr_pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
.map_err(|e| GatewayError::Other(format!("Failed to generate CSR key: {}", e)))?;
let csr_key =
EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, csr_pkcs8.as_ref(), &rng)
.map_err(|e| GatewayError::Other(format!("Failed to parse CSR key: {}", e)))?;
let csr_der = build_csr(&csr_key, domains, &rng)?;
let csr_b64 = URL_SAFE_NO_PAD.encode(&csr_der);
let payload = serde_json::json!({ "csr": csr_b64 });
let nonce = self.get_nonce().await?;
let (resp, _) = self
.acme_post(finalize_url, &payload.to_string(), &nonce)
.await?;
let status = resp.status();
if status.is_success() {
let order: AcmeOrder = resp.json().await.map_err(|e| {
GatewayError::Other(format!("Failed to parse finalize response: {}", e))
})?;
let key_pem = pem_encode("EC PRIVATE KEY", csr_pkcs8.as_ref());
let key_path = self.config.storage_path.join("csr.key.pem");
std::fs::write(&key_path, &key_pem)
.map_err(|e| GatewayError::Other(format!("Failed to write CSR key: {}", e)))?;
tracing::info!(status = order.status, "Order finalized");
Ok(order)
} else {
let body = resp.text().await.unwrap_or_default();
Err(GatewayError::Other(format!(
"ACME finalize failed (HTTP {}): {}",
status, body
)))
}
}
pub async fn download_certificate(&self, cert_url: &str) -> Result<String> {
let nonce = self.get_nonce().await?;
let (resp, _) = self.acme_post(cert_url, "", &nonce).await?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(GatewayError::Other(format!(
"Certificate download failed: {}",
body
)));
}
let cert_pem = resp
.text()
.await
.map_err(|e| GatewayError::Other(format!("Failed to read certificate body: {}", e)))?;
tracing::info!(bytes = cert_pem.len(), "Certificate downloaded");
Ok(cert_pem)
}
pub async fn issue_certificate(&mut self) -> Result<CertInfo> {
self.fetch_directory().await?;
self.register_account().await?;
let (order, order_url) = self.create_order().await?;
match self.config.challenge_type {
ChallengeType::Http01 => {
for auth_url in &order.authorizations {
self.solve_http01_challenge(auth_url).await?;
}
}
ChallengeType::Dns01 => {
let dns_config = self.config.dns_provider.as_ref().ok_or_else(|| {
GatewayError::Other(
"DNS provider configuration required for DNS-01 challenge".to_string(),
)
})?;
let solver = acme_dns::create_solver(dns_config)?;
for auth_url in &order.authorizations {
self.solve_dns01_challenge(auth_url, solver.as_ref())
.await?;
}
}
}
let order = self.poll_order_ready(&order_url).await?;
let order = if order.status == "ready" {
self.finalize_order(&order.finalize, &self.config.domains.clone())
.await?
} else {
order
};
let order = if order.certificate.is_none() {
self.poll_order_ready(&order_url).await?
} else {
order
};
let cert_url = order.certificate.ok_or_else(|| {
GatewayError::Other("Order completed but no certificate URL".to_string())
})?;
let cert_pem = self.download_certificate(&cert_url).await?;
let key_path = self.config.storage_path.join("csr.key.pem");
let key_pem = std::fs::read_to_string(&key_path)
.map_err(|e| GatewayError::Other(format!("Failed to read CSR key: {}", e)))?;
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let cert_info = CertInfo {
domain: self.config.domains.first().cloned().unwrap_or_default(),
cert_pem,
key_pem,
expires_at: now + 90 * 86400, issued_at: now,
};
self.storage.save(&cert_info)?;
tracing::info!(
domain = cert_info.domain,
expires_in_days = 90,
"Certificate issued and saved"
);
Ok(cert_info)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn test_config() -> AcmeConfig {
AcmeConfig {
email: "test@example.com".to_string(),
domains: vec!["example.com".to_string()],
staging: true,
storage_path: PathBuf::from("/tmp/acme-test"),
..Default::default()
}
}
#[test]
fn test_client_new() {
let challenges = Arc::new(ChallengeStore::new());
let client = AcmeClient::new(test_config(), challenges).unwrap();
assert!(client.challenges().is_empty());
}
#[test]
fn test_client_new_invalid_config() {
let challenges = Arc::new(ChallengeStore::new());
let config = AcmeConfig::default(); let result = AcmeClient::new(config, challenges);
assert!(result.is_err());
}
#[test]
fn test_client_ensure_account_key() {
let dir = tempfile::tempdir().unwrap();
let challenges = Arc::new(ChallengeStore::new());
let config = AcmeConfig {
email: "test@example.com".to_string(),
domains: vec!["example.com".to_string()],
storage_path: dir.path().to_path_buf(),
..Default::default()
};
let mut client = AcmeClient::new(config, challenges).unwrap();
client.ensure_account_key().unwrap();
assert!(dir.path().join("account.key").exists());
client.account_key = None;
client.ensure_account_key().unwrap();
}
#[test]
fn test_build_jws_without_account() {
let dir = tempfile::tempdir().unwrap();
let challenges = Arc::new(ChallengeStore::new());
let config = AcmeConfig {
email: "test@example.com".to_string(),
domains: vec!["example.com".to_string()],
storage_path: dir.path().to_path_buf(),
..Default::default()
};
let mut client = AcmeClient::new(config, challenges).unwrap();
client.ensure_account_key().unwrap();
let jws = client
.build_jws(
"https://acme.example/new-acct",
r#"{"test":true}"#,
"nonce123",
)
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&jws).unwrap();
assert!(parsed["protected"].is_string());
assert!(parsed["payload"].is_string());
assert!(parsed["signature"].is_string());
let protected = URL_SAFE_NO_PAD
.decode(parsed["protected"].as_str().unwrap())
.unwrap();
let header: serde_json::Value = serde_json::from_slice(&protected).unwrap();
assert_eq!(header["alg"], "ES256");
assert!(header["jwk"].is_object());
assert!(header.get("kid").is_none());
}
#[test]
fn test_build_jws_with_account() {
let dir = tempfile::tempdir().unwrap();
let challenges = Arc::new(ChallengeStore::new());
let config = AcmeConfig {
email: "test@example.com".to_string(),
domains: vec!["example.com".to_string()],
storage_path: dir.path().to_path_buf(),
..Default::default()
};
let mut client = AcmeClient::new(config, challenges).unwrap();
client.ensure_account_key().unwrap();
client.account_url = Some("https://acme.example/acct/1".to_string());
let jws = client
.build_jws("https://acme.example/order", r#"{"test":true}"#, "nonce456")
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&jws).unwrap();
let protected = URL_SAFE_NO_PAD
.decode(parsed["protected"].as_str().unwrap())
.unwrap();
let header: serde_json::Value = serde_json::from_slice(&protected).unwrap();
assert_eq!(header["alg"], "ES256");
assert_eq!(header["kid"], "https://acme.example/acct/1");
assert!(header.get("jwk").is_none());
}
#[test]
fn test_build_jws_post_as_get() {
let dir = tempfile::tempdir().unwrap();
let challenges = Arc::new(ChallengeStore::new());
let config = AcmeConfig {
email: "test@example.com".to_string(),
domains: vec!["example.com".to_string()],
storage_path: dir.path().to_path_buf(),
..Default::default()
};
let mut client = AcmeClient::new(config, challenges).unwrap();
client.ensure_account_key().unwrap();
let jws = client
.build_jws("https://acme.example/auth", "", "nonce789")
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&jws).unwrap();
assert_eq!(parsed["payload"], "");
}
#[test]
fn test_build_jws_no_key_fails() {
let challenges = Arc::new(ChallengeStore::new());
let client = AcmeClient::new(test_config(), challenges).unwrap();
let result = client.build_jws("https://acme.example/test", "{}", "nonce");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Account key"));
}
#[test]
fn test_acme_client_challenges_accessor() {
let challenges = Arc::new(ChallengeStore::new());
let client = AcmeClient::new(test_config(), challenges.clone()).unwrap();
assert!(client.challenges().is_empty());
}
}