Skip to main content

didbot_tls/
renew.rs

1//! When to renew, and what a renewal that keeps failing looks like from the
2//! outside.
3//!
4//! Everything in this module is pure: no network, no clock of its own — the
5//! caller always supplies `now`. That is deliberate. "Renew well ahead of
6//! expiry" and "a renewal that has been failing for days must not first
7//! become visible as a total outage" are both decisions this module can get
8//! wrong without ever touching a CA, so they are tested that way.
9
10use time::{Duration, OffsetDateTime};
11
12/// When renewal is attempted, and how often a failing attempt retries.
13#[derive(Debug, Clone, Copy)]
14pub struct RenewalPolicy {
15    /// How long before expiry a healthy renewal cycle starts trying.
16    ///
17    /// Let's Encrypt suggests renewing at a third of a certificate's
18    /// lifetime remaining; for a 90-day certificate that is 30 days, which
19    /// is also comfortably longer than the retry ceiling below, so a renewal
20    /// that fails immediately still gets many attempts before expiry.
21    pub renew_before: Duration,
22    /// How long to wait between retries once renewal is due and failing.
23    ///
24    /// Not exponential backoff: ACME rate limits key off account and order
25    /// creation frequency, not raw request count, and a failure here is far
26    /// more likely to be "the DNS credential rotated" or "the zone stopped
27    /// answering" than transient CA load — the kind of failure a human fixes
28    /// on their own schedule, not one that clears itself if retried faster.
29    pub retry_interval: Duration,
30}
31
32impl Default for RenewalPolicy {
33    fn default() -> Self {
34        Self {
35            renew_before: Duration::days(30),
36            retry_interval: Duration::hours(1),
37        }
38    }
39}
40
41/// How urgently a failing renewal should be surfaced.
42///
43/// Three levels rather than a boolean because "renewal is failing" is not
44/// the same fact on day one of a 30-day runway as it is on day twenty-nine —
45/// the epic asks for exactly this distinction: a renewal that has been
46/// failing quietly for days must not first become visible as a total
47/// outage, which means the loudness has to climb *before* expiry, not at it.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
49pub enum AlertLevel {
50    /// Not due, or due and either succeeding or not yet retried enough times
51    /// to distinguish transient trouble from a real problem.
52    Ok,
53    /// Renewal is due and has been failing long enough that a human should
54    /// know, well before it is urgent.
55    Warning,
56    /// Renewal is failing and expiry is close enough that this is the last
57    /// reasonable window to intervene before every agent stops resolving.
58    Critical,
59}
60
61impl AlertLevel {
62    /// The name this level carries outside the process — in `GET /health`'s
63    /// body, and so in whatever alarm reads that body.
64    ///
65    /// A method rather than a `Serialize` derive because the wire spelling
66    /// is a contract with an alarm expression, and one that changes when a
67    /// variant is renamed is a contract that breaks silently. Spelled out
68    /// here, a rename is a compile error at this match instead.
69    pub fn as_str(self) -> &'static str {
70        match self {
71            Self::Ok => "ok",
72            Self::Warning => "warning",
73            Self::Critical => "critical",
74        }
75    }
76}
77
78/// How close to expiry [`AlertLevel::Critical`] starts, regardless of how
79/// many attempts have failed. An expired wildcard is a total outage, so this
80/// floor does not wait for a retry count.
81const CRITICAL_WINDOW: Duration = Duration::days(3);
82
83/// How many consecutive failures, once renewal is due, promote `Ok` to
84/// `Warning`. One is deliberately not enough — a single failed attempt an
85/// hour after the last success is exactly what the retry interval exists to
86/// absorb quietly; a pattern is what earns attention.
87const WARNING_FAILURE_THRESHOLD: u32 = 3;
88
89/// Tracks renewal attempts against one certificate's expiry, so that a
90/// caller can ask two questions without touching the network: is a renewal
91/// due right now, and how loudly should its recent history be surfaced.
92#[derive(Debug, Clone)]
93pub struct RenewalTracker {
94    current_expiry: OffsetDateTime,
95    last_attempt: Option<OffsetDateTime>,
96    last_success: Option<OffsetDateTime>,
97    consecutive_failures: u32,
98}
99
100impl RenewalTracker {
101    /// A tracker for a certificate that expires at `current_expiry` and has
102    /// no renewal history yet — the state right after loading a certificate
103    /// from storage or completing first issuance.
104    pub fn new(current_expiry: OffsetDateTime) -> Self {
105        Self {
106            current_expiry,
107            last_attempt: None,
108            last_success: None,
109            consecutive_failures: 0,
110        }
111    }
112
113    /// The expiry this tracker is currently measuring against.
114    pub fn current_expiry(&self) -> OffsetDateTime {
115        self.current_expiry
116    }
117
118    /// How many renewal attempts have failed since the last success (or
119    /// since this tracker was created, if none has succeeded yet).
120    pub fn consecutive_failures(&self) -> u32 {
121        self.consecutive_failures
122    }
123
124    /// Records a successful renewal: resets the failure count and moves the
125    /// tracked expiry to the new certificate's.
126    pub fn record_success(&mut self, at: OffsetDateTime, new_expiry: OffsetDateTime) {
127        self.last_attempt = Some(at);
128        self.last_success = Some(at);
129        self.consecutive_failures = 0;
130        self.current_expiry = new_expiry;
131    }
132
133    /// Records a failed attempt. The expiry this tracker measures against is
134    /// unchanged — the old certificate is still what is being served.
135    pub fn record_failure(&mut self, at: OffsetDateTime) {
136        self.last_attempt = Some(at);
137        self.consecutive_failures += 1;
138    }
139
140    /// Whether a renewal attempt should be made now.
141    ///
142    /// True once inside the policy's renewal window and, if the last attempt
143    /// failed, only again once `retry_interval` has passed — so a caller can
144    /// poll this on a short, fixed tick without hammering the CA on every
145    /// tick once renewal is due.
146    pub fn due(&self, now: OffsetDateTime, policy: &RenewalPolicy) -> bool {
147        if now < self.current_expiry - policy.renew_before {
148            return false;
149        }
150        match self.last_attempt {
151            None => true,
152            Some(last) => now >= last + policy.retry_interval,
153        }
154    }
155
156    /// How loudly the current state should be surfaced.
157    pub fn alert_level(&self, now: OffsetDateTime) -> AlertLevel {
158        let time_to_expiry = self.current_expiry - now;
159        if time_to_expiry <= CRITICAL_WINDOW {
160            // Inside the critical window, a certificate that has never even
161            // been renewed yet at this expiry is not "fine" just because
162            // nothing has failed — expiry itself is now the risk.
163            return AlertLevel::Critical;
164        }
165        if self.consecutive_failures >= WARNING_FAILURE_THRESHOLD {
166            return AlertLevel::Warning;
167        }
168        AlertLevel::Ok
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    fn t(days_from_now: i64) -> OffsetDateTime {
177        OffsetDateTime::now_utc() + Duration::days(days_from_now)
178    }
179
180    #[test]
181    fn not_due_far_from_expiry() {
182        let tracker = RenewalTracker::new(t(60));
183        let policy = RenewalPolicy::default();
184        assert!(!tracker.due(OffsetDateTime::now_utc(), &policy));
185    }
186
187    #[test]
188    fn due_inside_the_renewal_window() {
189        let tracker = RenewalTracker::new(t(20));
190        let policy = RenewalPolicy::default();
191        assert!(tracker.due(OffsetDateTime::now_utc(), &policy));
192    }
193
194    #[test]
195    fn a_fresh_failure_is_not_immediately_retried() {
196        let mut tracker = RenewalTracker::new(t(20));
197        let policy = RenewalPolicy::default();
198        let now = OffsetDateTime::now_utc();
199        tracker.record_failure(now);
200        assert!(!tracker.due(now + Duration::minutes(1), &policy));
201    }
202
203    #[test]
204    fn a_failure_is_retried_after_the_interval() {
205        let mut tracker = RenewalTracker::new(t(20));
206        let policy = RenewalPolicy::default();
207        let now = OffsetDateTime::now_utc();
208        tracker.record_failure(now);
209        assert!(tracker.due(now + policy.retry_interval, &policy));
210    }
211
212    #[test]
213    fn success_resets_failures_and_the_tracked_expiry() {
214        let mut tracker = RenewalTracker::new(t(20));
215        let now = OffsetDateTime::now_utc();
216        tracker.record_failure(now);
217        tracker.record_failure(now);
218        let new_expiry = t(110);
219        tracker.record_success(now, new_expiry);
220        assert_eq!(tracker.consecutive_failures(), 0);
221        assert_eq!(tracker.current_expiry(), new_expiry);
222    }
223
224    #[test]
225    fn ok_while_healthy() {
226        let tracker = RenewalTracker::new(t(60));
227        assert_eq!(
228            tracker.alert_level(OffsetDateTime::now_utc()),
229            AlertLevel::Ok
230        );
231    }
232
233    #[test]
234    fn warning_after_repeated_failures_well_before_expiry() {
235        let mut tracker = RenewalTracker::new(t(25));
236        let now = OffsetDateTime::now_utc();
237        tracker.record_failure(now);
238        tracker.record_failure(now);
239        tracker.record_failure(now);
240        assert_eq!(tracker.alert_level(now), AlertLevel::Warning);
241    }
242
243    #[test]
244    fn one_failure_alone_is_not_yet_a_warning() {
245        let mut tracker = RenewalTracker::new(t(25));
246        let now = OffsetDateTime::now_utc();
247        tracker.record_failure(now);
248        assert_eq!(tracker.alert_level(now), AlertLevel::Ok);
249    }
250
251    #[test]
252    fn critical_inside_the_expiry_floor_even_with_no_failures_recorded() {
253        // A tracker that was only just created (e.g. after a restart that
254        // lost history) with an expiry two days out must not read as
255        // healthy just because `consecutive_failures` happens to be zero.
256        let tracker = RenewalTracker::new(t(2));
257        assert_eq!(
258            tracker.alert_level(OffsetDateTime::now_utc()),
259            AlertLevel::Critical
260        );
261    }
262
263    #[test]
264    fn critical_overrides_warning_close_to_expiry() {
265        let mut tracker = RenewalTracker::new(t(2));
266        let now = OffsetDateTime::now_utc();
267        tracker.record_failure(now);
268        tracker.record_failure(now);
269        tracker.record_failure(now);
270        assert_eq!(tracker.alert_level(now), AlertLevel::Critical);
271    }
272
273    #[test]
274    fn alert_levels_order_ok_below_warning_below_critical() {
275        assert!(AlertLevel::Ok < AlertLevel::Warning);
276        assert!(AlertLevel::Warning < AlertLevel::Critical);
277    }
278}