Skip to main content

gunnar_server/
clock.rs

1//! **The appliance's wall clock**: glue between PID 1 and edda `estate-clock`.
2//!
3//! Every rule — the sources, the quorum, the sanity window, the signed-peer
4//! protocol, step-at-boot and slew-while-running, the first fed syncs and the
5//! running triggers — lives in edda's `estate-clock` crate (where Gunnar's own
6//! `gunnar-clock` moved, lane L10 2026-10-21), which the front's gunnar-front
7//! links too. This file decides only *whether* this process owns the clock,
8//! *where* its feed is, and *what it says* about the answer.
9//!
10//! **Both wires during the move**: a box asks in `ESTATE-TIME/1` and asks again
11//! in `GUNNAR-TIME/1` when an old front answers in Gunnar's dialect
12//! (`estate_clock::peer::query`); `GUNNAR_CLOCK*` (the kit's
13//! `korp.env.GUNNAR_CLOCK=`) is read as an alias of `ESTATE_CLOCK*`
14//! (`estate_clock::config::var`).
15//!
16//! # What went wrong with the first cut (gunnar `5792dda`), measured
17//!
18//! The appliance booted two hours fast; `5792dda` asked three internet HTTPS
19//! hosts for a `Date` at boot and was re-imaged on 2026-09-14 — and the box
20//! still refused credentials `7199529 ms away`. Through the control plane, with
21//! a credential deliberately signed 7 199 500 ms fast so the gate let it
22//! through, `gunnar control mirror add … https://github.com/…` answered
23//! `github.com could not be resolved: failed to lookup address information: Try
24//! again`. UpCloud's firewall catch-all `drop` drops the replies to outbound
25//! UDP (from the front: `dig +notcp` times out, `dig +tcp` answers), musl's
26//! resolver asks over UDP only, so every source was unreachable, the verdict was
27//! "no quorum", and the clock was left alone. The only record was a log line on a
28//! VGA console. See the `estate-clock` crate note for the full measurement.
29//!
30//! # What it does now
31//!
32//! * **The source is configuration** (`korp.env.GUNNAR_CLOCK=` on the kernel
33//!   command line). On gunnar.rs that is `peer://10.13.8.99:50123` — the front,
34//!   by IP literal over the utility network, answering a nonce with a signed
35//!   timestamp (`GUNNAR_CLOCK_KEY` names its public key). No internet, no DNS.
36//!   Unset means **not configured**, and nothing is asked: an appliance in an
37//!   airgap has no business dialling Stripe for the time.
38//! * **At boot**, after the network and before any listener: one sync, steps.
39//! * **While running**, only when a credential is refused for skew (rate-limited
40//!   to one sync a minute) and on a 6-hour safety timer — `estate_clock::trigger`.
41//!   Small offsets slew, large ones step — `estate_clock::adjust`.
42//! * **Every attempt is recorded** (`estate_clock::report`) and served on the
43//!   control plane: `Admin.Health` carries the boot and the last report, and
44//!   `Bootstrap.Status` — the one call that needs no credential — carries the
45//!   last one and this server's own clock. When the clock is wrong a credential
46//!   is exactly what gets refused, so the credential-free call is the one that
47//!   makes a wrong clock diagnosable from the front.
48//!
49//! # Why it can never stop the boot or the server
50//!
51//! Every function here returns `()` and swallows everything. A forge that
52//! refuses to come up because a clock fetch failed is strictly worse than a
53//! forge with a wrong clock: the wrong clock refuses credentials, the dead
54//! forge refuses everything — including the call an operator uses to find out
55//! why. The periodic sync runs on the blocking pool, never on a request path.
56
57use estate_clock::config::{Config, CLOCK_ENV};
58use estate_clock::offer::FedRun;
59use estate_clock::report::{Outcome, Report, Trigger};
60
61pub use estate_clock::offer::{last_outcome, outcome_is_right};
62
63/// Whether this process may set the machine's clock at all.
64///
65/// Gated exactly as `crate::disktest` and `crate::dashboard` are — PID 1 **and**
66/// `korp.entry=` on the kernel command line. A container entrypoint is PID 1 of
67/// its namespace too, and a privileged container shares the host's clock:
68/// stepping it there would move somebody's real machine. Naming sources in
69/// [`CLOCK_ENV`] (or its Gunnar alias `GUNNAR_CLOCK`) explicitly turns it on
70/// regardless, which is what the installtest rig and a developer reproducing a
71/// refusal need; the syscall still needs `CAP_SYS_TIME` and a refusal is still
72/// only a report.
73fn owns_the_clock() -> bool {
74    std::env::var_os(CLOCK_ENV).is_some()
75        || estate_clock::config::gunnar_alias(CLOCK_ENV).is_some_and(|alias| std::env::var_os(alias).is_some())
76        || (appliance_pid1::net::is_init() && appliance_pid1::net::booted_by_korp_start())
77}
78
79/// Say what an attempt concluded, at the level it deserves.
80fn log(report: &Report) {
81    let summary = report.summary();
82    match report.outcome {
83        Outcome::Stepped | Outcome::Slewed | Outcome::AlreadyRight => {
84            tracing::info!(
85                outcome = report.outcome.as_str(),
86                offset_ms = report.offset_ms,
87                "clock: {summary}"
88            )
89        }
90        Outcome::NotConfigured | Outcome::Off => {
91            tracing::info!(outcome = report.outcome.as_str(), "clock: {summary}")
92        }
93        Outcome::Misconfigured | Outcome::NoQuorum | Outcome::Disagree | Outcome::ApplyFailed => {
94            tracing::warn!(
95                outcome = report.outcome.as_str(),
96                "clock: {summary}. If this box's clock is wrong, every credential it is handed \
97                 will be refused."
98            )
99        }
100    }
101    for row in &report.sources {
102        match (&row.offset_ms, &row.error) {
103            (_, Some(why)) => tracing::warn!(source = %row.source, %why, "clock source failed"),
104            (Some(offset_ms), None) => {
105                tracing::info!(source = %row.source, offset_ms, "clock source answered")
106            }
107            (None, None) => {}
108        }
109    }
110}
111
112/// One attempt on the blocking pool; the report is recorded by `sync` itself.
113async fn attempt(phase: Trigger) -> Report {
114    let config = crate::feed::clock_config();
115    match tokio::task::spawn_blocking(move || estate_clock::fetch::sync(&config, phase)).await {
116        Ok(report) => report,
117        Err(err) => {
118            let report = Report::without_sources(
119                phase,
120                Outcome::ApplyFailed,
121                format!("the clock sync task did not complete: {err}"),
122            );
123            estate_clock::report::record(report.clone());
124            report
125        }
126    }
127}
128
129/// **The boot call.** Sync once, stepping if the source says so.
130pub async fn synchronise_at_boot() {
131    if !owns_the_clock() {
132        let report = Report::without_sources(
133            Trigger::Boot,
134            Outcome::Off,
135            "not the appliance's PID 1, so the system clock is somebody else's to set",
136        );
137        tracing::debug!("clock: {}", report.summary());
138        estate_clock::report::record(report);
139        return;
140    }
141    log(&attempt(Trigger::Boot).await);
142}
143
144/// Whether a clock is configured at all (the medium's or the feed's).
145fn configured() -> bool {
146    !matches!(crate::feed::clock_config(), Ok(Config::NotConfigured) | Ok(Config::Off))
147}
148
149/// **The running triggers**: a credential refused for skew, and the 6-hour
150/// safety timer (`estate_clock::trigger::arm` has the rules and why there are
151/// only these, and arms them once per process). Called after the boot sync, and
152/// by a fed run before its first pause. Does nothing on a box that does not
153/// own its clock, or whose clock is not configured — a clock the installer's
154/// door feeds later arms them then ([`fed_arrived`]).
155///
156/// Both run on plain threads, never on the runtime: the skew handler is started
157/// by the credential gate and must not borrow a worker for a TCP exchange, and a
158/// sync is blocking by nature.
159pub fn arm_triggers() {
160    if !owns_the_clock() || !configured() {
161        return;
162    }
163    estate_clock::trigger::arm(|trigger| {
164        let config = crate::feed::clock_config();
165        log(&estate_clock::fetch::sync(&config, trigger));
166    });
167}
168
169/// One sync of the clock in force (the feed read now), logged.
170fn fed_sync() -> Outcome {
171    let config = crate::feed::clock_config();
172    let report = estate_clock::fetch::sync(&config, Trigger::Timer);
173    log(&report);
174    report.outcome
175}
176
177/// This process's fed clock runs (`estate_clock::offer::FedRun`): the
178/// triggers armed first, then syncs backing off while the front does not
179/// answer; one run at a time, off the caller's thread; the tunnel coming up
180/// cuts a pause short.
181static FED: FedRun = FedRun::new(fed_sync, arm_triggers);
182
183/// **The installer's door fed a clock source or key while this box runs**
184/// (the pairing loop's first answer, `crate::feed`). Every sync already reads
185/// the feed ([`crate::feed::clock_config`]), so this only makes the first one
186/// happen NOW — a first boot whose medium carries no clock would otherwise
187/// wait for the 6-hour timer, which was never armed — and arms the triggers.
188///
189/// ★ **Returns at once: the syncs run on their own thread** (cov-E r3
190/// 2026-10-06, runs 2 and 4): called from the installer's FEED, a twin fed
191/// before its tunnel was up once ran the whole backoff on the feed's thread
192/// and for 219 s answered nobody. A clock re-fed meanwhile gets one more run
193/// of its own when the one under way ends.
194pub fn fed_arrived() {
195    if !owns_the_clock() || !configured() {
196        return;
197    }
198    if !FED.start() {
199        tracing::warn!("clock: the fed sync could not get a thread of its own; the triggers take over");
200    }
201}
202
203/// **This box's tunnel just came up** (the pairing loop's `TunnelUp`).
204///
205/// The fed clock source is the front's utility address, reached through
206/// this tunnel on a box whose fed syncs ran before it was up (i54a run23:
207/// the twin's three syncs timed out, then it paused 20 s while its tunnel
208/// came up and the front's `BecomeTwin` was refused for skew seven times).
209/// So: a clock not yet set right is asked NOW — the pause under way is cut
210/// short, and a run is started if none is. Once per process; nothing when
211/// the clock is already right.
212pub fn tunnel_up() {
213    if !owns_the_clock() || !configured() {
214        return;
215    }
216    FED.tunnel_up(last_outcome());
217}