use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use std::time::{Duration, Instant};
use rustls::sign::CertifiedKey;
use super::{AcmeConfig, AcmeManager, RenewalCheck};
use crate::entry_point::Config;
pub const MAX_CONCURRENT_ORDERS: usize = 4;
pub const HANDSHAKE_WAIT: Duration = Duration::from_secs(90);
const REFUSED_FOR: Duration = Duration::from_secs(60);
const FAILED_FOR: Duration = Duration::from_secs(10 * 60);
const ASK_TIMEOUT_MS: u64 = 5_000;
struct OnDemand {
base: AcmeConfig,
ask: String,
dir: PathBuf,
configured: HashSet<String>,
certificates: RwLock<HashMap<String, Arc<CertifiedKey>>>,
held_off: Mutex<HashMap<String, Instant>>,
ordering: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
orders: tokio::sync::Semaphore,
}
static ON_DEMAND: OnceLock<OnDemand> = OnceLock::new();
pub fn configured() -> bool {
super::non_empty_env(Config::RWS_CONFIG_ACME_ON_DEMAND).is_some_and(|v| v.eq_ignore_ascii_case("true"))
&& super::non_empty_env(Config::RWS_CONFIG_ACME_DOMAINS).is_some()
}
pub fn enabled() -> bool {
ON_DEMAND.get().is_some()
}
pub fn start(base: &AcmeConfig) -> Result<(), String> {
let ask = super::non_empty_env(Config::RWS_CONFIG_ACME_ON_DEMAND_ASK)
.ok_or("[acme] on_demand needs on_demand_ask: without it anyone could make rws order certificates for any name")?;
if matches!(base.challenge, super::ChallengeType::Dns01 | super::ChallengeType::DnsPersist01) {
return Err(format!("[acme] on_demand works with http-01 and tls-alpn-01, not {}", base.challenge.as_str()));
}
let dir = Path::new(&base.cert_path).parent().unwrap_or(Path::new(".")).join("on-demand");
let mut configured: HashSet<String> = base.domains.iter().map(|d| d.to_ascii_lowercase()).collect();
configured.extend(crate::entry_point::get_virtual_hosts().into_iter().map(|vh| vh.domain.to_ascii_lowercase()));
let state = OnDemand {
base: base.clone(),
ask,
dir,
configured,
certificates: RwLock::default(),
held_off: Mutex::default(),
ordering: Mutex::default(),
orders: tokio::sync::Semaphore::new(MAX_CONCURRENT_ORDERS),
};
let loaded = state.load_all();
if ON_DEMAND.set(state).is_err() {
return Ok(());
}
println!("[ACME] On-demand TLS: certificates for names {} allows ({loaded} loaded).", ON_DEMAND.get().map_or("", |s| s.ask.as_str()));
tokio::spawn(renewal_loop());
Ok(())
}
pub fn certificate(server_name: &str) -> Option<Arc<CertifiedKey>> {
let state = ON_DEMAND.get()?;
state.certificates.read().unwrap_or_else(|e| e.into_inner()).get(&server_name.to_ascii_lowercase()).cloned()
}
pub async fn ensure(server_name: &str) {
let Some(state) = ON_DEMAND.get() else { return };
let name = server_name.to_ascii_lowercase();
if !state.wants(&name) {
return;
}
let order = tokio::spawn(obtain(name));
let _ = tokio::time::timeout(HANDSHAKE_WAIT, order).await;
}
pub fn valid_name(name: &str) -> bool {
if name.is_empty() || name.len() > 253 || !name.contains('.') {
return false;
}
let labels: Vec<&str> = name.split('.').collect();
let label_ok = |l: &&str| {
!l.is_empty() && l.len() <= 63 && !l.starts_with('-') && !l.ends_with('-')
&& l.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
};
labels.iter().all(label_ok) && !labels.last().is_some_and(|tld| tld.bytes().all(|b| b.is_ascii_digit()))
}
impl OnDemand {
fn wants(&self, name: &str) -> bool {
if self.configured.contains(name) || !valid_name(name) {
return false;
}
if self.certificates.read().unwrap_or_else(|e| e.into_inner()).contains_key(name) {
return false;
}
let mut held_off = self.held_off.lock().unwrap_or_else(|e| e.into_inner());
match held_off.get(name) {
Some(until) if *until > Instant::now() => false,
Some(_) => {
held_off.remove(name);
true
}
None => true,
}
}
fn hold_off(&self, name: &str, for_: Duration) {
let mut held_off = self.held_off.lock().unwrap_or_else(|e| e.into_inner());
if held_off.len() >= 10_000 {
let now = Instant::now();
held_off.retain(|_, until| *until > now);
}
held_off.insert(name.to_string(), Instant::now() + for_);
}
fn config_for(&self, name: &str) -> AcmeConfig {
let dir = self.dir.join(name);
AcmeConfig {
domains: vec![name.to_string()],
cert_path: dir.join("cert.pem").to_string_lossy().into_owned(),
key_path: dir.join("key.pem").to_string_lossy().into_owned(),
..self.base.clone()
}
}
fn install(&self, name: &str) -> bool {
let config = self.config_for(name);
match crate::tls::load_certified_key(&config.cert_path, &config.key_path) {
Ok(key) => {
if let Some(leaf) = key.cert.first() {
crate::metrics::tls::set_on_demand_certificate(name, leaf.as_ref());
}
self.certificates.write().unwrap_or_else(|e| e.into_inner()).insert(name.to_string(), Arc::new(key));
true
}
Err(e) => {
eprintln!("[ACME] On-demand certificate for '{name}' couldn't be loaded: {e}");
false
}
}
}
fn load_all(&self) -> usize {
let Ok(entries) = std::fs::read_dir(&self.dir) else { return 0 };
entries
.flatten()
.filter_map(|entry| entry.file_name().into_string().ok())
.filter(|name| valid_name(name) && !self.configured.contains(name))
.filter(|name| self.install(name))
.count()
}
async fn allowed(&self, name: &str) -> bool {
let separator = if self.ask.contains('?') { '&' } else { '?' };
let url = format!("{}{separator}domain={name}", self.ask);
match crate::http_client::AsyncClient::new().max_redirects(0).get(&url).timeout_ms(ASK_TIMEOUT_MS).send().await {
Ok(response) if response.is_success() => true,
Ok(response) => {
println!("[ACME] On-demand: the ask endpoint refused '{name}' ({}).", response.status());
false
}
Err(e) => {
eprintln!("[ACME] On-demand: the ask endpoint failed for '{name}': {e}");
false
}
}
}
}
async fn obtain(name: String) {
let Some(state) = ON_DEMAND.get() else { return };
let lock = {
let mut ordering = state.ordering.lock().unwrap_or_else(|e| e.into_inner());
Arc::clone(ordering.entry(name.clone()).or_default())
};
let _one = lock.lock().await;
let result = obtain_locked(state, &name).await;
state.ordering.lock().unwrap_or_else(|e| e.into_inner()).remove(&name);
if let Err(e) = result {
eprintln!("[ACME] On-demand certificate for '{name}' failed: {e} — not retried for {} minutes.", FAILED_FOR.as_secs() / 60);
state.hold_off(&name, FAILED_FOR);
}
}
async fn obtain_locked(state: &OnDemand, name: &str) -> Result<(), String> {
if !state.wants(name) {
return Ok(());
}
if !state.allowed(name).await {
state.hold_off(name, REFUSED_FOR);
return Ok(());
}
let _permit = state.orders.acquire().await.map_err(|e| e.to_string())?;
println!("[ACME] On-demand: getting a certificate for '{name}'.");
AcmeManager::new(state.config_for(name)).check().await?;
if state.install(name) {
Ok(())
} else {
Err("the new certificate couldn't be loaded".to_string())
}
}
async fn renewal_loop() {
let Some(state) = ON_DEMAND.get() else { return };
let interval = super::renew_check_interval_secs();
loop {
let mut next = interval;
let names: Vec<String> = state.certificates.read().unwrap_or_else(|e| e.into_inner()).keys().cloned().collect();
for name in names {
match AcmeManager::new(state.config_for(&name)).check().await {
Ok(RenewalCheck::Current { recheck_in }) => next = next.min(recheck_in),
Ok(RenewalCheck::Renewed) => {
state.install(&name);
next = next.min(60);
}
Err(e) => {
eprintln!("[ACME] On-demand renewal of '{name}' failed: {e}");
next = next.min(3600);
}
}
}
tokio::time::sleep(Duration::from_secs(next.max(60))).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_names_a_ca_would_issue_for_are_valid() {
for name in ["example.com", "shop.customer.example", "a-b.example.co", "xn--bcher-kva.example"] {
assert!(valid_name(name), "{name}");
}
for name in ["", "localhost", "*.example.com", "-a.example.com", "a-.example.com", "a..example.com",
"1.2.3.4", "Example.com", "a_b.example.com", "example.com.", &format!("{}.com", "a".repeat(64))] {
assert!(!valid_name(name), "{name}");
}
}
fn state(dir: &Path) -> OnDemand {
let base = AcmeConfig {
domains: vec!["saas.example".to_string()],
email: String::new(),
directory_url: String::new(),
cert_path: dir.join("cert.pem").to_string_lossy().into_owned(),
key_path: dir.join("key.pem").to_string_lossy().into_owned(),
challenge_port: 80,
renew_before_days: None,
profile: None,
account_key_path: String::new(),
challenge: super::super::ChallengeType::Http01,
dns: None,
};
OnDemand {
ask: "http://127.0.0.1:1/ask".to_string(),
dir: dir.join("on-demand"),
configured: base.domains.iter().cloned().collect(),
base,
certificates: RwLock::default(),
held_off: Mutex::default(),
ordering: Mutex::default(),
orders: tokio::sync::Semaphore::new(MAX_CONCURRENT_ORDERS),
}
}
#[test]
fn configured_held_off_and_served_names_are_not_ordered() {
let dir = crate::test_env::TempPath::new("rws-on-demand-wants");
let state = state(&dir);
assert!(state.wants("shop.customer.example"));
assert!(!state.wants("saas.example"));
state.hold_off("shop.customer.example", Duration::from_secs(60));
assert!(!state.wants("shop.customer.example"));
state.hold_off("other.customer.example", Duration::ZERO);
assert!(state.wants("other.customer.example"));
}
#[test]
fn each_name_gets_its_own_files_under_the_state_dir() {
let dir = crate::test_env::TempPath::new("rws-on-demand-paths");
let state = state(&dir);
let config = state.config_for("shop.customer.example");
assert_eq!(vec!["shop.customer.example".to_string()], config.domains);
assert!(config.cert_path.ends_with("on-demand/shop.customer.example/cert.pem"), "{}", config.cert_path);
assert!(config.key_path.ends_with("on-demand/shop.customer.example/key.pem"));
}
#[test]
fn certificates_ordered_before_are_loaded() {
let dir = crate::test_env::TempPath::new("rws-on-demand-load");
let state = state(&dir);
let name = "shop.customer.example";
let issued = rcgen::generate_simple_self_signed(vec![name.to_string()]).unwrap();
let files = state.config_for(name);
std::fs::create_dir_all(Path::new(&files.cert_path).parent().unwrap()).unwrap();
std::fs::write(&files.cert_path, issued.cert.pem()).unwrap();
std::fs::write(&files.key_path, issued.signing_key.serialize_pem()).unwrap();
std::fs::create_dir_all(state.dir.join("not a name")).unwrap();
assert_eq!(1, state.load_all());
assert!(!state.wants(name));
}
#[tokio::test]
async fn an_unreachable_ask_endpoint_refuses() {
let dir = crate::test_env::TempPath::new("rws-on-demand-ask");
let state = state(&dir);
assert!(!state.allowed("shop.customer.example").await);
}
}