use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, RwLock};
use anyhow::{Context, Result};
use axum::{
extract::{Path as AxPath, State},
http::StatusCode,
routing::get,
Router,
};
use instant_acme::{
Account, AccountCredentials, AuthorizationStatus, ChallengeType, Identifier, NewAccount,
NewOrder, OrderStatus, RetryPolicy,
};
use tokio::net::TcpListener;
use tracing::{info, warn};
use crate::config::{AcmeCfg, TlsCfg};
const HTTP01_PORT: u16 = 80;
pub async fn obtain_certificate(acme: &AcmeCfg, tls: &TlsCfg) -> Result<()> {
anyhow::ensure!(
!acme.domains.is_empty(),
"tls.acme.domains must list at least one domain"
);
anyhow::ensure!(
acme.accept_tos,
"set tls.acme.accept_tos = true to accept the ACME provider's Terms of Service"
);
anyhow::ensure!(
!tls.cert_path.is_empty() && !tls.key_path.is_empty(),
"tls.cert_path and tls.key_path must be set so the issued certificate can be stored"
);
info!(domains = ?acme.domains, directory = %acme.directory_url, "starting ACME order");
let account = account(acme).await?;
let identifiers: Vec<Identifier> = acme
.domains
.iter()
.map(|d| Identifier::Dns(d.clone()))
.collect();
let mut order = account
.new_order(&NewOrder::new(&identifiers))
.await
.context("creating ACME order")?;
let responses: Arc<RwLock<HashMap<String, String>>> = Arc::new(RwLock::new(HashMap::new()));
let _server = AbortOnDrop(spawn_challenge_server(Arc::clone(&responses)).await?);
let mut authorizations = order.authorizations();
while let Some(result) = authorizations.next().await {
let mut authz = result.context("fetching authorizations")?;
match authz.status {
AuthorizationStatus::Pending => {}
AuthorizationStatus::Valid => continue,
other => anyhow::bail!("unexpected authorization status: {other:?}"),
}
let mut challenge = authz
.challenge(ChallengeType::Http01)
.context("CA offered no http-01 challenge")?;
let token = challenge.token.clone();
let key_auth = challenge.key_authorization().as_str().to_string();
responses
.write()
.expect("challenge response map poisoned")
.insert(token, key_auth);
challenge
.set_ready()
.await
.context("signaling challenge ready")?;
}
let status = order
.poll_ready(&RetryPolicy::default())
.await
.context("waiting for the ACME order to become ready")?;
anyhow::ensure!(
status == OrderStatus::Ready,
"ACME order did not become ready (status: {status:?})"
);
let key_pem = order.finalize().await.context("finalizing ACME order")?;
let cert_chain_pem = order
.poll_certificate(&RetryPolicy::default())
.await
.context("waiting for the issued certificate")?;
write_pem(&tls.cert_path, &cert_chain_pem)?;
write_key_pem(&tls.key_path, &key_pem)?;
info!(cert = %tls.cert_path, key = %tls.key_path, "ACME certificate stored");
Ok(())
}
async fn account(acme: &AcmeCfg) -> Result<Account> {
let creds_path = Path::new(&acme.cache_dir).join("account.json");
if creds_path.exists() {
let raw = std::fs::read_to_string(&creds_path)
.with_context(|| format!("reading cached ACME account {}", creds_path.display()))?;
let creds: AccountCredentials =
serde_json::from_str(&raw).context("parsing cached ACME account credentials")?;
return Account::builder()
.context("building ACME client")?
.from_credentials(creds)
.await
.context("restoring ACME account from cached credentials");
}
let mailto = (!acme.email.is_empty()).then(|| format!("mailto:{}", acme.email));
let contact: Vec<&str> = mailto.as_deref().into_iter().collect();
let (account, credentials) = Account::builder()
.context("building ACME client")?
.create(
&NewAccount {
contact: &contact,
terms_of_service_agreed: acme.accept_tos,
only_return_existing: false,
},
acme.directory_url.clone(),
None,
)
.await
.context("creating ACME account")?;
if let Err(e) = std::fs::create_dir_all(&acme.cache_dir)
.and_then(|_| serde_json::to_string_pretty(&credentials).map_err(std::io::Error::other))
.and_then(|json| std::fs::write(&creds_path, json))
{
warn!(error = %e, path = %creds_path.display(), "could not cache ACME account credentials");
}
Ok(account)
}
async fn spawn_challenge_server(
responses: Arc<RwLock<HashMap<String, String>>>,
) -> Result<tokio::task::JoinHandle<()>> {
let app = Router::new()
.route("/.well-known/acme-challenge/:token", get(challenge_handler))
.with_state(responses);
let listener = TcpListener::bind(("0.0.0.0", HTTP01_PORT))
.await
.with_context(|| format!("binding ACME HTTP-01 listener on :{HTTP01_PORT}"))?;
Ok(tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app).await {
warn!(error = %e, "ACME challenge server stopped");
}
}))
}
async fn challenge_handler(
State(responses): State<Arc<RwLock<HashMap<String, String>>>>,
AxPath(token): AxPath<String>,
) -> (StatusCode, String) {
let found = responses.read().ok().and_then(|m| m.get(&token).cloned());
match found {
Some(key_auth) => (StatusCode::OK, key_auth),
None => (StatusCode::NOT_FOUND, String::new()),
}
}
fn create_parent(path: &str) -> Result<()> {
if let Some(parent) = Path::new(path)
.parent()
.filter(|p| !p.as_os_str().is_empty())
{
std::fs::create_dir_all(parent)
.with_context(|| format!("creating directory for {path}"))?;
}
Ok(())
}
fn write_pem(path: &str, contents: &str) -> Result<()> {
create_parent(path)?;
std::fs::write(path, contents).with_context(|| format!("writing {path}"))
}
fn write_key_pem(path: &str, contents: &str) -> Result<()> {
create_parent(path)?;
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("creating {path} (mode 0600)"))?;
file.write_all(contents.as_bytes())
.with_context(|| format!("writing {path}"))?;
Ok(())
}
#[cfg(not(unix))]
{
std::fs::write(path, contents).with_context(|| format!("writing {path}"))
}
}
struct AbortOnDrop(tokio::task::JoinHandle<()>);
impl Drop for AbortOnDrop {
fn drop(&mut self) {
self.0.abort();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AcmeCfg, TlsCfg};
use std::time::{SystemTime, UNIX_EPOCH};
#[tokio::test]
#[ignore = "requires a live test ACME CA (Pebble) + :80 — see the module test comment"]
async fn acme_http01_issues_against_pebble() {
let Ok(directory_url) = std::env::var("EDGEGUARD_TEST_ACME_DIR") else {
eprintln!("skipping acme_http01_issues_against_pebble: set EDGEGUARD_TEST_ACME_DIR");
return;
};
let domain =
std::env::var("EDGEGUARD_TEST_ACME_DOMAIN").unwrap_or_else(|_| "edgeguard.test".into());
crate::tls::init_crypto();
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let base = std::env::temp_dir().join(format!("eg-acme-{stamp}"));
std::fs::create_dir_all(&base).unwrap();
let cert_path = base.join("cert.pem").to_string_lossy().into_owned();
let key_path = base.join("key.pem").to_string_lossy().into_owned();
let acme = AcmeCfg {
enabled: true,
domains: vec![domain],
email: "ci@example.test".into(),
directory_url,
cache_dir: base.to_string_lossy().into_owned(),
accept_tos: true,
};
let tls = TlsCfg {
enabled: true,
cert_path: cert_path.clone(),
key_path: key_path.clone(),
acme: acme.clone(),
};
obtain_certificate(&acme, &tls)
.await
.expect("ACME HTTP-01 issuance against Pebble");
let cert = std::fs::read_to_string(&cert_path).expect("issued certificate written");
assert!(
cert.contains("BEGIN CERTIFICATE"),
"issued PEM chain present"
);
let key = std::fs::read_to_string(&key_path).expect("private key written");
assert!(key.contains("BEGIN"), "private key PEM present");
let _ = std::fs::remove_dir_all(&base);
}
}