use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use purecrypto::ec::BoxedEcdsaPrivateKey;
use purecrypto::hash::sha256;
use purecrypto::x509::Certificate;
use super::client::{AcmeClient, ChallengeSolver, LETSENCRYPT_PRODUCTION};
use super::jose::AccountKey;
use super::store::Store;
use crate::error::{Error, Result};
use crate::tls::TlsAcceptor;
const RENEW_BEFORE_SECS: u64 = 30 * 86_400;
const CACHE_MAX: usize = 1024;
const NEG_CACHE_COOLDOWN_SECS: u64 = 300;
const NEG_CACHE_MAX: usize = 4096;
const MAX_INFLIGHT: usize = 16;
#[derive(Debug, Clone)]
pub struct AcmeConfig {
pub directory_url: String,
pub accept_tos: bool,
pub email: Option<String>,
pub host_whitelist: Option<HashSet<String>>,
pub cert_dir: Option<PathBuf>,
}
impl Default for AcmeConfig {
fn default() -> AcmeConfig {
AcmeConfig {
directory_url: LETSENCRYPT_PRODUCTION.to_owned(),
accept_tos: false,
email: None,
host_whitelist: None,
cert_dir: None,
}
}
}
pub enum CertChoice {
Serve(TlsAcceptor),
Reject,
}
struct Cached {
acceptor: TlsAcceptor,
not_after: Option<u64>,
}
struct CertCache {
map: HashMap<String, (Arc<Cached>, u64)>,
tick: u64,
}
impl CertCache {
fn new() -> CertCache {
CertCache {
map: HashMap::new(),
tick: 0,
}
}
fn get(&mut self, host: &str) -> Option<Arc<Cached>> {
self.tick += 1;
let tick = self.tick;
let entry = self.map.get_mut(host)?;
entry.1 = tick;
Some(Arc::clone(&entry.0))
}
fn put(&mut self, host: &str, value: Arc<Cached>) {
self.tick += 1;
let tick = self.tick;
if !self.map.contains_key(host)
&& self.map.len() >= CACHE_MAX
&& let Some(lru) = self
.map
.iter()
.min_by_key(|(_, (_, t))| *t)
.map(|(k, _)| k.clone())
{
self.map.remove(&lru);
}
self.map.insert(host.to_owned(), (value, tick));
}
}
#[derive(Clone)]
pub struct AcmeManager {
inner: Arc<Inner>,
}
struct Inner {
cfg: AcmeConfig,
store: Store,
self_signed: TlsAcceptor,
cache: Mutex<CertCache>,
locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
failures: Mutex<HashMap<String, u64>>,
in_flight: AtomicUsize,
alpn_challenges: Arc<Mutex<HashMap<String, TlsAcceptor>>>,
http_challenges: Arc<Mutex<HashMap<String, String>>>,
}
impl AcmeManager {
pub fn new(cfg: AcmeConfig) -> Result<AcmeManager> {
let store = Store::open(cfg.cert_dir.clone())?;
let self_signed = TlsAcceptor::self_signed(&["localhost"])?;
Ok(AcmeManager {
inner: Arc::new(Inner {
cfg,
store,
self_signed,
cache: Mutex::new(CertCache::new()),
locks: Mutex::new(HashMap::new()),
failures: Mutex::new(HashMap::new()),
in_flight: AtomicUsize::new(0),
alpn_challenges: Arc::new(Mutex::new(HashMap::new())),
http_challenges: Arc::new(Mutex::new(HashMap::new())),
}),
})
}
pub fn self_signed(&self) -> TlsAcceptor {
self.inner.self_signed.clone()
}
pub fn challenge_acceptor(&self, host: &str) -> Option<TlsAcceptor> {
self.inner
.alpn_challenges
.lock()
.unwrap()
.get(&normalize(host))
.cloned()
}
pub fn http_challenge(&self, token: &str) -> Option<String> {
self.inner
.http_challenges
.lock()
.unwrap()
.get(token)
.cloned()
}
pub fn choose(&self, sni: Option<&str>, peer_is_loopback: bool) -> CertChoice {
if peer_is_loopback {
return CertChoice::Serve(self.self_signed());
}
let Some(host) = sni.map(normalize).filter(|h| !h.is_empty()) else {
return CertChoice::Serve(self.self_signed());
};
if let Some(wl) = &self.inner.cfg.host_whitelist
&& !wl.contains(&host)
{
return CertChoice::Reject;
}
match self.get_or_issue(&host) {
Ok(acceptor) => CertChoice::Serve(acceptor),
Err(e) => {
if cfg!(debug_assertions) {
eprintln!("httpsd: acme: no certificate for {host}: {e}");
}
CertChoice::Reject
}
}
}
pub fn choose_cached(&self, sni: Option<&str>, peer_is_loopback: bool) -> CertChoice {
if peer_is_loopback {
return CertChoice::Serve(self.self_signed());
}
let Some(host) = sni.map(normalize).filter(|h| !h.is_empty()) else {
return CertChoice::Serve(self.self_signed());
};
if let Some(wl) = &self.inner.cfg.host_whitelist
&& !wl.contains(&host)
{
return CertChoice::Reject;
}
if let Some(c) = self.inner.cache.lock().unwrap().get(&host) {
return CertChoice::Serve(c.acceptor.clone());
}
match self.inner.store.load_cert(&host) {
Ok(Some(stored)) => match TlsAcceptor::from_pem(&stored.chain_pem, &stored.key_pem) {
Ok(acceptor) => {
let not_after = cert_not_after(&stored.chain_pem);
self.cache_put(&host, acceptor.clone(), not_after);
CertChoice::Serve(acceptor)
}
Err(_) => CertChoice::Reject,
},
_ => CertChoice::Reject,
}
}
fn get_or_issue(&self, host: &str) -> Result<TlsAcceptor> {
let now = now_secs();
if let Some(c) = self.inner.cache.lock().unwrap().get(host)
&& !near_expiry(c.not_after, now)
{
return Ok(c.acceptor.clone());
}
if self.in_backoff(host, now) {
return Err(Error::Acme(format!(
"{host}: skipping issuance, backing off after a recent failure"
)));
}
let lock = self.host_lock(host);
let result = {
let _guard = lock.lock().unwrap();
if let Some(c) = self.inner.cache.lock().unwrap().get(host)
&& !near_expiry(c.not_after, now)
{
Ok(c.acceptor.clone())
} else if self.in_backoff(host, now) {
Err(Error::Acme(format!(
"{host}: skipping issuance, backing off after a recent failure"
)))
} else {
self.try_issue(host, now)
}
};
self.release_host_lock(host, lock);
result
}
fn try_issue(&self, host: &str, now: u64) -> Result<TlsAcceptor> {
let stored = self.inner.store.load_cert(host)?;
if let Some(stored) = &stored {
let not_after = cert_not_after(&stored.chain_pem);
if !near_expiry(not_after, now) {
let acceptor = TlsAcceptor::from_pem(&stored.chain_pem, &stored.key_pem)?;
self.cache_put(host, acceptor.clone(), not_after);
self.clear_failure(host);
return Ok(acceptor);
}
}
let Some(_permit) = self.acquire_permit() else {
return Err(Error::Acme(format!(
"{host}: too many certificate issuances in flight, retry shortly"
)));
};
match self.issue(host) {
Ok(acceptor) => {
self.clear_failure(host);
Ok(acceptor)
}
Err(e) => {
if let Some(stored) = &stored {
let not_after = cert_not_after(&stored.chain_pem);
if not_after.is_some_and(|t| t > now) {
if cfg!(debug_assertions) {
eprintln!(
"httpsd: acme: renewal for {host} failed, serving existing: {e}"
);
}
let acceptor = TlsAcceptor::from_pem(&stored.chain_pem, &stored.key_pem)?;
self.cache_put(host, acceptor.clone(), not_after);
return Ok(acceptor);
}
}
self.record_failure(host, now);
Err(e)
}
}
}
fn issue(&self, host: &str) -> Result<TlsAcceptor> {
if !self.inner.cfg.accept_tos {
return Err(Error::Acme(
"automatic issuance disabled: the CA terms of service have not been accepted"
.into(),
));
}
let mut client = self.make_client()?;
let solver = ManagerSolver {
alpn: Arc::clone(&self.inner.alpn_challenges),
http: Arc::clone(&self.inner.http_challenges),
};
let issued = client.issue(&[host], &solver)?;
self.inner
.store
.save_cert(host, &issued.chain_pem, &issued.key_pem)?;
let not_after = cert_not_after(&issued.chain_pem);
let acceptor = TlsAcceptor::from_pem(&issued.chain_pem, &issued.key_pem)?;
self.cache_put(host, acceptor.clone(), not_after);
Ok(acceptor)
}
fn make_client(&self) -> Result<AcmeClient> {
let account = match self.inner.store.load_account_key()? {
Some(pem) => {
let key = BoxedEcdsaPrivateKey::from_sec1_pem(&pem)
.map_err(|e| Error::Acme(format!("account key: {e:?}")))?;
AccountKey::new(key)
}
None => {
let acct = AccountKey::generate();
self.inner
.store
.save_account_key(&acct.private_key().to_sec1_pem())?;
acct
}
};
AcmeClient::new(
&self.inner.cfg.directory_url,
account,
self.inner.cfg.email.clone(),
)
}
fn host_lock(&self, host: &str) -> Arc<Mutex<()>> {
self.inner
.locks
.lock()
.unwrap()
.entry(host.to_owned())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
fn release_host_lock(&self, host: &str, lock: Arc<Mutex<()>>) {
let mut locks = self.inner.locks.lock().unwrap();
if Arc::strong_count(&lock) == 2 {
locks.remove(host);
}
}
fn in_backoff(&self, host: &str, now: u64) -> bool {
let mut failures = self.inner.failures.lock().unwrap();
match failures.get(host).copied() {
Some(retry_at) if retry_at > now => true,
Some(_) => {
failures.remove(host);
false
}
None => false,
}
}
fn record_failure(&self, host: &str, now: u64) {
let retry_at = now.saturating_add(NEG_CACHE_COOLDOWN_SECS);
let mut failures = self.inner.failures.lock().unwrap();
if !failures.contains_key(host) && failures.len() >= NEG_CACHE_MAX {
failures.retain(|_, &mut t| t > now);
if failures.len() >= NEG_CACHE_MAX
&& let Some(oldest) = failures
.iter()
.min_by_key(|&(_, &t)| t)
.map(|(k, _)| k.clone())
{
failures.remove(&oldest);
}
}
failures.insert(host.to_owned(), retry_at);
}
fn clear_failure(&self, host: &str) {
self.inner.failures.lock().unwrap().remove(host);
}
fn acquire_permit(&self) -> Option<Permit<'_>> {
let prev = self.inner.in_flight.fetch_add(1, Ordering::SeqCst);
if prev >= MAX_INFLIGHT {
self.inner.in_flight.fetch_sub(1, Ordering::SeqCst);
None
} else {
Some(Permit {
counter: &self.inner.in_flight,
})
}
}
fn cache_put(&self, host: &str, acceptor: TlsAcceptor, not_after: Option<u64>) {
self.inner.cache.lock().unwrap().put(
host,
Arc::new(Cached {
acceptor,
not_after,
}),
);
}
}
struct Permit<'a> {
counter: &'a AtomicUsize,
}
impl Drop for Permit<'_> {
fn drop(&mut self) {
self.counter.fetch_sub(1, Ordering::SeqCst);
}
}
struct ManagerSolver {
alpn: Arc<Mutex<HashMap<String, TlsAcceptor>>>,
http: Arc<Mutex<HashMap<String, String>>>,
}
impl ChallengeSolver for ManagerSolver {
fn preferred(&self) -> &[&'static str] {
&["tls-alpn-01", "http-01"]
}
fn present(&self, typ: &str, host: &str, token: &str, key_auth: &str) -> Result<()> {
match typ {
"tls-alpn-01" => {
let digest = sha256(key_auth.as_bytes());
let acceptor = TlsAcceptor::acme_challenge(host, &digest)?;
self.alpn.lock().unwrap().insert(normalize(host), acceptor);
}
"http-01" => {
self.http
.lock()
.unwrap()
.insert(token.to_owned(), key_auth.to_owned());
}
other => return Err(Error::Acme(format!("unsupported challenge: {other}"))),
}
Ok(())
}
fn cleanup(&self, typ: &str, host: &str, token: &str) {
match typ {
"tls-alpn-01" => {
self.alpn.lock().unwrap().remove(&normalize(host));
}
"http-01" => {
self.http.lock().unwrap().remove(token);
}
_ => {}
}
}
}
fn normalize(host: &str) -> String {
host.trim().trim_end_matches('.').to_ascii_lowercase()
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn near_expiry(not_after: Option<u64>, now: u64) -> bool {
match not_after {
Some(t) => now + RENEW_BEFORE_SECS >= t,
None => true,
}
}
fn cert_not_after(chain_pem: &str) -> Option<u64> {
let cert = Certificate::from_pem(chain_pem).ok()?;
Some(cert.validity().ok()?.not_after.to_unix())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expiry_window() {
let now = 1_000_000_000;
assert!(near_expiry(Some(now + 10 * 86_400), now)); assert!(!near_expiry(Some(now + 60 * 86_400), now)); assert!(near_expiry(None, now)); }
#[test]
fn normalize_host() {
assert_eq!(normalize(" Example.COM. "), "example.com");
}
}