Skip to main content

featherbit/acme/
manager.rs

1//! The renewal scheduler: one task per managed certificate that decides when
2//! to (re)issue, takes the storage lease so only one instance orders, runs
3//! `order::issue`, persists, publishes into [`ManagedCerts`], and backs off on
4//! failure. Instances that lose the lease poll storage and adopt whatever the
5//! leaseholder wrote, refreshing challenge certs meanwhile so the CA may
6//! validate through any of them.
7
8use std::collections::HashMap;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::Arc;
11use std::time::Duration;
12
13use serde::Serialize;
14use tokio::sync::{Mutex, Notify};
15use tracing::{error, info, warn};
16
17use super::challenge::TlsAlpnSolver;
18use super::client::{AcmeClient, AcmeClientFactory};
19use super::metrics::AcmeMetrics;
20use super::order::{issue, KeyType};
21use super::storage::CertStorage;
22use super::{
23    load_certified_key, now_unix, parse_cert_meta, placeholder_cert, publish, update, AcmeError,
24    CertId, CertMeta, CertState, ManagedCert, ManagedCerts,
25};
26
27pub const LEASE_TTL: Duration = Duration::from_secs(300);
28/// ARI is re-queried at most this often per certificate.
29const ARI_INTERVAL_SECS: i64 = 3_600;
30/// Placeholders are minted with one hour of validity; re-mint this long before
31/// they lapse so a slot stuck in `Placeholder` never serves an expired
32/// certificate (design section 4: "re-minted on every restart and hourly while
33/// still in Placeholder state").
34const PLACEHOLDER_REMINT_MARGIN_SECS: i64 = 300;
35
36/// Unix time at which the certificate should be renewed: `renew_before` ahead of
37/// expiry, or the start of the CA's ARI window if that comes first.
38pub fn when_to_renew(not_after: i64, ari: Option<(i64, i64)>, renew_before_secs: i64) -> i64 {
39    let by_window = not_after - renew_before_secs;
40    match ari {
41        Some((start, _)) => by_window.min(start),
42        None => by_window,
43    }
44}
45
46/// `60·2^(failures−1)` seconds, capped at one hour; `0` when there are no failures.
47pub fn backoff_secs(failures: u32) -> u64 {
48    if failures == 0 {
49        return 0;
50    }
51    60u64
52        .saturating_mul(1u64 << (failures - 1).min(10))
53        .min(3_600)
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
57#[serde(rename_all = "snake_case")]
58pub enum RenewOutcome {
59    Scheduled,
60    NotDue,
61    InProgress,
62    Unknown,
63}
64
65#[derive(Debug, Clone)]
66pub struct ManagedSlot {
67    pub id: CertId,
68    pub domains: Vec<String>,
69}
70
71#[derive(Debug, Clone)]
72pub struct ManagerConfig {
73    pub key_type: KeyType,
74    pub renew_before: Duration,
75    /// How often an idle slot re-evaluates (ARI changes, peers' writes).
76    pub poll_interval: Duration,
77    pub lease_ttl: Duration,
78    /// How often a non-leaseholder checks storage for a peer's certificate.
79    pub peer_poll: Duration,
80    /// How often a non-leaseholder refreshes challenge certs from storage.
81    pub challenge_refresh: Duration,
82}
83
84impl Default for ManagerConfig {
85    fn default() -> Self {
86        Self {
87            key_type: KeyType::EcdsaP256,
88            renew_before: Duration::from_secs(30 * 86_400),
89            poll_interval: Duration::from_secs(60),
90            lease_ttl: LEASE_TTL,
91            peer_poll: Duration::from_secs(60),
92            challenge_refresh: Duration::from_secs(2),
93        }
94    }
95}
96
97struct SlotControl {
98    waker: Notify,
99    force: AtomicBool,
100    in_flight: AtomicBool,
101}
102
103/// Aborts the wrapped task on drop — including when the future holding it is
104/// simply cancelled (e.g. a slot task dropped on shutdown) rather than run to
105/// completion, so a lease-keepalive loop can never outlive the order it was
106/// keeping alive for. A plain `abort()` call reached only via a specific
107/// success/error path does not cover that case.
108struct AbortOnDrop(tokio::task::JoinHandle<()>);
109
110impl Drop for AbortOnDrop {
111    fn drop(&mut self) {
112        self.0.abort();
113    }
114}
115
116pub struct Manager {
117    cfg: ManagerConfig,
118    factory: Arc<dyn AcmeClientFactory>,
119    client: Mutex<Option<Arc<dyn AcmeClient>>>,
120    storage: Arc<dyn CertStorage>,
121    solver: Arc<TlsAlpnSolver>,
122    certs: ManagedCerts,
123    slots: Vec<ManagedSlot>,
124    controls: HashMap<String, SlotControl>,
125    metrics: Option<Arc<AcmeMetrics>>,
126    owner: String,
127}
128
129impl Manager {
130    pub fn new(
131        cfg: ManagerConfig,
132        factory: Arc<dyn AcmeClientFactory>,
133        storage: Arc<dyn CertStorage>,
134        solver: Arc<TlsAlpnSolver>,
135        certs: ManagedCerts,
136        slots: Vec<ManagedSlot>,
137        metrics: Option<Arc<AcmeMetrics>>,
138    ) -> Arc<Self> {
139        let controls = slots
140            .iter()
141            .map(|s| {
142                (
143                    s.id.as_str().to_string(),
144                    SlotControl {
145                        waker: Notify::new(),
146                        force: AtomicBool::new(false),
147                        in_flight: AtomicBool::new(false),
148                    },
149                )
150            })
151            .collect();
152        let owner = format!(
153            "{}:{}:{}",
154            std::env::var("HOSTNAME").unwrap_or_else(|_| "gateway".into()),
155            std::process::id(),
156            uuid::Uuid::new_v4().simple()
157        );
158        Arc::new(Self {
159            cfg,
160            factory,
161            client: Mutex::new(None),
162            storage,
163            solver,
164            certs,
165            slots,
166            controls,
167            metrics,
168            owner,
169        })
170    }
171
172    pub fn slots(&self) -> &[ManagedSlot] {
173        &self.slots
174    }
175
176    pub fn in_flight(&self, id: &str) -> bool {
177        self.controls
178            .get(id)
179            .is_some_and(|c| c.in_flight.load(Ordering::SeqCst))
180    }
181
182    /// Wakes the slot's task. Without `force`, a valid certificate outside its
183    /// renewal window is left alone (`NotDue`) — Let's Encrypt's duplicate-cert
184    /// limits are the classic footgun.
185    pub fn renew_now(&self, id: &str, force: bool) -> RenewOutcome {
186        let Some(ctl) = self.controls.get(id) else {
187            return RenewOutcome::Unknown;
188        };
189        if ctl.in_flight.load(Ordering::SeqCst) {
190            return RenewOutcome::InProgress;
191        }
192        let due = self
193            .certs
194            .load()
195            .get(id)
196            .map(|c| {
197                c.state != CertState::Issued
198                    || c.meta.next_renewal_at.is_some_and(|t| t <= now_unix())
199            })
200            .unwrap_or(true);
201        if !due && !force {
202            return RenewOutcome::NotDue;
203        }
204        ctl.force.store(true, Ordering::SeqCst);
205        ctl.waker.notify_one();
206        RenewOutcome::Scheduled
207    }
208
209    /// Spawns the per-slot loops and returns.
210    pub async fn run(self: Arc<Self>) {
211        for slot in self.slots.clone() {
212            let me = self.clone();
213            tokio::spawn(async move { me.run_slot(slot).await });
214        }
215    }
216
217    async fn client(&self) -> Result<Arc<dyn AcmeClient>, AcmeError> {
218        let mut guard = self.client.lock().await;
219        if let Some(c) = guard.as_ref() {
220            return Ok(c.clone());
221        }
222        let c = self.factory.connect().await?;
223        *guard = Some(c.clone());
224        Ok(c)
225    }
226
227    fn snapshot(&self, id: &CertId) -> Option<ManagedCert> {
228        self.certs.load().get(id.as_str()).cloned()
229    }
230
231    fn observe(&self, id: &CertId) {
232        if let (Some(m), Some(c)) = (&self.metrics, self.snapshot(id)) {
233            let not_after = if c.state == CertState::Placeholder {
234                0
235            } else {
236                c.meta.not_after
237            };
238            m.observe(id.as_str(), c.state, not_after);
239        }
240    }
241
242    /// Mints a fresh self-signed placeholder when the current one is about to
243    /// lapse. Placeholders are deliberately short-lived (one hour) so they are
244    /// never mistaken for a real certificate; a slot whose issuance keeps
245    /// failing would otherwise end up serving an *expired* self-signed cert,
246    /// which fails the TLS handshake outright instead of merely failing
247    /// verification. The state stays `Placeholder` -- that is what `/readyz`
248    /// keys on -- and the recorded attempt/error history is carried over.
249    fn remint_placeholder_if_stale(&self, slot: &ManagedSlot, current: ManagedCert) -> ManagedCert {
250        if current.state != CertState::Placeholder
251            || current.meta.not_after - now_unix() >= PLACEHOLDER_REMINT_MARGIN_SECS
252        {
253            return current;
254        }
255        let (key, leaf) = match placeholder_cert(&slot.domains) {
256            Ok(v) => v,
257            Err(e) => {
258                warn!(
259                    "acme: could not re-mint the placeholder for {}: {}",
260                    slot.id, e
261                );
262                return current;
263            }
264        };
265        let meta = match parse_cert_meta(&leaf) {
266            Ok(m) => m,
267            Err(e) => {
268                warn!(
269                    "acme: could not read the re-minted placeholder for {}: {}",
270                    slot.id, e
271                );
272                return current;
273            }
274        };
275        let fresh = ManagedCert {
276            key,
277            leaf_der: leaf,
278            state: CertState::Placeholder,
279            meta: CertMeta {
280                next_renewal_at: current.meta.next_renewal_at,
281                last_attempt_at: current.meta.last_attempt_at,
282                last_error: current.meta.last_error.clone(),
283                ..meta
284            },
285            domains: slot.domains.clone(),
286        };
287        info!(
288            "acme: re-minted the self-signed placeholder for {} (still awaiting a real certificate)",
289            slot.id
290        );
291        publish(&self.certs, &slot.id, fresh);
292        self.snapshot(&slot.id).unwrap_or(current)
293    }
294
295    async fn run_slot(self: Arc<Self>, slot: ManagedSlot) {
296        let ctl = &self.controls[slot.id.as_str()];
297        let mut failures: u32 = 0;
298        let mut ari: Option<(i64, i64)> = None;
299        let mut ari_checked_at: i64 = 0;
300        self.observe(&slot.id);
301        loop {
302            let Some(current) = self.snapshot(&slot.id) else {
303                return;
304            };
305            let current = self.remint_placeholder_if_stale(&slot, current);
306            let forced = ctl.force.swap(false, Ordering::SeqCst);
307            let now = now_unix();
308
309            // Decide when this cert is due. Order matters: a forced renewal skips
310            // the backoff; a failed attempt (placeholder or not) waits it out; a
311            // placeholder with no failures yet is due immediately.
312            let due_at = if forced {
313                now
314            } else if failures > 0 {
315                current.meta.last_attempt_at.unwrap_or(now) + backoff_secs(failures) as i64
316            } else if current.state == CertState::Placeholder {
317                now
318            } else {
319                if now - ari_checked_at >= ARI_INTERVAL_SECS {
320                    if let Ok(client) = self.client().await {
321                        ari = client
322                            .renewal_window(&current.leaf_der)
323                            .await
324                            .unwrap_or(None);
325                    }
326                    ari_checked_at = now;
327                }
328                let t = when_to_renew(
329                    current.meta.not_after,
330                    ari,
331                    self.cfg.renew_before.as_secs() as i64,
332                );
333                update(&self.certs, &slot.id, |c| c.meta.next_renewal_at = Some(t));
334                t
335            };
336
337            if now < due_at {
338                let wait = Duration::from_secs((due_at - now) as u64).min(self.cfg.poll_interval);
339                tokio::select! {
340                    _ = tokio::time::sleep(wait) => {}
341                    _ = ctl.waker.notified() => {}
342                }
343                continue;
344            }
345
346            // Due: try to become the leaseholder.
347            ctl.in_flight.store(true, Ordering::SeqCst);
348            let outcome = self.attempt(&slot).await;
349            ctl.in_flight.store(false, Ordering::SeqCst);
350            match outcome {
351                Ok(true) => {
352                    failures = 0;
353                    ari_checked_at = 0; // re-query ARI for the new cert
354                }
355                Ok(false) => {
356                    // A peer holds the lease: adopt its result, keep challenges fresh.
357                    if self.follow_peer(&slot, ctl).await {
358                        // We adopted a peer's fresh certificate: any failure
359                        // streak (and cached ARI) we accumulated is about the
360                        // *old* certificate and no longer applies. Without
361                        // this reset, the next loop pass would take the
362                        // `failures > 0` branch off a stale streak and fire a
363                        // pointless duplicate order `backoff_secs(failures)`
364                        // seconds later.
365                        failures = 0;
366                        ari = None;
367                        ari_checked_at = 0;
368                    }
369                }
370                Err(e) => {
371                    failures += 1;
372                    error!(
373                        "acme: issuance for {} failed (attempt {}): {}",
374                        slot.id, failures, e
375                    );
376                    let now = now_unix();
377                    // A placeholder that fails stays `Placeholder` — that is the
378                    // sole state `/readyz` keys on (constraints.md), so it must not
379                    // flip to `Failed` just because an issuance attempt failed, or
380                    // readiness would report ready while still serving a self-signed
381                    // cert. A real cert that fails to renew becomes `Failed` but
382                    // keeps serving the last-good certificate.
383                    update(&self.certs, &slot.id, |c| {
384                        if c.state != CertState::Placeholder {
385                            c.state = CertState::Failed;
386                        }
387                        c.meta.last_attempt_at = Some(now);
388                        c.meta.last_error = Some(e.to_string());
389                        c.meta.next_renewal_at = Some(now + backoff_secs(failures) as i64);
390                    });
391                    if let Some(m) = &self.metrics {
392                        m.attempt(slot.id.as_str(), false, now);
393                    }
394                    if self
395                        .snapshot(&slot.id)
396                        .is_some_and(|c| c.state == CertState::Placeholder)
397                    {
398                        warn!(
399                            "acme: {} is still serving a self-signed placeholder",
400                            slot.id
401                        );
402                    }
403                }
404            }
405            self.observe(&slot.id);
406        }
407    }
408
409    /// One issuance under the lease. `Ok(false)` = a peer holds the lease.
410    async fn attempt(&self, slot: &ManagedSlot) -> Result<bool, AcmeError> {
411        if !self
412            .storage
413            .try_acquire_lease(&slot.id, &self.owner, self.cfg.lease_ttl)
414            .await?
415        {
416            return Ok(false);
417        }
418        update(&self.certs, &slot.id, |c| {
419            if c.state == CertState::Issued || c.state == CertState::Failed {
420                c.state = CertState::Renewing;
421            }
422        });
423        self.observe(&slot.id);
424        let result = async {
425            let client = self.client().await?;
426            // Keep the lease alive across the order *and* its persistence —
427            // an abort-on-drop guard so a cancelled slot future can't leave
428            // this loop renewing the lease forever (which would lock every
429            // peer out of it). `?` below drops (and so aborts) it on any
430            // failure; the explicit `drop` ends it right after `save_cert`.
431            let keepalive = AbortOnDrop({
432                let storage = self.storage.clone();
433                let id = slot.id.clone();
434                let owner = self.owner.clone();
435                let ttl = self.cfg.lease_ttl;
436                tokio::spawn(async move {
437                    loop {
438                        tokio::time::sleep(ttl / 4).await;
439                        let _ = storage.renew_lease(&id, &owner, ttl).await;
440                    }
441                })
442            });
443            let stored = issue(
444                client.as_ref(),
445                &self.solver,
446                &slot.domains,
447                self.cfg.key_type,
448            )
449            .await?;
450            self.storage.save_cert(&slot.id, &stored).await?;
451            drop(keepalive);
452            let (key, leaf) = load_certified_key(&stored.chain_pem, &stored.key_pem)?;
453            let meta = parse_cert_meta(&leaf)?;
454            let now = now_unix();
455            info!(
456                "acme: issued certificate for {} (serial {}, expires {})",
457                slot.id, meta.serial, meta.not_after
458            );
459            publish(
460                &self.certs,
461                &slot.id,
462                ManagedCert {
463                    key,
464                    leaf_der: leaf,
465                    state: CertState::Issued,
466                    meta: super::CertMeta {
467                        last_attempt_at: Some(now),
468                        ..meta
469                    },
470                    domains: slot.domains.clone(),
471                },
472            );
473            if let Some(m) = &self.metrics {
474                m.attempt(slot.id.as_str(), true, now);
475            }
476            Ok::<(), AcmeError>(())
477        }
478        .await;
479        if let Err(e) = self.storage.release_lease(&slot.id, &self.owner).await {
480            warn!("acme: releasing lease for {} failed: {}", slot.id, e);
481        }
482        // On failure, restore the served state (the placeholder/old cert is untouched).
483        if result.is_err() {
484            update(&self.certs, &slot.id, |c| {
485                if c.state == CertState::Renewing {
486                    c.state = CertState::Failed;
487                }
488            });
489        }
490        result.map(|_| true)
491    }
492
493    /// Non-leaseholder path: refresh challenge certs from storage every
494    /// `challenge_refresh` and look for the peer's certificate every `peer_poll`,
495    /// for at most one lease TTL (then the outer loop re-evaluates). Returns
496    /// whether a peer's certificate was adopted, so the caller can reset any
497    /// failure/backoff state that no longer applies to it.
498    async fn follow_peer(&self, slot: &ManagedSlot, ctl: &SlotControl) -> bool {
499        let deadline = tokio::time::Instant::now() + self.cfg.lease_ttl;
500        let mut next_adopt = tokio::time::Instant::now();
501        while tokio::time::Instant::now() < deadline {
502            if let Err(e) = self.solver.refresh_from_storage(&slot.domains).await {
503                warn!("acme: challenge refresh for {} failed: {}", slot.id, e);
504            }
505            if tokio::time::Instant::now() >= next_adopt {
506                next_adopt = tokio::time::Instant::now() + self.cfg.peer_poll;
507                match self.adopt_from_storage(slot).await {
508                    Ok(true) => return true,
509                    Ok(false) => {}
510                    Err(e) => warn!(
511                        "acme: reading peer certificate for {} failed: {}",
512                        slot.id, e
513                    ),
514                }
515            }
516            if ctl.force.load(Ordering::SeqCst) {
517                return false;
518            }
519            tokio::time::sleep(self.cfg.challenge_refresh).await;
520        }
521        false
522    }
523
524    /// Publishes the stored certificate when it is newer than what we serve.
525    async fn adopt_from_storage(&self, slot: &ManagedSlot) -> Result<bool, AcmeError> {
526        let Some(stored) = self.storage.load_cert(&slot.id).await? else {
527            return Ok(false);
528        };
529        let (key, leaf) = load_certified_key(&stored.chain_pem, &stored.key_pem)?;
530        let meta = parse_cert_meta(&leaf)?;
531        let current = self.snapshot(&slot.id);
532        let newer = current
533            .as_ref()
534            .map(|c| c.state == CertState::Placeholder || meta.not_after > c.meta.not_after)
535            .unwrap_or(true);
536        if !newer || meta.not_after <= now_unix() {
537            return Ok(false);
538        }
539        publish(
540            &self.certs,
541            &slot.id,
542            ManagedCert {
543                key,
544                leaf_der: leaf,
545                state: CertState::Issued,
546                meta,
547                domains: slot.domains.clone(),
548            },
549        );
550        info!("acme: adopted certificate for {} issued by a peer", slot.id);
551        self.observe(&slot.id);
552        Ok(true)
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use crate::acme::client::mock::{MockAcmeClient, MockBehavior, MockFactory, MockStep};
560    use crate::acme::storage::fs::FsCertStorage;
561    use crate::acme::{new_managed_certs, placeholder_cert, publish, CertState, ManagedCert};
562
563    #[test]
564    fn when_to_renew_takes_the_earlier_of_renew_before_and_ari() {
565        let not_after = 1_000_000;
566        assert_eq!(when_to_renew(not_after, None, 100), 999_900);
567        assert_eq!(
568            when_to_renew(not_after, Some((999_000, 999_500)), 100),
569            999_000
570        );
571        assert_eq!(
572            when_to_renew(not_after, Some((999_950, 999_990)), 100),
573            999_900
574        );
575    }
576
577    #[test]
578    fn backoff_doubles_from_a_minute_and_caps_at_an_hour() {
579        assert_eq!(backoff_secs(0), 0);
580        assert_eq!(backoff_secs(1), 60);
581        assert_eq!(backoff_secs(2), 120);
582        assert_eq!(backoff_secs(6), 1_920);
583        assert_eq!(backoff_secs(7), 3_600);
584        assert_eq!(backoff_secs(40), 3_600);
585    }
586
587    struct Harness {
588        client: Arc<MockAcmeClient>,
589        factory: Arc<MockFactory>,
590        storage: Arc<dyn CertStorage>,
591        certs: ManagedCerts,
592        slot: ManagedSlot,
593    }
594
595    fn harness(tag: &str, behavior: MockBehavior) -> Harness {
596        let d = std::env::temp_dir().join(format!("fb_acme_mgr_{}_{}", tag, std::process::id()));
597        let _ = std::fs::remove_dir_all(&d);
598        let storage: Arc<dyn CertStorage> = Arc::new(FsCertStorage::new(d));
599        let client = MockAcmeClient::new(behavior);
600        let factory = MockFactory::new(client.clone());
601        let (id, domains) = CertId::from_domains(&["m.example.com".into()]).unwrap();
602        let certs = new_managed_certs();
603        let (key, leaf) = placeholder_cert(&domains).unwrap();
604        // Real metadata, as `acme::start` seeds it: a placeholder's `not_after`
605        // is what drives re-minting, so `CertMeta::default()` (not_after 0)
606        // would look permanently lapsed here.
607        let meta = crate::acme::parse_cert_meta(&leaf).unwrap();
608        publish(
609            &certs,
610            &id,
611            ManagedCert {
612                key,
613                leaf_der: leaf,
614                state: CertState::Placeholder,
615                meta,
616                domains: domains.clone(),
617            },
618        );
619        Harness {
620            client,
621            factory,
622            storage,
623            certs,
624            slot: ManagedSlot { id, domains },
625        }
626    }
627
628    fn fast_cfg() -> ManagerConfig {
629        ManagerConfig {
630            poll_interval: Duration::from_millis(50),
631            peer_poll: Duration::from_millis(50),
632            challenge_refresh: Duration::from_millis(20),
633            ..Default::default()
634        }
635    }
636
637    fn manager(h: &Harness, cfg: ManagerConfig) -> Arc<Manager> {
638        Manager::new(
639            cfg,
640            h.factory.clone(),
641            h.storage.clone(),
642            TlsAlpnSolver::new(h.storage.clone()),
643            h.certs.clone(),
644            vec![h.slot.clone()],
645            None,
646        )
647    }
648
649    async fn wait_until(
650        certs: &ManagedCerts,
651        id: &CertId,
652        what: &str,
653        pred: impl Fn(&ManagedCert) -> bool,
654    ) -> ManagedCert {
655        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
656        loop {
657            if let Some(c) = certs.load().get(id.as_str()) {
658                if pred(c) {
659                    return c.clone();
660                }
661            }
662            assert!(
663                tokio::time::Instant::now() < deadline,
664                "timed out waiting for {what}"
665            );
666            tokio::time::sleep(Duration::from_millis(20)).await;
667        }
668    }
669
670    async fn wait_state(certs: &ManagedCerts, id: &CertId, want: CertState) -> ManagedCert {
671        wait_until(certs, id, &format!("{want:?}"), |c| c.state == want).await
672    }
673
674    /// A failed issuance attempt on a placeholder: still `Placeholder` (that is
675    /// the sole state `/readyz` keys on) but with the failure recorded.
676    async fn wait_failed_placeholder(certs: &ManagedCerts, id: &CertId) -> ManagedCert {
677        wait_until(certs, id, "placeholder with a recorded error", |c| {
678            c.state == CertState::Placeholder && c.meta.last_error.is_some()
679        })
680        .await
681    }
682
683    /// `renew_now(id, true)`, retried while the slot is `InProgress` with an
684    /// *unrelated* attempt (rather than treating that as a silently dropped
685    /// force), bounded by a deadline. A single unretried call would make the
686    /// caller's force lost with no signal beyond an eventual, confusing
687    /// timeout somewhere else.
688    async fn force_renew_scheduled(m: &Manager, id: &str) {
689        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
690        loop {
691            match m.renew_now(id, true) {
692                RenewOutcome::Scheduled => return,
693                RenewOutcome::InProgress => {}
694                other => panic!(
695                    "renew_now({id}, true) returned {other:?}, expected Scheduled or InProgress"
696                ),
697            }
698            assert!(
699                tokio::time::Instant::now() < deadline,
700                "timed out getting renew_now to schedule for {id}"
701            );
702            tokio::time::sleep(Duration::from_millis(10)).await;
703        }
704    }
705
706    /// Waits for the mock factory's remaining scripted connect failures to
707    /// reach `want` — i.e. for a connect attempt to have actually run (the
708    /// counter is decremented from inside `MockFactory::connect`). Used as a
709    /// deterministic barrier between two forced retries so the second
710    /// `renew_now` call can't coalesce with the first before the scheduler
711    /// gives the slot task a chance to run it (the `force` flag is a single
712    /// bool, so two signals delivered before either is consumed collapse into
713    /// one attempt).
714    async fn wait_fail_connects(factory: &MockFactory, want: usize) {
715        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
716        while factory
717            .fail_connects
718            .load(std::sync::atomic::Ordering::SeqCst)
719            != want
720        {
721            assert!(
722                tokio::time::Instant::now() < deadline,
723                "timed out waiting for fail_connects to reach {want}"
724            );
725            tokio::time::sleep(Duration::from_millis(10)).await;
726        }
727    }
728
729    #[tokio::test]
730    async fn placeholder_is_issued_on_start_and_persisted() {
731        let h = harness("issue", MockBehavior::default());
732        let m = manager(&h, fast_cfg());
733        m.clone().run().await;
734        let issued = wait_state(&h.certs, &h.slot.id, CertState::Issued).await;
735        assert!(issued.meta.issuer.contains("Mock ACME CA"));
736        assert!(issued.meta.next_renewal_at.is_some());
737        assert!(h.storage.load_cert(&h.slot.id).await.unwrap().is_some());
738        assert_eq!(h.client.orders(), 1);
739        // Not due: renew_now without force is refused, with force re-issues.
740        assert_eq!(m.renew_now(h.slot.id.as_str(), false), RenewOutcome::NotDue);
741        assert_eq!(m.renew_now("nope", true), RenewOutcome::Unknown);
742        assert_eq!(
743            m.renew_now(h.slot.id.as_str(), true),
744            RenewOutcome::Scheduled
745        );
746        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
747        while h.client.orders() < 2 {
748            assert!(tokio::time::Instant::now() < deadline);
749            tokio::time::sleep(Duration::from_millis(20)).await;
750        }
751        let renewed = wait_state(&h.certs, &h.slot.id, CertState::Issued).await;
752        assert_ne!(renewed.meta.serial, issued.meta.serial);
753    }
754
755    #[tokio::test]
756    async fn failure_keeps_placeholder_records_error_and_backs_off() {
757        let h = harness(
758            "fail",
759            MockBehavior {
760                fail_step: Some(MockStep::Finalize),
761                ..Default::default()
762            },
763        );
764        let before = h
765            .certs
766            .load()
767            .get(h.slot.id.as_str())
768            .unwrap()
769            .leaf_der
770            .clone();
771        let m = manager(&h, fast_cfg());
772        m.clone().run().await;
773        let failed = wait_failed_placeholder(&h.certs, &h.slot.id).await;
774        assert_eq!(
775            failed.state,
776            CertState::Placeholder,
777            "readiness stays keyed on Placeholder, not Failed"
778        );
779        assert!(failed
780            .meta
781            .last_error
782            .as_deref()
783            .unwrap_or("")
784            .contains("badCSR"));
785        assert!(failed.meta.last_attempt_at.is_some());
786        assert_eq!(failed.leaf_der, before, "placeholder keeps serving");
787        let n = h.client.orders();
788        tokio::time::sleep(Duration::from_millis(300)).await;
789        assert_eq!(
790            h.client.orders(),
791            n,
792            "backoff (≥60 s) prevents a hot retry loop"
793        );
794        // Let the CA recover and force a retry immediately.
795        h.client.set_behavior(MockBehavior::default());
796        assert_eq!(
797            m.renew_now(h.slot.id.as_str(), false),
798            RenewOutcome::Scheduled,
799            "a placeholder is always due"
800        );
801        wait_state(&h.certs, &h.slot.id, CertState::Issued).await;
802    }
803
804    #[tokio::test]
805    async fn unreachable_directory_at_start_is_retried() {
806        let h = harness("connect", MockBehavior::default());
807        h.factory
808            .fail_connects
809            .store(2, std::sync::atomic::Ordering::SeqCst);
810        let m = manager(&h, fast_cfg());
811        m.clone().run().await;
812        // Two failed connects → still Placeholder (with a recorded error) and
813        // backing off; force-renew (retried instead of a single racy call, in
814        // case it lands while the previous forced attempt is still
815        // in-flight) skips the wait for each of the two remaining connects.
816        wait_failed_placeholder(&h.certs, &h.slot.id).await;
817        force_renew_scheduled(&m, h.slot.id.as_str()).await;
818        // Deterministic barrier: wait for *this* forced attempt's connect()
819        // to have actually run (and consumed the last scripted failure)
820        // before issuing the second force, so the two forces can't collapse
821        // into a single attempt via the shared `force` flag.
822        wait_fail_connects(&h.factory, 0).await;
823        force_renew_scheduled(&m, h.slot.id.as_str()).await;
824        wait_state(&h.certs, &h.slot.id, CertState::Issued).await;
825    }
826
827    #[tokio::test]
828    async fn peer_holding_the_lease_makes_us_adopt_its_certificate() {
829        let h = harness("peer", MockBehavior::default());
830        // "Peer" holds the lease and (later) writes the cert.
831        assert!(h
832            .storage
833            .try_acquire_lease(&h.slot.id, "peer", Duration::from_secs(30))
834            .await
835            .unwrap());
836        let m = manager(&h, fast_cfg());
837        m.clone().run().await;
838        tokio::time::sleep(Duration::from_millis(200)).await;
839        assert_eq!(
840            h.client.orders(),
841            0,
842            "must not order while a peer holds the lease"
843        );
844        let solver = TlsAlpnSolver::new(h.storage.clone());
845        let stored =
846            crate::acme::order::issue(&h.client, &solver, &h.slot.domains, KeyType::EcdsaP256)
847                .await
848                .unwrap();
849        h.storage.save_cert(&h.slot.id, &stored).await.unwrap();
850        let adopted = wait_state(&h.certs, &h.slot.id, CertState::Issued).await;
851        assert!(adopted.meta.issuer.contains("Mock ACME CA"));
852        assert_eq!(h.client.orders(), 1, "only the peer's order happened");
853    }
854
855    /// Regression test for a duplicate-order bug: this node fails a local
856    /// issuance first (so its in-loop `failures` counter is nonzero), then a
857    /// peer takes the lease and writes a valid certificate that this node
858    /// adopts. Adoption must reset the stale failure/backoff state — leaving
859    /// it set would (per `backoff_secs`) eventually fire a pointless
860    /// duplicate order off the *old* failure streak even though we're now
861    /// serving a freshly issued certificate.
862    #[tokio::test]
863    async fn peer_adoption_resets_backoff_so_no_stale_duplicate_order_follows() {
864        let h = harness(
865            "peer_backoff",
866            MockBehavior {
867                fail_step: Some(MockStep::NewOrder),
868                ..Default::default()
869            },
870        );
871        let m = manager(&h, fast_cfg());
872        m.clone().run().await;
873        // Our own first attempt fails and accumulates a failure/backoff.
874        wait_failed_placeholder(&h.certs, &h.slot.id).await;
875        assert_eq!(h.client.orders(), 1);
876
877        // A peer now takes the lease (ours was released after the failed
878        // attempt) while we still have `failures > 0` recorded locally, and
879        // force us to notice — we must not order while it holds the lease.
880        assert!(h
881            .storage
882            .try_acquire_lease(&h.slot.id, "peer", Duration::from_secs(30))
883            .await
884            .unwrap());
885        force_renew_scheduled(&m, h.slot.id.as_str()).await;
886        tokio::time::sleep(Duration::from_millis(100)).await;
887        assert_eq!(
888            h.client.orders(),
889            1,
890            "must not order while a peer holds the lease"
891        );
892
893        // Let the CA recover and have the "peer" issue and persist a valid
894        // certificate while still holding the lease.
895        h.client.set_behavior(MockBehavior::default());
896        let solver = TlsAlpnSolver::new(h.storage.clone());
897        let stored =
898            crate::acme::order::issue(&h.client, &solver, &h.slot.domains, KeyType::EcdsaP256)
899                .await
900                .unwrap();
901        h.storage.save_cert(&h.slot.id, &stored).await.unwrap();
902        let adopted = wait_state(&h.certs, &h.slot.id, CertState::Issued).await;
903        assert!(adopted.meta.issuer.contains("Mock ACME CA"));
904        assert_eq!(
905            h.client.orders(),
906            2,
907            "our failed attempt + the peer's order"
908        );
909
910        // The real discriminator: `next_renewal_at` is only ever written by
911        // the ARI/renew-before branch, which only runs once `failures == 0`
912        // (the `failures > 0` branch computes a local `due_at` but never
913        // calls `update()`, so the field stays `None` — exactly what
914        // `parse_cert_meta`/`adopt_from_storage` leave it at). Seeing it
915        // become `Some` after adoption proves the reset actually happened,
916        // deterministically and fast, instead of racing (or failing to
917        // distinguish within) the real 60 s+ backoff floor.
918        let scheduled = wait_until(
919            &h.certs,
920            &h.slot.id,
921            "next_renewal_at recorded after adoption (proves the failure \
922             streak was reset, not stuck on the stale-backoff branch)",
923            |c| c.state == CertState::Issued && c.meta.next_renewal_at.is_some(),
924        )
925        .await;
926        assert!(scheduled.meta.next_renewal_at.unwrap() > now_unix());
927
928        // Secondary check, kept from the original assertion: no stale-backoff
929        // duplicate order follows adoption either.
930        tokio::time::sleep(Duration::from_millis(300)).await;
931        assert_eq!(
932            h.client.orders(),
933            2,
934            "adopting a peer's certificate must reset local backoff state, \
935             not leave a duplicate order pending"
936        );
937    }
938
939    /// A slot that never manages to issue must not end up serving an *expired*
940    /// self-signed placeholder: the manager re-mints it before the one-hour
941    /// validity lapses, without leaving `Placeholder` (readiness keys on that
942    /// state) and without losing the recorded failure.
943    #[tokio::test]
944    async fn a_placeholder_close_to_expiry_is_reminted_while_staying_a_placeholder() {
945        let h = harness(
946            "remint",
947            MockBehavior {
948                fail_step: Some(MockStep::NewOrder),
949                ..Default::default()
950            },
951        );
952        // Re-publish the seeded placeholder as one that expires in a minute.
953        let seeded = h.certs.load().get(h.slot.id.as_str()).unwrap().clone();
954        let before = seeded.leaf_der.clone();
955        publish(
956            &h.certs,
957            &h.slot.id,
958            ManagedCert {
959                meta: crate::acme::CertMeta {
960                    not_after: now_unix() + 60,
961                    ..seeded.meta.clone()
962                },
963                ..seeded
964            },
965        );
966
967        let m = manager(&h, fast_cfg());
968        m.clone().run().await;
969
970        let reminted = wait_until(&h.certs, &h.slot.id, "a re-minted placeholder", |c| {
971            c.state == CertState::Placeholder && c.leaf_der != before
972        })
973        .await;
974        assert_eq!(reminted.state, CertState::Placeholder);
975        assert!(
976            reminted.meta.not_after > now_unix() + 60,
977            "the fresh placeholder carries a full validity window: {:?}",
978            reminted.meta
979        );
980        // The failing CA's error history survives the swap.
981        wait_failed_placeholder(&h.certs, &h.slot.id).await;
982    }
983}