Skip to main content

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}