mock_upcloud/guest_clock.rs
1//! **The clock, which is the provider's most expensive lie — except that it is
2//! not the provider's lie at all.**
3//!
4//! MEASURED on the live estate, to the second:
5//!
6//! ```text
7//! appliance − front = +7 198 668 ms (1 h 59 m 58.7 s: the CEST offset, plus ordinary error)
8//! front − laptop = +468 ms (same hypervisor, same zone, minutes older)
9//! ```
10//!
11//! Two facts, and it is the PAIR that makes this diagnosable rather than
12//! mysterious:
13//!
14//! 1. **The skew is exactly a timezone offset, never a drift.** +2 h in summer,
15//! and therefore **+1 h in winter** — because the mechanism is an RTC that
16//! holds UTC being READ as local time. So it is computed here from the zone
17//! and the date ([`utc_offset_seconds`]), not stored as a constant. A mock
18//! that hardcoded +2 h would let a fix pass in September and fail in
19//! January, which is precisely the trap the real thing sets.
20//! 2. **Not every guest gets it.** The front, on the same hypervisor, in the
21//! same zone, minutes older, was correct to under half a second. The
22//! difference is the guest's own software: the front runs an ordinary distro
23//! whose userland establishes that the RTC is UTC (`/etc/adjtime`,
24//! `systemd-timedated`); the appliance runs gunnar as PID 1 with none of
25//! that, and nothing to say so.
26//!
27//! **So the hypervisor here presents a CORRECT UTC clock and the GUEST gets it
28//! wrong.** That is where the bug lives, and a mock that skewed the clock itself
29//! would model the symptom and hide the cause — it would let a "fix" that
30//! subtracts two hours somewhere pass, which is not a fix, it is the same bug
31//! with a second sign error stacked on it.
32//!
33//! # And it cannot correct itself
34//!
35//! [`Fault::UdpInboundDropped`] is on by default because it is the provider's
36//! normal: inbound UDP replies are dropped, so DNS-over-UDP and NTP do not work
37//! and `systemd-timesyncd` is useless up there. That is the whole reason
38//! `gunnar-clock` exists and takes signed time from the FRONT and from nowhere
39//! else — which is also what makes it airgap-safe. A mock that let NTP through
40//! would let a fix that "just uses NTP" look correct here and fail there.
41//!
42//! # The cascade, which is the thing worth asserting end to end
43//!
44//! Two hours out, the box refuses the front's signed answer as too skewed, so
45//! it can never correct itself; and then every credential falls outside the
46//! ±300 000 ms window and nothing authenticates. Six of seven RED rows from one
47//! cause. [`cascade`] produces exactly those rows so a test can assert the
48//! CHAIN rather than its last link.
49//!
50//! # The rule, encoded so it cannot be cheated past
51//!
52//! **The skew window is never widened.** A row that needs a wider window to pass
53//! is a RED. [`SkewWindow`] makes that demonstrable rather than a slogan: widen
54//! it as far as you like and [`cascade`] still returns the quorum rows, because
55//! a box whose clock is wrong is still a box whose clock is wrong. There is no
56//! number that turns this green; only fixing the RTC interpretation does.
57
58use crate::faults::{Fault, Faults};
59
60/// How a guest's userland reads the hardware clock.
61///
62/// This is the whole bug, as a two-variant enum. The hypervisor's RTC holds UTC
63/// in both cases.
64#[derive(Clone, Copy, PartialEq, Eq, Debug)]
65pub enum RtcInterpretation {
66 /// An ordinary distro: `/etc/adjtime` says `UTC`, `systemd-timedated`
67 /// agrees, and the wall clock is right. **The front.**
68 Utc,
69 /// gunnar as PID 1: no `systemd-timedated`, no `/etc/adjtime`, nothing that
70 /// establishes what the RTC holds — so it is read as local time and the
71 /// wall clock lands one timezone offset ahead. **The appliance.**
72 LocalTime,
73}
74
75impl RtcInterpretation {
76 /// The skew this interpretation produces, in ms, for a zone and a moment.
77 /// `Utc` is zero by construction — not "small", zero: the front's measured
78 /// +468 ms is ordinary clock error and is modelled separately by whoever
79 /// wants it, because a mock that baked half a second of noise into the
80 /// correct case would make an exact assertion impossible.
81 pub fn skew_ms(self, zone: &str, unix_secs: i64) -> i64 {
82 match self {
83 RtcInterpretation::Utc => 0,
84 RtcInterpretation::LocalTime => utc_offset_seconds(zone, unix_secs) * 1000,
85 }
86 }
87}
88
89/// The zone's UTC offset in seconds at `unix_secs`.
90///
91/// `se-sto1` is Europe/Stockholm: CET (+1 h) in winter, CEST (+2 h) in summer,
92/// switching at 01:00 UTC on the last Sunday of March and the last Sunday of
93/// October — the EU rule, which is the same for every European zone this estate
94/// buys in. `fi-hel1`, `de-fra1`, `nl-ams1`, `uk-lon1` are here too, because a
95/// zone that is not in this table would otherwise silently answer zero and turn
96/// the whole behaviour off.
97///
98/// No `chrono`, no tz database: the rule is twelve lines and a tz database is a
99/// dependency that would have to be shipped with an airgapped appliance.
100pub fn utc_offset_seconds(zone: &str, unix_secs: i64) -> i64 {
101 let (winter, summer) = match zone {
102 // Every zone UpCloud runs that this estate has ever bought in.
103 "se-sto1" | "de-fra1" | "nl-ams1" | "es-mad1" | "pl-waw1" | "fr-par1" => (3600, 7200),
104 "fi-hel1" => (7200, 10800),
105 "uk-lon1" | "ie-dub1" | "pt-lis1" => (0, 3600),
106 // A zone outside Europe keeps no DST rule here, and says so by being
107 // absent rather than by answering a plausible zero.
108 _ => return 0,
109 };
110 if is_eu_summer_time(unix_secs) {
111 summer
112 } else {
113 winter
114 }
115}
116
117/// The EU rule: summer time runs from 01:00 UTC on the last Sunday of March to
118/// 01:00 UTC on the last Sunday of October.
119pub fn is_eu_summer_time(unix_secs: i64) -> bool {
120 let (y, _, _) = civil_from_days(unix_secs.div_euclid(86_400));
121 let start = days_from_civil(y, 3, last_sunday(y, 3)) * 86_400 + 3_600;
122 let end = days_from_civil(y, 10, last_sunday(y, 10)) * 86_400 + 3_600;
123 unix_secs >= start && unix_secs < end
124}
125
126/// The day-of-month of the last Sunday in `month`.
127fn last_sunday(y: i64, m: i64) -> i64 {
128 let last = days_in_month(y, m);
129 // 1970-01-01 was a Thursday, so `days % 7 == 3` is Sunday counting from 0.
130 for d in (1..=last).rev() {
131 let days = days_from_civil(y, m, d);
132 if days.rem_euclid(7) == 3 {
133 return d;
134 }
135 }
136 unreachable!("every month has a Sunday")
137}
138
139fn days_in_month(y: i64, m: i64) -> i64 {
140 match m {
141 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
142 4 | 6 | 9 | 11 => 30,
143 _ if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
144 _ => 28,
145 }
146}
147
148/// Howard Hinnant's `days_from_civil`. Days since 1970-01-01.
149pub fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
150 let y = if m <= 2 { y - 1 } else { y };
151 let era = y.div_euclid(400);
152 let yoe = y - era * 400;
153 let mp = if m > 2 { m - 3 } else { m + 9 };
154 let doy = (153 * mp + 2) / 5 + d - 1;
155 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
156 era * 146_097 + doe - 719_468
157}
158
159/// Its inverse.
160pub fn civil_from_days(z: i64) -> (i64, i64, i64) {
161 let z = z + 719_468;
162 let era = z.div_euclid(146_097);
163 let doe = z - era * 146_097;
164 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
165 let y = yoe + era * 400;
166 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
167 let mp = (5 * doy + 2) / 153;
168 let d = doy - (153 * mp + 2) / 5 + 1;
169 let m = if mp < 10 { mp + 3 } else { mp - 9 };
170 (if m <= 2 { y + 1 } else { y }, m, d)
171}
172
173// ── the window, and the rule that it is never widened ────────────────────────
174
175/// **±300 000 ms, and it does not move.**
176///
177/// The measured refusal, verbatim: *"the credential is 7198751 ms away from this
178/// server's clock; the window is ±300000 ms"*.
179pub const DEFAULT_WINDOW_MS: u64 = 300_000;
180
181#[derive(Clone, Copy, Debug)]
182pub struct SkewWindow {
183 pub pm_ms: u64,
184}
185
186impl Default for SkewWindow {
187 fn default() -> Self {
188 SkewWindow { pm_ms: DEFAULT_WINDOW_MS }
189 }
190}
191
192#[derive(Clone, Debug, PartialEq, Eq)]
193pub enum Verdict {
194 Accepted,
195 /// How far outside the window it fell, and the sentence a caller sees.
196 Refused { by_ms: u64, message: String },
197}
198
199impl SkewWindow {
200 pub fn check(&self, skew_ms: i64) -> Verdict {
201 let away = skew_ms.unsigned_abs();
202 if away <= self.pm_ms {
203 return Verdict::Accepted;
204 }
205 Verdict::Refused {
206 by_ms: away - self.pm_ms,
207 message: format!(
208 "the credential is {away} ms away from this server's clock; the window is ±{} ms",
209 self.pm_ms
210 ),
211 }
212 }
213}
214
215/// One row of the cascade: what went red, and why.
216#[derive(Clone, Debug, PartialEq, Eq)]
217pub struct Red {
218 pub row: &'static str,
219 pub why: String,
220}
221
222/// **The whole chain, from one wrong clock.**
223///
224/// Six of seven RED rows on the live estate came from this single cause, and a
225/// test that only asserts the last one (`monetize-poll: tenants=0`) would chase
226/// the wrong thing for an afternoon. The order here is the order of causation,
227/// not the order they were noticed in.
228///
229/// The `window` argument exists to make the rule demonstrable: **widening it
230/// does not clear the chain.** The first three rows are about the box refusing
231/// to CORRECT itself, and no window makes a wrong clock right — so a fix that
232/// reaches for a bigger number still has a red, by construction. See
233/// [`widening_the_window_still_leaves_a_red`].
234pub fn cascade(skew_ms: i64, window: SkewWindow) -> Vec<Red> {
235 let mut reds = vec![];
236 if skew_ms.unsigned_abs() <= 1_000 {
237 return reds;
238 }
239 // (1)–(3): the box cannot fix itself. The front's signed answer is refused
240 // for being further away than the box will accept, one refusal is not a
241 // quorum, and so the clock is left exactly as wrong as it was.
242 reds.push(Red {
243 row: "skew-refusal",
244 why: format!("the front's signed time is {} ms away; refused as too skewed", skew_ms.unsigned_abs()),
245 });
246 reds.push(Red { row: "no-quorum", why: "one refused answer is not a quorum".into() });
247 reds.push(Red { row: "clock-unchanged", why: "nothing was accepted, so nothing was set".into() });
248
249 // (4)–(6): and now nothing authenticates, because every credential is
250 // stamped by a clock that is right.
251 if let Verdict::Refused { message, .. } = window.check(skew_ms) {
252 reds.push(Red { row: "console-key", why: message });
253 reds.push(Red { row: "banner", why: "git.gunnar.rs:2222 connection timed out".into() });
254 reds.push(Red { row: "monetize-poll", why: "tenants=0 failures=1".into() });
255 }
256 reds
257}
258
259// ── the network half ─────────────────────────────────────────────────────────
260
261/// **Can a UDP reply get back in?** No, and it is on by default.
262///
263/// `systemd-timesyncd` sends its NTP request and waits forever; a DNS query over
264/// UDP does the same. That is why `gunnar-clock` takes signed time from the
265/// front over TCP and from nowhere else.
266pub fn udp_reply_arrives(faults: &Faults) -> bool {
267 !faults.fires(Fault::UdpInboundDropped)
268}
269
270/// What an NTP query gets. `None` is not an error — it is silence, which is the
271/// harder thing to handle and the thing that actually happens.
272pub fn ntp_answer(faults: &Faults, true_unix_secs: i64) -> Option<i64> {
273 udp_reply_arrives(faults).then_some(true_unix_secs)
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 /// **The measured pair, both halves.** The appliance is one timezone offset
281 /// ahead; the front, on the same hypervisor in the same zone, is not. If
282 /// this test ever collapses to one row it has stopped modelling the thing
283 /// that made the bug findable.
284 #[test]
285 fn the_appliance_is_skewed_and_the_front_is_not() {
286 // 2026-09-20, the day it was measured. CEST.
287 let t = days_from_civil(2026, 9, 20) * 86_400;
288 let appliance = RtcInterpretation::LocalTime.skew_ms("se-sto1", t);
289 let front = RtcInterpretation::Utc.skew_ms("se-sto1", t);
290 assert_eq!(appliance, 7_200_000, "1 h 59 m 58.7 s was measured; the mechanism is exactly 2 h");
291 assert_eq!(front, 0, "same hypervisor, same zone, correct — that is the half that names the cause");
292 }
293
294 /// **+2 h in summer and +1 h in winter, because it is an OFFSET.** A mock
295 /// that hardcoded two hours would let a fix pass in September and fail in
296 /// January.
297 #[test]
298 fn the_skew_follows_the_calendar_not_a_constant() {
299 let summer = days_from_civil(2026, 9, 20) * 86_400;
300 let winter = days_from_civil(2026, 1, 15) * 86_400;
301 assert_eq!(RtcInterpretation::LocalTime.skew_ms("se-sto1", summer), 7_200_000);
302 assert_eq!(RtcInterpretation::LocalTime.skew_ms("se-sto1", winter), 3_600_000);
303 // And Helsinki is an hour further out again, both halves of the year.
304 assert_eq!(RtcInterpretation::LocalTime.skew_ms("fi-hel1", summer), 10_800_000);
305 assert_eq!(RtcInterpretation::LocalTime.skew_ms("fi-hel1", winter), 7_200_000);
306 // London is zero in winter — a zone where this bug is INVISIBLE for
307 // half the year, which is its own trap.
308 assert_eq!(RtcInterpretation::LocalTime.skew_ms("uk-lon1", winter), 0);
309 assert_eq!(RtcInterpretation::LocalTime.skew_ms("uk-lon1", summer), 3_600_000);
310 }
311
312 /// The EU switch is 01:00 UTC on the last Sunday of March and of October.
313 /// 2026: 29 March and 25 October.
314 #[test]
315 fn the_switch_is_on_the_last_sunday() {
316 assert_eq!(last_sunday(2026, 3), 29);
317 assert_eq!(last_sunday(2026, 10), 25);
318 assert_eq!(last_sunday(2027, 3), 28);
319 let just_before = days_from_civil(2026, 3, 29) * 86_400 + 3_599;
320 let just_after = days_from_civil(2026, 3, 29) * 86_400 + 3_601;
321 assert!(!is_eu_summer_time(just_before));
322 assert!(is_eu_summer_time(just_after));
323 let oct_before = days_from_civil(2026, 10, 25) * 86_400 + 3_599;
324 let oct_after = days_from_civil(2026, 10, 25) * 86_400 + 3_601;
325 assert!(is_eu_summer_time(oct_before));
326 assert!(!is_eu_summer_time(oct_after));
327 }
328
329 #[test]
330 fn the_civil_conversions_round_trip() {
331 for (y, m, d) in [(1970, 1, 1), (2000, 2, 29), (2026, 9, 20), (2027, 12, 31)] {
332 assert_eq!(civil_from_days(days_from_civil(y, m, d)), (y, m, d));
333 }
334 }
335
336 /// The measured refusal, word for word.
337 #[test]
338 fn the_credential_refusal_is_the_measured_sentence() {
339 let w = SkewWindow::default();
340 match w.check(7_198_751) {
341 Verdict::Refused { message, .. } => assert_eq!(
342 message,
343 "the credential is 7198751 ms away from this server's clock; the window is ±300000 ms"
344 ),
345 v => panic!("{v:?}"),
346 }
347 assert_eq!(w.check(299_999), Verdict::Accepted);
348 assert_eq!(w.check(-299_999), Verdict::Accepted);
349 }
350
351 /// **Six of seven reds from one cause**, in the order of causation.
352 #[test]
353 fn one_wrong_clock_is_six_red_rows() {
354 let reds = cascade(7_198_668, SkewWindow::default());
355 let rows: Vec<&str> = reds.iter().map(|r| r.row).collect();
356 assert_eq!(
357 rows,
358 vec!["skew-refusal", "no-quorum", "clock-unchanged", "console-key", "banner", "monetize-poll"]
359 );
360 assert!(reds[3].why.contains("±300000 ms"), "{}", reds[3].why);
361 // And a correct clock is no reds at all, or the cascade would be
362 // reporting weather instead of a cause.
363 assert!(cascade(468, SkewWindow::default()).is_empty(), "the front's 468 ms is fine");
364 }
365
366 /// **THE RULE.** Widen the window as far as you like: the box still cannot
367 /// correct itself, so there is still a red. There is no number that buys a
368 /// green here, and this test is what stops anyone trying.
369 #[test]
370 fn widening_the_window_still_leaves_a_red() {
371 for window_ms in [300_000u64, 1_000_000, 7_200_000, 86_400_000] {
372 let reds = cascade(7_198_668, SkewWindow { pm_ms: window_ms });
373 assert!(
374 !reds.is_empty(),
375 "a ±{window_ms} ms window turned a wrong clock green, which must never be possible"
376 );
377 assert_eq!(reds[0].row, "skew-refusal", "the first red is always the box refusing to fix itself");
378 }
379 // The only thing that clears it is the clock being right — which is the
380 // RTC interpretation, and nothing else.
381 assert!(cascade(RtcInterpretation::Utc.skew_ms("se-sto1", 0), SkewWindow::default()).is_empty());
382 }
383
384 /// NTP is silence, not an error, and silence is the harder thing to handle.
385 #[test]
386 fn no_udp_reply_ever_comes_back() {
387 let f = Faults::none();
388 assert!(!udp_reply_arrives(&f), "dropped inbound UDP is the provider's NORMAL, so it is the default");
389 assert_eq!(ntp_answer(&f, 1_788_436_800), None);
390 f.disarm(Fault::UdpInboundDropped);
391 assert_eq!(ntp_answer(&f, 1_788_436_800), Some(1_788_436_800));
392 }
393}