use std::sync::{Arc, RwLock};
use std::time::Duration;
use rustls::crypto::CryptoProvider;
use crate::acme::challenge::Http01Tokens;
use crate::acme::store::{AcmeStore, CertId, StoredCert};
use crate::config::AcmeConfig;
use crate::scheduler::SchedulerCoordinator;
use crate::task::TaskCoordination;
use crate::tls::ReloadableCertResolver;
const RENEWAL_CHECK_INTERVAL: Duration = Duration::from_secs(3600);
const RENEWAL_TASK_NAME: &str = "acme-renewal";
pub type ReporterFn = Arc<dyn Fn(String) + Send + Sync>;
#[must_use]
pub const fn needs_renewal(not_after_unix: i64, renew_before_days: u32, now_unix: i64) -> bool {
let threshold = (renew_before_days as i64).saturating_mul(86_400);
not_after_unix.saturating_sub(now_unix) < threshold
}
pub fn self_signed_placeholder(domains: &[String]) -> Result<StoredCert, String> {
use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair};
if domains.is_empty() {
return Err("cannot build a placeholder certificate with no domains".to_owned());
}
let key_pair =
KeyPair::generate().map_err(|e| format!("failed to generate placeholder key: {e}"))?;
let mut params = CertificateParams::new(domains.to_vec())
.map_err(|e| format!("failed to build placeholder params: {e}"))?;
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, domains[0].clone());
params.distinguished_name = dn;
let cert = params
.self_signed(&key_pair)
.map_err(|e| format!("failed to self-sign placeholder: {e}"))?;
Ok(StoredCert {
chain_pem: cert.pem(),
key_pem: key_pair.serialize_pem(),
})
}
#[derive(Clone, Default)]
pub struct AcmeStatus(Arc<RwLock<AcmeStatusInner>>);
#[derive(Default)]
struct AcmeStatusInner {
last_success_unix: Option<i64>,
last_failure: Option<(i64, String)>,
cert_not_after_unix: Option<i64>,
}
#[derive(Clone, Default)]
pub struct AcmeStatusSnapshot {
pub last_success_unix: Option<i64>,
pub last_failure: Option<(i64, String)>,
pub cert_not_after_unix: Option<i64>,
}
impl AcmeStatus {
#[must_use]
pub fn new() -> Self {
Self::default()
}
fn write(&self) -> std::sync::RwLockWriteGuard<'_, AcmeStatusInner> {
self.0
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub fn record_success(&self, now_unix: i64, cert_not_after_unix: i64) {
let mut inner = self.write();
inner.last_success_unix = Some(now_unix);
inner.cert_not_after_unix = Some(cert_not_after_unix);
inner.last_failure = None;
}
pub fn record_failure(&self, now_unix: i64, message: impl Into<String>) {
self.write().last_failure = Some((now_unix, message.into()));
}
pub fn set_cert_not_after(&self, not_after_unix: i64) {
self.write().cert_not_after_unix = Some(not_after_unix);
}
#[must_use]
pub fn snapshot(&self) -> AcmeStatusSnapshot {
let inner = self
.0
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
AcmeStatusSnapshot {
last_success_unix: inner.last_success_unix,
last_failure: inner.last_failure.clone(),
cert_not_after_unix: inner.cert_not_after_unix,
}
}
}
pub struct AcmeHealthIndicator {
status: AcmeStatus,
renew_before_days: u32,
now_unix: fn() -> i64,
}
impl AcmeHealthIndicator {
#[must_use]
pub fn new(status: AcmeStatus, renew_before_days: u32) -> Self {
Self {
status,
renew_before_days,
now_unix: default_now_unix,
}
}
#[must_use]
pub fn grade(&self, now_unix: i64) -> crate::actuator::HealthCheckOutput {
use crate::actuator::{HealthCheckOutput, HealthStatus};
let snap = self.status.snapshot();
let mut details = std::collections::HashMap::new();
if let Some(not_after) = snap.cert_not_after_unix {
let days = (not_after - now_unix) / 86_400;
details.insert("days_until_expiry".to_owned(), serde_json::json!(days));
}
if let Some(ts) = snap.last_success_unix {
details.insert("last_success_unix".to_owned(), serde_json::json!(ts));
}
if let Some((ts, msg)) = &snap.last_failure {
details.insert("last_failure_unix".to_owned(), serde_json::json!(ts));
details.insert("last_failure".to_owned(), serde_json::json!(msg));
}
let cert_expired = snap
.cert_not_after_unix
.is_some_and(|not_after| not_after <= now_unix);
if cert_expired {
details.insert("cert_expired".to_owned(), serde_json::json!(true));
}
let in_danger = snap
.cert_not_after_unix
.is_none_or(|not_after| needs_renewal(not_after, self.renew_before_days, now_unix));
let status = if cert_expired || (snap.last_failure.is_some() && in_danger) {
HealthStatus::Down
} else {
HealthStatus::Up
};
HealthCheckOutput { status, details }
}
}
impl crate::actuator::HealthIndicator for AcmeHealthIndicator {
fn check(&self) -> futures::future::BoxFuture<'_, crate::actuator::HealthCheckOutput> {
let now = (self.now_unix)();
Box::pin(async move { self.grade(now) })
}
fn group(&self) -> crate::actuator::IndicatorGroup {
crate::actuator::IndicatorGroup::HealthOnly
}
}
fn default_now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
}
pub struct AcmeRenewalTask {
pub resolver: Arc<ReloadableCertResolver>,
pub provider: Arc<CryptoProvider>,
pub store: Arc<dyn AcmeStore>,
pub cert_id: CertId,
pub tokens: Http01Tokens,
pub status: AcmeStatus,
pub config: AcmeConfig,
pub serving_stored_cert: bool,
pub leadership_degraded: bool,
pub renew_window_misconfigured: std::sync::atomic::AtomicBool,
}
struct PublishedTokens<'a> {
tokens: &'a Http01Tokens,
published: Vec<String>,
}
impl<'a> PublishedTokens<'a> {
const fn new(tokens: &'a Http01Tokens) -> Self {
Self {
tokens,
published: Vec::new(),
}
}
fn publish(&mut self, token: String, key_authorization: String) {
self.tokens.insert(token.clone(), key_authorization);
self.published.push(token);
}
}
impl Drop for PublishedTokens<'_> {
fn drop(&mut self) {
for token in &self.published {
self.tokens.remove(token);
}
}
}
#[must_use]
fn due_at_boot(stored_not_after: Option<i64>, renew_before_days: u32, now_unix: i64) -> bool {
stored_not_after.is_none_or(|not_after| needs_renewal(not_after, renew_before_days, now_unix))
}
impl AcmeRenewalTask {
pub async fn run(
self,
coordinator: Arc<dyn SchedulerCoordinator>,
reporter: ReporterFn,
shutdown: tokio_util::sync::CancellationToken,
) {
let stored_not_after = if self.serving_stored_cert {
self.stored_not_after().await
} else {
None
};
if due_at_boot(stored_not_after, self.config.renew_before_days, now_unix()) {
self.try_renew_once(&coordinator, &reporter).await;
}
loop {
tokio::select! {
() = tokio::time::sleep(RENEWAL_CHECK_INTERVAL) => {}
() = shutdown.cancelled() => break,
}
self.maybe_renew(&coordinator, &reporter).await;
}
}
async fn maybe_renew(
&self,
coordinator: &Arc<dyn SchedulerCoordinator>,
reporter: &ReporterFn,
) {
self.adopt_stored_cert_if_newer().await;
if self
.renew_window_misconfigured
.load(std::sync::atomic::Ordering::SeqCst)
{
return;
}
let due = self.stored_not_after().await.is_none_or(|not_after| {
needs_renewal(not_after, self.config.renew_before_days, now_unix())
});
if due {
self.try_renew_once(coordinator, reporter).await;
}
}
async fn try_renew_once(
&self,
coordinator: &Arc<dyn SchedulerCoordinator>,
reporter: &ReporterFn,
) {
if self.leadership_degraded {
let msg = "ACME renewal: a distributed scheduler backend is configured but its \
coordinator is unavailable in this process — refusing to order to avoid racing \
replicas and Let's Encrypt rate limits (fix the scheduler backend / database \
connectivity, or run ACME on a single host)"
.to_owned();
tracing::error!("{msg}");
self.status.record_failure(now_unix(), msg.clone());
reporter(msg);
return;
}
let tick_key = format!("acme:{}", self.cert_id.as_str());
let lease = match coordinator
.try_acquire(RENEWAL_TASK_NAME, &tick_key, TaskCoordination::Fleet)
.await
{
Ok(Some(lease)) => lease,
Ok(None) => {
self.adopt_stored_cert_if_newer().await;
return;
}
Err(e) => {
let msg = format!("ACME renewal leader election failed: {e}");
tracing::error!("{msg}");
self.status.record_failure(now_unix(), msg.clone());
reporter(msg);
return;
}
};
let outcome = self.issue().await;
if let Err(e) = lease.release().await {
tracing::warn!(error = %e, "failed to release ACME renewal lease");
}
self.handle_issue_outcome(outcome, reporter);
}
fn handle_issue_outcome(&self, outcome: Result<i64, String>, reporter: &ReporterFn) {
match outcome {
Ok(not_after) => {
self.status.record_success(now_unix(), not_after);
tracing::info!(
cert_id = self.cert_id.as_str(),
"ACME certificate issued/renewed and hot-swapped into the TLS listener"
);
if needs_renewal(not_after, self.config.renew_before_days, now_unix()) {
let msg = format!(
"ACME renewal: renew_before_days ({}) is >= the issued certificate \
lifetime; refusing to re-order to avoid burning CA rate limits — lower \
[server.tls.acme] renew_before_days below the certificate's validity \
period",
self.config.renew_before_days
);
tracing::error!("{msg}");
self.status.record_failure(now_unix(), msg.clone());
reporter(msg);
self.renew_window_misconfigured
.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
Err(e) => {
let msg = format!("ACME certificate issuance failed: {e}");
tracing::error!("{msg}");
self.status.record_failure(now_unix(), msg.clone());
reporter(msg);
}
}
}
async fn stored_not_after(&self) -> Option<i64> {
let stored = self.store.load_cert(&self.cert_id).await.ok().flatten()?;
crate::tls::certified_key_from_pem(
stored.chain_pem.as_bytes(),
stored.key_pem.as_bytes(),
&self.provider,
)
.ok()?;
crate::tls::leaf_not_after_from_pem(stored.chain_pem.as_bytes()).ok()
}
async fn adopt_stored_cert_if_newer(&self) {
let Some(stored) = self.store.load_cert(&self.cert_id).await.ok().flatten() else {
return;
};
let Ok(not_after) = crate::tls::leaf_not_after_from_pem(stored.chain_pem.as_bytes()) else {
return;
};
let serving = self.status.snapshot().cert_not_after_unix;
if serving.is_none_or(|current| not_after > current)
&& let Ok(certified) = crate::tls::certified_key_from_pem(
stored.chain_pem.as_bytes(),
stored.key_pem.as_bytes(),
&self.provider,
)
{
self.resolver.store(certified);
self.status.set_cert_not_after(not_after);
tracing::info!("adopted a newer ACME certificate from the store");
}
}
async fn issue(&self) -> Result<i64, String> {
use instant_acme::{
AuthorizationStatus, ChallengeType, Identifier, NewOrder, OrderStatus, RetryPolicy,
};
let account = self.load_or_register_account().await?;
let identifiers: Vec<Identifier> = self
.config
.domains
.iter()
.map(|d| Identifier::Dns(d.clone()))
.collect();
let mut order = account
.new_order(&NewOrder::new(&identifiers))
.await
.map_err(|e| format!("failed to create ACME order: {e}"))?;
let mut published = PublishedTokens::new(&self.tokens);
{
let mut authorizations = order.authorizations();
while let Some(result) = authorizations.next().await {
let mut authz =
result.map_err(|e| format!("failed to fetch authorization: {e}"))?;
if authz.status == AuthorizationStatus::Valid {
continue;
}
let mut challenge = authz
.challenge(ChallengeType::Http01)
.ok_or_else(|| "authorization offered no http-01 challenge".to_owned())?;
let token = challenge.token.clone();
let key_auth = challenge.key_authorization().as_str().to_owned();
published.publish(token, key_auth);
challenge
.set_ready()
.await
.map_err(|e| format!("failed to signal challenge ready: {e}"))?;
}
}
let status = order
.poll_ready(&RetryPolicy::default())
.await
.map_err(|e| format!("order did not become ready: {e}"))?;
if status != OrderStatus::Ready {
return Err(format!("ACME order ended in unexpected state {status:?}"));
}
let (csr_der, key_pem) = self.generate_csr()?;
order
.finalize_csr(&csr_der)
.await
.map_err(|e| format!("failed to finalize order: {e}"))?;
let chain_pem = order
.poll_certificate(&RetryPolicy::default())
.await
.map_err(|e| format!("failed to download certificate: {e}"))?;
let stored = StoredCert { chain_pem, key_pem };
let not_after = crate::tls::leaf_not_after_from_pem(stored.chain_pem.as_bytes())?;
self.store
.save_cert(&self.cert_id, &stored)
.await
.map_err(|e| format!("failed to persist issued certificate: {e}"))?;
let certified = crate::tls::certified_key_from_pem(
stored.chain_pem.as_bytes(),
stored.key_pem.as_bytes(),
&self.provider,
)?;
self.resolver.store(certified);
Ok(not_after)
}
fn generate_csr(&self) -> Result<(Vec<u8>, String), String> {
use rcgen::{CertificateParams, DistinguishedName, KeyPair};
let key_pair =
KeyPair::generate().map_err(|e| format!("failed to generate cert key: {e}"))?;
let mut params = CertificateParams::new(self.config.domains.clone())
.map_err(|e| format!("failed to build CSR params: {e}"))?;
params.distinguished_name = DistinguishedName::new();
let csr = params
.serialize_request(&key_pair)
.map_err(|e| format!("failed to serialize CSR: {e}"))?;
Ok((csr.der().to_vec(), key_pair.serialize_pem()))
}
async fn load_or_register_account(&self) -> Result<instant_acme::Account, String> {
use instant_acme::{Account, AccountCredentials, NewAccount};
let directory_url = crate::acme::directory_url(&self.config.directory);
if let Some(bytes) = self
.store
.load_account()
.await
.map_err(|e| format!("failed to read stored ACME account: {e}"))?
{
let credentials: AccountCredentials = serde_json::from_slice(&bytes)
.map_err(|e| format!("stored ACME account is corrupt: {e}"))?;
let account = Account::builder()
.map_err(|e| format!("failed to build ACME client: {e}"))?
.from_credentials(credentials)
.await
.map_err(|e| format!("failed to restore ACME account: {e}"))?;
return Ok(account);
}
let contact = format!("mailto:{}", self.config.contact_email.trim());
let contacts = [contact.as_str()];
let (account, credentials) = Account::builder()
.map_err(|e| format!("failed to build ACME client: {e}"))?
.create(
&NewAccount {
contact: &contacts,
terms_of_service_agreed: true,
only_return_existing: false,
},
directory_url,
None,
)
.await
.map_err(|e| format!("failed to register ACME account: {e}"))?;
let serialized = serde_json::to_vec(&credentials)
.map_err(|e| format!("failed to serialize ACME account: {e}"))?;
self.store
.save_account(&serialized)
.await
.map_err(|e| format!("failed to persist ACME account: {e}"))?;
Ok(account)
}
}
fn now_unix() -> i64 {
default_now_unix()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::actuator::HealthStatus;
const DAY: i64 = 86_400;
#[test]
fn needs_renewal_matrix() {
let now = 1_000_000_000;
assert!(!needs_renewal(now + 60 * DAY, 30, now));
assert!(needs_renewal(now + 29 * DAY, 30, now));
assert!(!needs_renewal(now + 30 * DAY, 30, now));
assert!(needs_renewal(now - DAY, 30, now));
}
#[test]
fn placeholder_self_signed_loads_and_swaps_via_resolver() {
let placeholder =
self_signed_placeholder(&["app.example.com".to_owned()]).expect("placeholder builds");
let provider = crate::tls::crypto_provider();
let certified = crate::tls::certified_key_from_pem(
placeholder.chain_pem.as_bytes(),
placeholder.key_pem.as_bytes(),
&provider,
)
.expect("placeholder loads as a CertifiedKey");
let resolver = Arc::new(ReloadableCertResolver::new(Arc::clone(&certified)));
let next = self_signed_placeholder(&["app.example.com".to_owned()]).unwrap();
let next_key = crate::tls::certified_key_from_pem(
next.chain_pem.as_bytes(),
next.key_pem.as_bytes(),
&provider,
)
.unwrap();
resolver.store(Arc::clone(&next_key));
assert!(Arc::ptr_eq(&resolver.current(), &next_key));
assert!(crate::tls::leaf_not_after_from_pem(placeholder.chain_pem.as_bytes()).is_ok());
}
#[test]
fn placeholder_requires_a_domain() {
assert!(self_signed_placeholder(&[]).is_err());
}
#[test]
fn health_up_when_healthy() {
let now = 1_000_000_000;
let status = AcmeStatus::new();
status.record_success(now, now + 60 * DAY);
let indicator = AcmeHealthIndicator::new(status, 30);
assert_eq!(indicator.grade(now).status, HealthStatus::Up);
}
#[test]
fn health_up_on_failure_while_cert_still_valid() {
let now = 1_000_000_000;
let status = AcmeStatus::new();
status.set_cert_not_after(now + 60 * DAY);
status.record_failure(now, "temporary CA error");
let indicator = AcmeHealthIndicator::new(status, 30);
let out = indicator.grade(now);
assert_eq!(out.status, HealthStatus::Up);
assert!(out.details.contains_key("last_failure"));
}
#[test]
fn health_down_on_failure_within_expiry_danger() {
let now = 1_000_000_000;
let status = AcmeStatus::new();
status.set_cert_not_after(now + 5 * DAY);
status.record_failure(now, "CA outage");
let indicator = AcmeHealthIndicator::new(status, 30);
assert_eq!(indicator.grade(now).status, HealthStatus::Down);
}
#[test]
fn health_down_when_no_cert_and_failure() {
let now = 1_000_000_000;
let status = AcmeStatus::new();
status.record_failure(now, "never issued");
let indicator = AcmeHealthIndicator::new(status, 30);
assert_eq!(indicator.grade(now).status, HealthStatus::Down);
}
#[test]
fn health_down_when_served_cert_already_expired_without_failure() {
let now = 1_000_000_000;
let status = AcmeStatus::new();
status.set_cert_not_after(now - DAY);
assert!(
status.snapshot().last_failure.is_none(),
"precondition: no failure recorded"
);
let indicator = AcmeHealthIndicator::new(status, 30);
let out = indicator.grade(now);
assert_eq!(out.status, HealthStatus::Down);
assert_eq!(
out.details.get("cert_expired"),
Some(&serde_json::json!(true))
);
}
#[test]
fn health_up_when_cert_has_plenty_of_validity_and_no_failure() {
let now = 1_000_000_000;
let status = AcmeStatus::new();
status.set_cert_not_after(now + 60 * DAY);
let indicator = AcmeHealthIndicator::new(status, 30);
let out = indicator.grade(now);
assert_eq!(out.status, HealthStatus::Up);
assert!(!out.details.contains_key("cert_expired"));
}
#[test]
fn published_tokens_are_cleared_on_error_path() {
let tokens = Http01Tokens::new();
let outcome: Result<(), String> = {
let mut published = PublishedTokens::new(&tokens);
published.publish("token-a".to_owned(), "key-a".to_owned());
published.publish("token-b".to_owned(), "key-b".to_owned());
assert_eq!(tokens.get("token-a").as_deref(), Some("key-a"));
assert_eq!(tokens.get("token-b").as_deref(), Some("key-b"));
Err("simulated failure after publishing".to_owned())
};
assert!(outcome.is_err());
assert!(tokens.get("token-a").is_none());
assert!(tokens.get("token-b").is_none());
}
#[test]
fn published_tokens_are_cleared_on_success_path() {
let tokens = Http01Tokens::new();
{
let mut published = PublishedTokens::new(&tokens);
published.publish("token-a".to_owned(), "key-a".to_owned());
assert_eq!(tokens.get("token-a").as_deref(), Some("key-a"));
}
assert!(tokens.get("token-a").is_none());
}
#[test]
fn due_at_boot_matrix() {
let now = 1_000_000_000;
assert!(due_at_boot(None, 30, now));
assert!(!due_at_boot(Some(now + 60 * DAY), 30, now));
assert!(due_at_boot(Some(now + 5 * DAY), 30, now));
assert!(due_at_boot(Some(now - DAY), 30, now));
}
struct FailingCoordinator;
impl SchedulerCoordinator for FailingCoordinator {
fn backend(&self) -> &'static str {
"failing"
}
fn replica_id(&self) -> &'static str {
"test-replica"
}
fn try_acquire<'a>(
&'a self,
_task_name: &'a str,
_tick_key: &'a str,
_coordination: TaskCoordination,
) -> crate::scheduler::SchedulerFuture<
'a,
crate::AutumnResult<Option<crate::scheduler::SchedulerLease>>,
> {
Box::pin(async {
Err(crate::AutumnError::service_unavailable_msg(
"advisory-lock pool unavailable",
))
})
}
}
#[tokio::test]
async fn coordinator_error_records_failure_and_reports() {
let domains = vec!["app.example.com".to_owned()];
let placeholder = self_signed_placeholder(&domains).expect("placeholder builds");
let provider = crate::tls::crypto_provider();
let certified = crate::tls::certified_key_from_pem(
placeholder.chain_pem.as_bytes(),
placeholder.key_pem.as_bytes(),
&provider,
)
.expect("placeholder loads");
let resolver = Arc::new(ReloadableCertResolver::new(certified));
let store_dir = tempfile::tempdir().unwrap();
let store: Arc<dyn AcmeStore> = Arc::new(crate::acme::store::FsAcmeStore::new(
store_dir.path(),
"staging",
));
let status = AcmeStatus::new();
let config = AcmeConfig {
domains: domains.clone(),
contact_email: "ops@example.com".to_owned(),
directory: crate::config::AcmeDirectory::Staging,
cache_dir: store_dir.path().to_path_buf(),
http_challenge_port: 80,
renew_before_days: 30,
};
let task = AcmeRenewalTask {
resolver,
provider: crate::tls::crypto_provider(),
store,
cert_id: CertId::from_domains(&domains),
tokens: Http01Tokens::new(),
status: status.clone(),
config,
serving_stored_cert: false,
leadership_degraded: false,
renew_window_misconfigured: std::sync::atomic::AtomicBool::new(false),
};
let captured = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let sink = Arc::clone(&captured);
let reporter: ReporterFn = Arc::new(move |msg| sink.lock().unwrap().push(msg));
let coordinator: Arc<dyn SchedulerCoordinator> = Arc::new(FailingCoordinator);
task.try_renew_once(&coordinator, &reporter).await;
let snap = status.snapshot();
let failure = snap
.last_failure
.expect("coordinator error must be recorded as a failure");
assert!(
failure.1.contains("leader election failed"),
"unexpected failure message: {}",
failure.1
);
let msgs = captured.lock().unwrap().clone();
assert_eq!(msgs.len(), 1, "reporter must be invoked once");
assert!(msgs[0].contains("leader election failed"));
}
struct RecordingCoordinator {
acquired: Arc<std::sync::atomic::AtomicBool>,
}
impl SchedulerCoordinator for RecordingCoordinator {
fn backend(&self) -> &'static str {
"in_process"
}
fn replica_id(&self) -> &'static str {
"test-replica"
}
fn try_acquire<'a>(
&'a self,
_task_name: &'a str,
_tick_key: &'a str,
_coordination: TaskCoordination,
) -> crate::scheduler::SchedulerFuture<
'a,
crate::AutumnResult<Option<crate::scheduler::SchedulerLease>>,
> {
self.acquired
.store(true, std::sync::atomic::Ordering::SeqCst);
Box::pin(async { Ok(None) })
}
}
fn degraded_test_task(
leadership_degraded: bool,
) -> (AcmeRenewalTask, tempfile::TempDir, AcmeStatus) {
let domains = vec!["app.example.com".to_owned()];
let placeholder = self_signed_placeholder(&domains).expect("placeholder builds");
let provider = crate::tls::crypto_provider();
let certified = crate::tls::certified_key_from_pem(
placeholder.chain_pem.as_bytes(),
placeholder.key_pem.as_bytes(),
&provider,
)
.expect("placeholder loads");
let resolver = Arc::new(ReloadableCertResolver::new(certified));
let store_dir = tempfile::tempdir().unwrap();
let store: Arc<dyn AcmeStore> = Arc::new(crate::acme::store::FsAcmeStore::new(
store_dir.path(),
"staging",
));
let status = AcmeStatus::new();
let config = AcmeConfig {
domains: domains.clone(),
contact_email: "ops@example.com".to_owned(),
directory: crate::config::AcmeDirectory::Staging,
cache_dir: store_dir.path().to_path_buf(),
http_challenge_port: 80,
renew_before_days: 30,
};
let task = AcmeRenewalTask {
resolver,
provider: crate::tls::crypto_provider(),
store,
cert_id: CertId::from_domains(&domains),
tokens: Http01Tokens::new(),
status: status.clone(),
config,
serving_stored_cert: false,
leadership_degraded,
renew_window_misconfigured: std::sync::atomic::AtomicBool::new(false),
};
(task, store_dir, status)
}
#[tokio::test]
async fn leadership_degraded_refuses_to_order_and_reports() {
let (task, _store_dir, status) = degraded_test_task(true);
let captured = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let sink = Arc::clone(&captured);
let reporter: ReporterFn = Arc::new(move |msg| sink.lock().unwrap().push(msg));
let acquired = Arc::new(std::sync::atomic::AtomicBool::new(false));
let coordinator: Arc<dyn SchedulerCoordinator> = Arc::new(RecordingCoordinator {
acquired: Arc::clone(&acquired),
});
task.try_renew_once(&coordinator, &reporter).await;
assert!(
!acquired.load(std::sync::atomic::Ordering::SeqCst),
"degraded leadership must NOT acquire a lease or order"
);
let snap = status.snapshot();
let failure = snap
.last_failure
.expect("degraded leadership must be recorded as a failure");
assert!(
failure.1.contains("refusing to order"),
"unexpected failure message: {}",
failure.1
);
let msgs = captured.lock().unwrap().clone();
assert_eq!(msgs.len(), 1, "reporter must be invoked once");
assert!(msgs[0].contains("refusing to order"));
}
#[tokio::test]
async fn single_replica_path_is_unaffected_and_proceeds_to_order() {
let (task, _store_dir, status) = degraded_test_task(false);
let captured = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let sink = Arc::clone(&captured);
let reporter: ReporterFn = Arc::new(move |msg| sink.lock().unwrap().push(msg));
let acquired = Arc::new(std::sync::atomic::AtomicBool::new(false));
let coordinator: Arc<dyn SchedulerCoordinator> = Arc::new(RecordingCoordinator {
acquired: Arc::clone(&acquired),
});
task.try_renew_once(&coordinator, &reporter).await;
assert!(
acquired.load(std::sync::atomic::Ordering::SeqCst),
"single-replica path must proceed to leader election / ordering"
);
assert!(
status.snapshot().last_failure.is_none(),
"single-replica path must not record a degraded failure"
);
assert!(
captured.lock().unwrap().is_empty(),
"single-replica path must not report a degraded failure"
);
}
fn cert_valid_until_ymd(year: i32, month: u8, day: u8) -> (StoredCert, i64) {
use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair, date_time_ymd};
let key = KeyPair::generate().expect("keypair");
let mut params =
CertificateParams::new(vec!["app.example.com".to_owned()]).expect("params");
params.not_before = date_time_ymd(2020, 1, 1);
params.not_after = date_time_ymd(year, month, day);
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, "app.example.com");
params.distinguished_name = dn;
let cert = params.self_signed(&key).expect("self-sign");
let stored = StoredCert {
chain_pem: cert.pem(),
key_pem: key.serialize_pem(),
};
let not_after = crate::tls::leaf_not_after_from_pem(stored.chain_pem.as_bytes())
.expect("leaf notAfter parses");
(stored, not_after)
}
fn torn_cert_future() -> StoredCert {
let (valid, _) = cert_valid_until_ymd(2200, 1, 1);
let other = rcgen::KeyPair::generate().expect("mismatched keypair");
StoredCert {
chain_pem: valid.chain_pem,
key_pem: other.serialize_pem(),
}
}
#[tokio::test]
async fn adopt_swaps_in_a_strictly_newer_stored_cert() {
let (task, _dir, status) = degraded_test_task(false);
let initial = task.resolver.current();
let (newer, newer_na) = cert_valid_until_ymd(2200, 1, 1);
status.set_cert_not_after(newer_na - 100 * DAY);
task.store.save_cert(&task.cert_id, &newer).await.unwrap();
task.adopt_stored_cert_if_newer().await;
assert!(
!Arc::ptr_eq(&task.resolver.current(), &initial),
"a strictly-newer stored cert must be swapped into the resolver"
);
assert_eq!(status.snapshot().cert_not_after_unix, Some(newer_na));
}
#[tokio::test]
async fn adopt_does_not_downgrade_to_an_older_stored_cert() {
let (task, _dir, status) = degraded_test_task(false);
let initial = task.resolver.current();
let (older, older_na) = cert_valid_until_ymd(2100, 1, 1);
let served = older_na + 100 * DAY;
status.set_cert_not_after(served);
task.store.save_cert(&task.cert_id, &older).await.unwrap();
task.adopt_stored_cert_if_newer().await;
assert!(
Arc::ptr_eq(&task.resolver.current(), &initial),
"must not downgrade to an older stored cert"
);
assert_eq!(
status.snapshot().cert_not_after_unix,
Some(served),
"served expiry must be unchanged"
);
}
#[tokio::test]
async fn adopt_no_swap_when_store_is_empty() {
let (task, _dir, status) = degraded_test_task(false);
let initial = task.resolver.current();
status.set_cert_not_after(1_000_000_000);
task.adopt_stored_cert_if_newer().await;
assert!(Arc::ptr_eq(&task.resolver.current(), &initial));
assert_eq!(status.snapshot().cert_not_after_unix, Some(1_000_000_000));
}
#[tokio::test]
async fn maybe_renew_adopts_newer_cert_without_ordering_when_not_due() {
let (task, _dir, status) = degraded_test_task(false);
let initial = task.resolver.current();
let (newer, newer_na) = cert_valid_until_ymd(2200, 1, 1);
status.set_cert_not_after(newer_na - 100 * DAY);
task.store.save_cert(&task.cert_id, &newer).await.unwrap();
let reporter: ReporterFn = Arc::new(|_| {});
let acquired = Arc::new(std::sync::atomic::AtomicBool::new(false));
let coordinator: Arc<dyn SchedulerCoordinator> = Arc::new(RecordingCoordinator {
acquired: Arc::clone(&acquired),
});
task.maybe_renew(&coordinator, &reporter).await;
assert!(
!Arc::ptr_eq(&task.resolver.current(), &initial),
"maybe_renew must adopt a newer stored cert every tick"
);
assert_eq!(status.snapshot().cert_not_after_unix, Some(newer_na));
assert!(
!acquired.load(std::sync::atomic::Ordering::SeqCst),
"adoption must not trigger ordering when renewal is not due"
);
}
#[tokio::test]
async fn stored_not_after_is_absent_for_a_torn_pair() {
let (task, _dir, _status) = degraded_test_task(false);
let torn = torn_cert_future();
assert!(
crate::tls::leaf_not_after_from_pem(torn.chain_pem.as_bytes()).is_ok(),
"precondition: the torn chain is itself a valid, future-dated leaf"
);
task.store.save_cert(&task.cert_id, &torn).await.unwrap();
assert!(
task.stored_not_after().await.is_none(),
"a torn/mismatched pair must count as absent for the renewal decision"
);
}
#[tokio::test]
async fn stored_not_after_is_present_for_a_valid_pair() {
let (task, _dir, _status) = degraded_test_task(false);
let (valid, na) = cert_valid_until_ymd(2200, 1, 1);
task.store.save_cert(&task.cert_id, &valid).await.unwrap();
assert_eq!(
task.stored_not_after().await,
Some(na),
"a valid matching pair reports its leaf notAfter normally"
);
}
#[tokio::test]
async fn backstop_refuses_reorder_when_fresh_cert_still_needs_renewal() {
let (task, _dir, status) = degraded_test_task(false);
let captured = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let sink = Arc::clone(&captured);
let reporter: ReporterFn = Arc::new(move |msg| sink.lock().unwrap().push(msg));
task.handle_issue_outcome(Ok(now_unix() + DAY), &reporter);
let snap = status.snapshot();
let failure = snap
.last_failure
.expect("a fresh cert still due for renewal must record a failure");
assert!(
failure.1.contains("refusing to re-order"),
"unexpected failure message: {}",
failure.1
);
let msgs = captured.lock().unwrap().clone();
assert_eq!(msgs.len(), 1, "reporter must be invoked once");
assert!(msgs[0].contains("refusing to re-order"));
assert!(
task.renew_window_misconfigured
.load(std::sync::atomic::Ordering::SeqCst),
"the misconfigured-window backstop must be set"
);
let acquired = Arc::new(std::sync::atomic::AtomicBool::new(false));
let coordinator: Arc<dyn SchedulerCoordinator> = Arc::new(RecordingCoordinator {
acquired: Arc::clone(&acquired),
});
task.maybe_renew(&coordinator, &reporter).await;
assert!(
!acquired.load(std::sync::atomic::Ordering::SeqCst),
"a backed-off loop must NOT re-order (no tight loop)"
);
}
#[tokio::test]
async fn healthy_issuance_does_not_trip_backstop() {
let (task, _dir, status) = degraded_test_task(false);
let reporter: ReporterFn = Arc::new(|_| {});
task.handle_issue_outcome(Ok(now_unix() + 60 * DAY), &reporter);
let snap = status.snapshot();
assert!(
snap.last_failure.is_none(),
"a healthy issuance must not record a failure"
);
assert!(
snap.last_success_unix.is_some(),
"a healthy issuance must record success"
);
assert!(
!task
.renew_window_misconfigured
.load(std::sync::atomic::Ordering::SeqCst),
"a healthy issuance must not trip the backstop"
);
let acquired = Arc::new(std::sync::atomic::AtomicBool::new(false));
let coordinator: Arc<dyn SchedulerCoordinator> = Arc::new(RecordingCoordinator {
acquired: Arc::clone(&acquired),
});
task.maybe_renew(&coordinator, &reporter).await;
assert!(
acquired.load(std::sync::atomic::Ordering::SeqCst),
"a healthy issuance must leave the loop free to renew"
);
}
}