use std::collections::HashMap;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{bail, Context, Result};
use bytes::Bytes;
use instant_acme::{
Account, AccountCredentials, AuthorizationStatus, ChallengeType,
HttpClient, Identifier, LetsEncrypt, NewAccount, NewOrder, OrderStatus, RetryPolicy,
};
use tokio::sync::RwLock;
use tracing::{error, info};
pub struct AcmeConfig {
pub email: String,
pub domains: Vec<String>,
pub cert_path: PathBuf,
pub key_path: PathBuf,
pub cache_dir: PathBuf,
pub staging: bool,
pub challenge_port: u16,
}
pub fn needs_renewal(cert_path: &Path) -> bool {
if !cert_path.exists() {
return true;
}
match std::fs::metadata(cert_path).and_then(|m| m.modified()) {
Ok(t) => {
std::time::SystemTime::now()
.duration_since(t)
.unwrap_or_default()
> Duration::from_secs(60 * 86400)
}
Err(_) => true,
}
}
struct ReqwestClient(reqwest::Client);
impl HttpClient for ReqwestClient {
fn request(
&self,
req: http::Request<instant_acme::BodyWrapper<Bytes>>,
) -> Pin<Box<dyn Future<Output = Result<instant_acme::BytesResponse, instant_acme::Error>> + Send>>
{
use http_body_util::BodyExt as _;
let client = self.0.clone();
Box::pin(async move {
let (parts, body) = req.into_parts();
let body_bytes = body.collect().await
.unwrap_or_else(|_| unreachable!("Infallible"))
.to_bytes();
let mut rb = client.request(parts.method.clone(), parts.uri.to_string());
for (name, value) in &parts.headers {
rb = rb.header(name, value);
}
rb = rb.body(reqwest::Body::from(body_bytes));
let rsp = rb.send().await
.map_err(|e| instant_acme::Error::Other(Box::new(e)))?;
let status = http::StatusCode::from_u16(rsp.status().as_u16())
.map_err(|e| instant_acme::Error::Other(Box::new(e)))?;
let mut builder = http::Response::builder().status(status);
for (k, v) in rsp.headers() {
builder = builder.header(k, v);
}
let (rsp_parts, _) = builder
.body(())
.map_err(|e| instant_acme::Error::Other(Box::new(e)))?
.into_parts();
let body_bytes: Bytes = rsp.bytes().await
.map_err(|e| instant_acme::Error::Other(Box::new(e)))?;
Ok(instant_acme::BytesResponse {
parts: rsp_parts,
body: Box::new(body_bytes),
})
})
}
}
async fn build_account(config: &AcmeConfig) -> Result<Account> {
let dir_url = if config.staging {
LetsEncrypt::Staging.url().to_owned()
} else {
LetsEncrypt::Production.url().to_owned()
};
let creds_path = config.cache_dir.join("account.json");
if creds_path.exists() {
let json = std::fs::read_to_string(&creds_path)
.context("read ACME account credentials")?;
let creds: AccountCredentials = serde_json::from_str(&json)
.context("parse ACME account credentials")?;
return Account::builder_with_http(Box::new(ReqwestClient(reqwest::Client::new())))
.from_credentials(creds)
.await
.context("restore ACME account");
}
let contact = format!("mailto:{}", config.email);
let (account, creds) = Account::builder_with_http(
Box::new(ReqwestClient(reqwest::Client::new())),
)
.create(
&NewAccount {
contact: &[&contact],
terms_of_service_agreed: true,
only_return_existing: false,
},
dir_url,
None,
)
.await
.context("create ACME account")?;
let json = serde_json::to_string_pretty(&creds)?;
std::fs::write(&creds_path, &json).context("save ACME account credentials")?;
info!(path = %creds_path.display(), "ACME account credentials saved");
Ok(account)
}
pub async fn ensure_certificate(config: &AcmeConfig) -> Result<()> {
std::fs::create_dir_all(&config.cache_dir)
.with_context(|| format!("create ACME cache dir: {}", config.cache_dir.display()))?;
let account = build_account(config).await?;
let identifiers: Vec<Identifier> = config.domains.iter()
.map(|d| Identifier::Dns(d.clone()))
.collect();
let mut order = account
.new_order(&NewOrder::new(&identifiers))
.await
.context("create ACME order")?;
let token_map: Arc<RwLock<HashMap<String, String>>> =
Arc::new(RwLock::new(HashMap::new()));
let mut any_pending = false;
{
let mut authorizations = order.authorizations();
while let Some(result) = authorizations.next().await {
let mut authz = result.context("fetch ACME authorization")?;
match authz.status {
AuthorizationStatus::Valid => continue,
AuthorizationStatus::Pending => any_pending = true,
other => bail!("Authorization status {other:?} — cannot proceed"),
}
let Some(mut challenge) = authz.challenge(ChallengeType::Http01) else {
bail!("No HTTP-01 challenge offered by the ACME server");
};
let token = challenge.token.clone();
let key_auth = challenge.key_authorization().as_str().to_owned();
token_map.write().await.insert(token, key_auth);
challenge.set_ready().await.context("notify ACME challenge ready")?;
}
}
if !any_pending {
info!("All ACME authorizations already valid — skipping challenge phase");
}
let port = config.challenge_port;
let tokens = Arc::clone(&token_map);
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let server = tokio::spawn(async move {
challenge_server(tokens, port, shutdown_rx).await;
});
tokio::time::sleep(Duration::from_millis(500)).await;
let status = order.poll_ready(&RetryPolicy::default()).await
.context("poll ACME order ready")?;
let _ = shutdown_tx.send(());
let _ = server.await;
if status != OrderStatus::Ready {
bail!("ACME order not ready after polling: {status:?}");
}
let private_key_pem = order.finalize().await
.context("finalize ACME order")?;
let cert_chain_pem = order.poll_certificate(&RetryPolicy::default()).await
.context("download certificate")?;
let cert_tmp = config.cert_path.with_extension("pem.tmp");
let key_tmp = config.key_path.with_extension("pem.tmp");
std::fs::write(&cert_tmp, &cert_chain_pem).context("write cert temp file")?;
std::fs::write(&key_tmp, &private_key_pem).context("write key temp file")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&key_tmp, std::fs::Permissions::from_mode(0o600));
}
std::fs::rename(&cert_tmp, &config.cert_path).context("rename cert file")?;
std::fs::rename(&key_tmp, &config.key_path).context("rename key file")?;
info!(
cert = %config.cert_path.display(),
key = %config.key_path.display(),
"TLS certificate issued/renewed via Let's Encrypt"
);
Ok(())
}
async fn challenge_server(
tokens: Arc<RwLock<HashMap<String, String>>>,
port: u16,
shutdown: tokio::sync::oneshot::Receiver<()>,
) {
use axum::{extract::Path as APath, extract::State, http::StatusCode, routing::get, Router};
async fn handler(
APath(token): APath<String>,
State(tokens): State<Arc<RwLock<HashMap<String, String>>>>,
) -> (StatusCode, String) {
let map = tokens.read().await;
match map.get(&token) {
Some(ka) => (StatusCode::OK, ka.clone()),
None => (StatusCode::NOT_FOUND, String::new()),
}
}
let app = Router::new()
.route("/.well-known/acme-challenge/:token", get(handler))
.with_state(tokens);
let addr = format!("0.0.0.0:{port}");
let listener = match tokio::net::TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) => {
error!(addr, err = %e, "ACME: cannot bind challenge server — HTTP-01 will fail");
return;
}
};
info!(addr, "ACME HTTP-01 challenge server started");
axum::serve(listener, app)
.with_graceful_shutdown(async move { let _ = shutdown.await; })
.await
.ok();
}
pub async fn renewal_loop(config: AcmeConfig) {
loop {
tokio::time::sleep(Duration::from_secs(6 * 3600)).await;
if needs_renewal(&config.cert_path) {
info!("ACME: triggering cert renewal (≤30 days remaining)");
match ensure_certificate(&config).await {
Ok(()) => info!(
"ACME cert renewed — restart runbound to apply the new certificate"
),
Err(e) => error!(err = %e, "ACME cert renewal failed — will retry in 6 h"),
}
}
}
}