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}