#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
use std::sync::{Arc, RwLock};
use std::time::Duration;
use rustls::crypto::CryptoProvider;
use crate::acme::challenge::Http01Tokens;
use crate::acme::dns::resolver::DnsLookup;
use crate::acme::dns::{DnsProvider, TxtRecord};
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 DNS01_ORDER_READY_TIMEOUT: Duration = Duration::from_secs(180);
const RENEWAL_TASK_NAME: &str = "acme-renewal";
pub type ReporterFn = Arc<dyn Fn(String) + Send + Sync>;
pub type RecoveryFn = Arc<dyn Fn() + Send + Sync>;
pub struct DnsChallenge {
pub provider: Arc<dyn DnsProvider>,
pub lookup: Arc<dyn DnsLookup>,
pub resolvers: Vec<std::net::SocketAddr>,
pub propagation_timeout: Duration,
pub poll_interval: Duration,
}
#[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,
dns_provider: Option<&'static str>,
}
impl AcmeHealthIndicator {
#[must_use]
pub fn new(status: AcmeStatus, renew_before_days: u32) -> Self {
Self {
status,
renew_before_days,
now_unix: default_now_unix,
dns_provider: None,
}
}
#[must_use]
pub const fn with_dns_provider(mut self, provider: Option<&'static str>) -> Self {
self.dns_provider = provider;
self
}
#[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();
details.insert(
"challenge".to_owned(),
serde_json::json!(if self.dns_provider.is_some() {
"dns-01"
} else {
"http-01"
}),
);
if let Some(provider) = self.dns_provider {
details.insert("dns_provider".to_owned(), serde_json::json!(provider));
}
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
}
}
#[allow(
clippy::disallowed_methods,
reason = "compares real X.509 expiry dates from a real ACME server"
)]
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,
pub dns: Option<DnsChallenge>,
pub recovery: Option<RecoveryFn>,
}
pub(crate) struct PublishedTokens<'a> {
tokens: &'a Http01Tokens,
published: Vec<String>,
}
impl<'a> PublishedTokens<'a> {
pub(crate) 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) => {
let recovered_from_failure = self.status.snapshot().last_failure.is_some();
self.status.record_success(now_unix(), not_after);
if recovered_from_failure && let Some(recovery) = &self.recovery {
recovery();
}
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::{Identifier, NewOrder};
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}"))?;
if let Some(dns) = &self.dns {
self.answer_dns01(&mut order, dns).await?;
} else {
let published = self.answer_http01(&mut order).await?;
let ready = self.await_order_ready(&mut order).await;
drop(published);
ready?;
}
self.finalize_and_install(&mut order).await
}
async fn answer_http01<'a>(
&'a self,
order: &mut instant_acme::Order,
) -> Result<PublishedTokens<'a>, String> {
answer_http01(&self.tokens, order).await
}
async fn answer_dns01(
&self,
order: &mut instant_acme::Order,
dns: &DnsChallenge,
) -> Result<(), String> {
let wanted = self.collect_dns01_records(order).await?;
if wanted.is_empty() {
return self.await_order_ready(order).await;
}
let mut published: Vec<TxtRecord> = Vec::new();
let outcome = self
.publish_and_validate_dns01(order, dns, &wanted, &mut published)
.await;
Self::cleanup_dns01(dns, &published).await;
outcome
}
async fn publish_and_validate_dns01(
&self,
order: &mut instant_acme::Order,
dns: &DnsChallenge,
wanted: &[TxtRecord],
published: &mut Vec<TxtRecord>,
) -> Result<(), String> {
published.extend(wanted.iter().cloned());
dns.provider.upsert_txt_batch(wanted).await.map_err(|e| {
format!(
"failed to publish the DNS-01 TXT records via the {} provider: {e}",
dns.provider.name()
)
})?;
let probe_targets = Self::dns01_probe_targets(dns, wanted).await;
crate::acme::dns::resolver::wait_for_propagation(
wanted,
&probe_targets,
dns.propagation_timeout,
dns.poll_interval,
dns.lookup.as_ref(),
)
.await?;
Self::signal_dns01_ready(order).await?;
self.await_order_ready(order).await
}
async fn dns01_probe_targets(
dns: &DnsChallenge,
wanted: &[TxtRecord],
) -> crate::acme::dns::resolver::ProbeTargets {
let mut targets = crate::acme::dns::resolver::ProbeTargets::flat(&dns.resolvers);
let mut discovered: Vec<&str> = Vec::new();
for record in wanted {
if discovered.contains(&record.fqdn.as_str()) {
continue;
}
discovered.push(&record.fqdn);
let authoritative = crate::acme::dns::resolver::authoritative_resolvers(
&record.fqdn,
&dns.resolvers,
dns.lookup.as_ref(),
)
.await;
if authoritative.is_empty() {
tracing::warn!(
fqdn = record.fqdn,
"could not discover the authoritative nameservers for the DNS-01 challenge \
zone; falling back to the configured resolvers for this name. A recursive \
resolver can cache a negative answer for longer than the propagation \
budget, so raise [server.tls.acme.dns] propagation_timeout_secs if issuance \
times out"
);
continue;
}
tracing::debug!(
fqdn = record.fqdn,
servers = authoritative.len(),
"probing the DNS-01 challenge zone's authoritative nameservers"
);
targets.set_authoritative(&record.fqdn, authoritative);
}
targets
}
async fn collect_dns01_records(
&self,
order: &mut instant_acme::Order,
) -> Result<Vec<TxtRecord>, String> {
use instant_acme::{AuthorizationStatus, ChallengeType, Identifier};
let mut records = Vec::new();
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 domain = match authz.identifier().identifier {
Identifier::Dns(dns) => dns.clone(),
other => {
return Err(format!(
"the DNS-01 challenge can only answer a DNS identifier, but this \
authorization is for {other:?}"
));
}
};
if !Self::is_ordered_identifier(&self.config.domains, &domain) {
return Err(format!(
"the CA returned an authorization for `{domain}`, which is not one of the \
configured [server.tls.acme] domains; refusing to publish a challenge record \
for it"
));
}
let challenge = authz.challenge(ChallengeType::Dns01).ok_or_else(|| {
format!(
"the CA offered no dns-01 challenge for `{domain}`; a wildcard certificate \
cannot be issued without it"
)
})?;
records.push(TxtRecord::new(
&domain,
challenge.key_authorization().dns_value(),
));
}
Ok(records)
}
fn is_ordered_identifier(domains: &[String], identifier: &str) -> bool {
let identifier = identifier.trim().trim_end_matches('.').to_ascii_lowercase();
domains.iter().any(|domain| {
let domain = domain.trim().trim_end_matches('.').to_ascii_lowercase();
domain == identifier || domain.strip_prefix("*.") == Some(identifier.as_str())
})
}
async fn signal_dns01_ready(order: &mut instant_acme::Order) -> Result<(), String> {
use instant_acme::{AuthorizationStatus, ChallengeType};
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::Dns01)
.ok_or_else(|| "authorization offered no dns-01 challenge".to_owned())?;
challenge
.set_ready()
.await
.map_err(|e| format!("failed to signal the dns-01 challenge ready: {e}"))?;
}
Ok(())
}
async fn cleanup_dns01(dns: &DnsChallenge, published: &[TxtRecord]) {
if published.is_empty() {
return;
}
if let Err(e) = dns.provider.delete_txt_batch(published).await {
tracing::warn!(
provider = dns.provider.name(),
error = %e,
"could not remove the DNS-01 challenge TXT records; they will be overwritten by \
the next issuance but can be deleted by hand"
);
}
}
async fn await_order_ready(&self, order: &mut instant_acme::Order) -> Result<(), String> {
await_order_ready(
order,
self.dns.is_some().then_some(DNS01_ORDER_READY_TIMEOUT),
)
.await
}
async fn finalize_and_install(&self, order: &mut instant_acme::Order) -> Result<i64, String> {
use instant_acme::RetryPolicy;
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> {
generate_csr(&self.config.domains)
}
async fn load_or_register_account(&self) -> Result<instant_acme::Account, String> {
load_or_register_account(self.store.as_ref(), &self.config).await
}
}
pub(crate) async fn answer_http01<'a>(
tokens: &'a Http01Tokens,
order: &mut instant_acme::Order,
) -> Result<PublishedTokens<'a>, String> {
use instant_acme::{AuthorizationStatus, ChallengeType};
let mut published = PublishedTokens::new(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}"))?;
}
Ok(published)
}
pub(crate) async fn await_order_ready(
order: &mut instant_acme::Order,
timeout: Option<Duration>,
) -> Result<(), String> {
use instant_acme::{OrderStatus, RetryPolicy};
let policy = timeout.map_or_else(RetryPolicy::default, |t| RetryPolicy::default().timeout(t));
let status = order
.poll_ready(&policy)
.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:?}"));
}
Ok(())
}
pub(crate) fn generate_csr(domains: &[String]) -> 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(domains.to_vec())
.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()))
}
pub(crate) fn account_builder(config: &AcmeConfig) -> Result<instant_acme::AccountBuilder, String> {
ensure_default_crypto_provider();
config.ca_root_path.as_ref().map_or_else(
|| {
instant_acme::Account::builder()
.map_err(|e| format!("failed to build ACME client: {e}"))
},
|path| {
instant_acme::Account::builder_with_root(path).map_err(|e| {
format!(
"failed to build ACME client with [server.tls.acme] ca_root_path {}: {e}",
path.display()
)
})
},
)
}
pub(crate) async fn load_or_register_account(
store: &dyn AcmeStore,
config: &AcmeConfig,
) -> Result<instant_acme::Account, String> {
use instant_acme::{AccountCredentials, NewAccount};
static ACCOUNT_INIT: std::sync::LazyLock<tokio::sync::Mutex<()>> =
std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
let _init = ACCOUNT_INIT.lock().await;
let directory_url = crate::acme::directory_url(&config.directory);
if let Some(bytes) = 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(config)?
.from_credentials(credentials)
.await
.map_err(|e| format!("failed to restore ACME account: {e}"))?;
return Ok(account);
}
let contact = format!("mailto:{}", config.contact_email.trim());
let contacts = [contact.as_str()];
let (account, credentials) = account_builder(config)?
.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}"))?;
store
.save_account(&serialized)
.await
.map_err(|e| format!("failed to persist ACME account: {e}"))?;
Ok(account)
}
pub(crate) fn ensure_default_crypto_provider() {
if rustls::crypto::CryptoProvider::get_default().is_none() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
}
fn now_unix() -> i64 {
default_now_unix()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::actuator::HealthStatus;
struct NoopProvider;
impl crate::acme::dns::DnsProvider for NoopProvider {
fn name(&self) -> &'static str {
"noop"
}
fn upsert_txt<'a>(
&'a self,
_record: &'a TxtRecord,
) -> futures::future::BoxFuture<'a, Result<(), String>> {
Box::pin(async { Ok(()) })
}
fn delete_txt<'a>(
&'a self,
_record: &'a TxtRecord,
) -> futures::future::BoxFuture<'a, Result<(), String>> {
Box::pin(async { Ok(()) })
}
}
#[tokio::test]
async fn probe_targets_are_discovered_per_zone() {
use crate::acme::dns::resolver::{DnsAnswer, DnsLookup, Rdata, ResourceRecord};
use std::net::SocketAddr;
struct ScriptedZoneLookup {
zones: Vec<(&'static str, &'static str, SocketAddr)>,
asked: std::sync::Mutex<Vec<(SocketAddr, String, u16)>>,
}
impl DnsLookup for ScriptedZoneLookup {
fn query<'a>(
&'a self,
server: SocketAddr,
name: &'a str,
qtype: u16,
_recursion_desired: bool,
) -> futures::future::BoxFuture<'a, Result<DnsAnswer, String>> {
self.asked
.lock()
.unwrap()
.push((server, name.to_owned(), qtype));
let name = name.to_owned();
let zones = self.zones.clone();
Box::pin(async move {
let records = match qtype {
2 => zones
.iter()
.find(|(zone, ..)| *zone == name)
.map(|(zone, ns, _)| ResourceRecord {
name: (*zone).to_owned(),
rtype: 2,
rdata: Rdata::Name((*ns).to_owned()),
})
.into_iter()
.collect::<Vec<_>>(),
1 => zones
.iter()
.find(|(_, ns, _)| *ns == name)
.map(|(_, ns, addr)| ResourceRecord {
name: (*ns).to_owned(),
rtype: 1,
rdata: Rdata::A(match addr.ip() {
std::net::IpAddr::V4(v4) => v4,
std::net::IpAddr::V6(_) => unreachable!("scripted as v4"),
}),
})
.into_iter()
.collect(),
_ => Vec::new(),
};
Ok(DnsAnswer { rcode: 0, records })
})
}
}
let ns_a: SocketAddr = "10.0.0.1:53".parse().unwrap();
let ns_b: SocketAddr = "10.0.0.2:53".parse().unwrap();
let recursive: SocketAddr = "127.0.0.1:53".parse().unwrap();
let lookup = std::sync::Arc::new(ScriptedZoneLookup {
zones: vec![
("myapp.com", "ns-a.example", ns_a),
("myapp.io", "ns-b.example", ns_b),
],
asked: std::sync::Mutex::new(Vec::new()),
});
let dns = DnsChallenge {
provider: std::sync::Arc::new(NoopProvider),
lookup: std::sync::Arc::clone(&lookup) as std::sync::Arc<dyn DnsLookup>,
resolvers: vec![recursive],
propagation_timeout: Duration::from_secs(1),
poll_interval: Duration::from_millis(10),
};
let wanted = vec![
TxtRecord::new("myapp.com", "value-apex"),
TxtRecord::new("myapp.com", "value-wildcard"),
TxtRecord::new("myapp.io", "value-io"),
];
let targets = AcmeRenewalTask::dns01_probe_targets(&dns, &wanted).await;
assert_eq!(
targets.for_name("_acme-challenge.myapp.com"),
([ns_a].as_slice(), true),
"the .com name must be probed at its own zone's nameserver, authoritatively"
);
assert_eq!(
targets.for_name("_acme-challenge.myapp.io"),
([ns_b].as_slice(), true),
"the .io name must NOT inherit the first zone's nameserver — it is not \
authoritative for .io and can only ever answer a referral"
);
let ns_queries = lookup
.asked
.lock()
.unwrap()
.iter()
.filter(|(_, name, qtype)| *qtype == 2 && name == "myapp.com")
.count();
assert_eq!(
ns_queries, 1,
"one NS discovery per distinct challenge name"
);
}
#[tokio::test]
async fn undiscoverable_zones_fall_back_without_affecting_their_neighbours() {
use crate::acme::dns::resolver::{DnsAnswer, DnsLookup, Rdata, ResourceRecord};
use std::net::SocketAddr;
struct OnlyComIsDiscoverable;
impl DnsLookup for OnlyComIsDiscoverable {
fn query<'a>(
&'a self,
_server: SocketAddr,
name: &'a str,
qtype: u16,
_recursion_desired: bool,
) -> futures::future::BoxFuture<'a, Result<DnsAnswer, String>> {
let name = name.to_owned();
Box::pin(async move {
let records = match (qtype, name.as_str()) {
(2, "myapp.com") => vec![ResourceRecord {
name: name.clone(),
rtype: 2,
rdata: Rdata::Name("ns-a.example".to_owned()),
}],
(1, "ns-a.example") => vec![ResourceRecord {
name: name.clone(),
rtype: 1,
rdata: Rdata::A(std::net::Ipv4Addr::new(10, 0, 0, 1)),
}],
_ => Vec::new(),
};
Ok(DnsAnswer { rcode: 0, records })
})
}
}
let recursive: SocketAddr = "127.0.0.1:53".parse().unwrap();
let dns = DnsChallenge {
provider: std::sync::Arc::new(NoopProvider),
lookup: std::sync::Arc::new(OnlyComIsDiscoverable),
resolvers: vec![recursive],
propagation_timeout: Duration::from_secs(1),
poll_interval: Duration::from_millis(10),
};
let wanted = vec![
TxtRecord::new("myapp.com", "value-com"),
TxtRecord::new("myapp.io", "value-io"),
];
let targets = AcmeRenewalTask::dns01_probe_targets(&dns, &wanted).await;
assert_eq!(
targets.for_name("_acme-challenge.myapp.com"),
([SocketAddr::from(([10, 0, 0, 1], 53))].as_slice(), true),
"the discoverable zone keeps its authoritative servers"
);
assert_eq!(
targets.for_name("_acme-challenge.myapp.io"),
([recursive].as_slice(), false),
"the undiscoverable zone falls back to the configured resolvers — and is \
marked non-authoritative, so its probe asks them to actually resolve"
);
}
const DAY: i64 = 86_400;
#[test]
fn building_an_acme_client_guarantees_a_process_crypto_provider() {
ensure_default_crypto_provider();
assert!(
rustls::crypto::CryptoProvider::get_default().is_some(),
"no process-level CryptoProvider installed; every ACME order would panic"
);
ensure_default_crypto_provider();
assert!(rustls::crypto::CryptoProvider::get_default().is_some());
}
#[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,
ca_root_path: None,
dns: None,
custom_domains: None,
};
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),
dns: None,
recovery: None,
};
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,
ca_root_path: None,
dns: None,
custom_domains: None,
};
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),
dns: None,
recovery: None,
};
(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"
);
}
}